Skip to main content

headless_lms_mcp/http/
auth.rs

1//! Authentication for the MCP server at `/api/v0/mcp`, and the RFC 9728 metadata its 401
2//! challenge points to.
3
4use actix_web::{
5    Error, HttpMessage, HttpResponse,
6    body::{EitherBody, MessageBody},
7    dev::{ServiceRequest, ServiceResponse},
8    http::header,
9    middleware::Next,
10    web,
11};
12use headless_lms_base::config::ApplicationConfiguration;
13use headless_lms_models::library::oauth::{
14    bearer::{BearerTokenError, TokenSubject, bearer_token},
15    mcp::{McpScope, authenticate_mcp_access_token},
16};
17use serde_json::json;
18
19use super::McpEndpoint;
20use crate::protocol::www_authenticate_challenge;
21
22/// Where the MCP server's RFC 9728 protected resource metadata is served.
23pub fn protected_resource_metadata_url(app_conf: &ApplicationConfiguration) -> String {
24    app_conf.public_url("/.well-known/oauth-protected-resource/api/v0/mcp")
25}
26
27/**
28GET `/.well-known/oauth-protected-resource/api/v0/mcp` - RFC 9728 protected resource metadata of the
29MCP server: which authorization server issues its tokens and which scopes exist.
30*/
31pub async fn protected_resource_metadata(
32    app_conf: web::Data<ApplicationConfiguration>,
33) -> HttpResponse {
34    HttpResponse::Ok().json(json!({
35        "resource": app_conf.mcp_resource_url(),
36        "authorization_servers": [app_conf.oauth_issuer()],
37        "scopes_supported": McpScope::all_strs(),
38        "bearer_methods_supported": ["header"],
39        "resource_name": "courses.mooc.fi course material",
40    }))
41}
42
43/// Middleware for the MCP route scope, through `actix_web::middleware::from_fn`: validates the
44/// Bearer token for the MCP audience and puts the `McpPrincipal` and its `TokenSubject` in the
45/// request extensions, or answers 401 with the challenge that starts the client's OAuth flow.
46pub async fn authenticate(
47    endpoint: web::Data<McpEndpoint>,
48    req: ServiceRequest,
49    next: Next<impl MessageBody>,
50) -> Result<ServiceResponse<EitherBody<impl MessageBody>>, Error> {
51    let app_conf = &endpoint.state.app_conf;
52    let token = req
53        .headers()
54        .get(header::AUTHORIZATION)
55        .and_then(|value| value.to_str().ok())
56        .and_then(bearer_token)
57        .map(str::to_string);
58    let Some(token) = token else {
59        return Ok(unauthorized(req, app_conf, None));
60    };
61    let mut conn = endpoint
62        .state
63        .pool
64        .acquire()
65        .await
66        .map_err(actix_web::error::ErrorInternalServerError)?;
67    let principal = match authenticate_mcp_access_token(
68        &mut conn,
69        &token,
70        &app_conf.oauth_server_configuration.oauth_token_hmac_key,
71        &app_conf.mcp_resource_url(),
72    )
73    .await
74    {
75        Ok(principal) => principal,
76        Err(BearerTokenError::Invalid(description)) => {
77            return Ok(unauthorized(
78                req,
79                app_conf,
80                Some(("invalid_token", description)),
81            ));
82        }
83        Err(BearerTokenError::Database(err)) => {
84            return Err(actix_web::error::ErrorInternalServerError(err));
85        }
86    };
87    drop(conn);
88    req.extensions_mut().insert(TokenSubject {
89        user_id: principal.actor.user_id,
90        grant_id: principal.actor.oauth_grant_id,
91    });
92    req.extensions_mut().insert(principal);
93    next.call(req)
94        .await
95        .map(ServiceResponse::map_into_left_body)
96}
97
98fn unauthorized<B>(
99    req: ServiceRequest,
100    app_conf: &ApplicationConfiguration,
101    error: Option<(&str, &str)>,
102) -> ServiceResponse<EitherBody<B>> {
103    let response = HttpResponse::Unauthorized()
104        .insert_header((
105            header::WWW_AUTHENTICATE,
106            www_authenticate_challenge(
107                &protected_resource_metadata_url(app_conf),
108                &McpScope::all_strs().join(" "),
109                error,
110            ),
111        ))
112        .finish();
113    req.into_response(response).map_into_right_body()
114}