Skip to main content

systemprompt_api/services/proxy/auth/
challenge.rs

1//! OAuth challenge construction for the proxy auth boundary.
2//!
3//! [`OAuthChallengeBuilder`] builds the `WWW-Authenticate: Bearer` 401/403
4//! responses (per RFC 6750 and RFC 9728) that drive MCP and agent clients into
5//! their OAuth discovery handshake, deriving the advertised `resource_metadata`
6//! URL from the incoming request host. [`AuthValidator`] performs the bearer
7//! check and [`challenge_or_error`] maps a failed check onto a [`ProxyError`].
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use axum::body::Body;
13use axum::http::header::{AUTHORIZATION, HOST};
14use axum::http::{HeaderMap, StatusCode};
15use axum::response::Response;
16use serde_json::json;
17
18use crate::services::proxy::backend::ProxyError;
19use crate::services::request_base_url::resolve as resolve_request_base_url;
20use systemprompt_models::RequestContext;
21use systemprompt_models::auth::AuthenticatedUser;
22use systemprompt_models::modules::ApiPaths;
23use systemprompt_oauth::services::AuthService;
24use systemprompt_runtime::AppContext;
25
26#[derive(Debug, Clone, Copy)]
27pub(super) struct AuthValidator;
28
29impl AuthValidator {
30    pub(super) fn validate_service_access(
31        headers: &HeaderMap,
32        service_name: &str,
33        req_context: Option<&RequestContext>,
34    ) -> Result<AuthenticatedUser, StatusCode> {
35        let result = AuthService::authorize_service_access(headers, service_name);
36
37        if let Err(status) = &result {
38            let trace_id =
39                req_context.map_or_else(|| "unknown".to_owned(), |rc| rc.trace_id().to_string());
40            tracing::warn!(service = %service_name, status = %status, trace_id = %trace_id, "auth failed");
41        }
42
43        result
44    }
45}
46
47pub(super) struct ChallengeRequest<'a> {
48    pub service_name: &'a str,
49    pub resource_path: &'a str,
50    pub headers: &'a HeaderMap,
51    pub ctx: &'a AppContext,
52    pub status_code: StatusCode,
53    pub has_authorization: bool,
54}
55
56#[derive(Debug, Clone, Copy)]
57pub struct OAuthChallengeBuilder;
58
59impl OAuthChallengeBuilder {
60    /// Build the `resource_metadata` URL advertised in the WWW-Authenticate
61    /// header. Host-derives the base from the incoming request so the 401
62    /// challenge agrees with the body of
63    /// `/.well-known/oauth-protected-resource` — both must reflect
64    /// whichever identity the client dialled in on (127.0.0.1 vs localhost
65    /// vs configured public host), or the OAuth flow fails to round-trip.
66    pub fn resource_metadata_url(
67        headers: &HeaderMap,
68        configured_api_external_url: &str,
69        resource_path: &str,
70    ) -> Result<String, url::ParseError> {
71        let configured = url::Url::parse(configured_api_external_url)?;
72        let raw_host = headers.get(HOST).and_then(|v| v.to_str().ok());
73        let base = resolve_request_base_url(raw_host, &configured).into_string();
74        Ok(format!(
75            "{base}/.well-known/oauth-protected-resource{resource_path}"
76        ))
77    }
78
79    pub(super) fn build_challenge_response(
80        req: &ChallengeRequest<'_>,
81    ) -> Result<Response<Body>, StatusCode> {
82        let ChallengeRequest {
83            service_name,
84            resource_path,
85            headers,
86            ctx,
87            status_code,
88            has_authorization,
89        } = *req;
90        tracing::warn!(service = %service_name, status = %status_code, "Building OAuth challenge");
91
92        let resource_metadata_url =
93            Self::resource_metadata_url(headers, &ctx.config().api_external_url, resource_path)
94                .map_err(|e| {
95                    tracing::error!(error = %e, "api_external_url is not a valid URL");
96                    StatusCode::INTERNAL_SERVER_ERROR
97                })?;
98
99        let (auth_header_value, error_body) = if status_code == StatusCode::UNAUTHORIZED {
100            if has_authorization {
101                let header = format!(
102                    "Bearer realm=\"{service_name}\", \
103                     resource_metadata=\"{resource_metadata_url}\", error=\"invalid_token\", \
104                     error_description=\"The access token is missing or invalid\""
105                );
106                let body = json!({
107                    "error": "invalid_token",
108                    "error_description": "The access token is missing or invalid",
109                    "server": service_name
110                });
111                (header, body)
112            } else {
113                // RFC 6750 §3: omit `error` on the no-credentials challenge so clients
114                // know to start the OAuth flow rather than treat the request as rejected.
115                let header = format!(
116                    "Bearer realm=\"{service_name}\", \
117                     resource_metadata=\"{resource_metadata_url}\""
118                );
119                (header, json!({}))
120            }
121        } else {
122            let header = format!(
123                "Bearer realm=\"{service_name}\", error=\"insufficient_scope\", \
124                 error_description=\"The access token lacks required scope\""
125            );
126            let body = json!({
127                "error": "insufficient_scope",
128                "error_description": "The access token does not have the required scope for this resource",
129                "server": service_name
130            });
131            (header, body)
132        };
133
134        Response::builder()
135            .status(status_code)
136            .header("Content-Type", "application/json")
137            .header("WWW-Authenticate", auth_header_value)
138            .body(Body::from(error_body.to_string()))
139            .map_err(|e| {
140                tracing::error!(error = %e, "Failed to build OAuth challenge response");
141                StatusCode::INTERNAL_SERVER_ERROR
142            })
143    }
144}
145
146pub(crate) fn build_mcp_unknown_service_challenge(
147    service_name: &str,
148    headers: &HeaderMap,
149    ctx: &AppContext,
150    req_context: Option<&RequestContext>,
151) -> Option<ProxyError> {
152    let status_code =
153        AuthValidator::validate_service_access(headers, service_name, req_context).err()?;
154    let resource_path = ApiPaths::mcp_server_endpoint(service_name);
155    let has_authorization = headers.get(AUTHORIZATION).is_some();
156    Some(challenge_or_error(&ChallengeRequest {
157        service_name,
158        resource_path: &resource_path,
159        headers,
160        ctx,
161        status_code,
162        has_authorization,
163    }))
164}
165
166pub(super) fn challenge_or_error(req: &ChallengeRequest<'_>) -> ProxyError {
167    match OAuthChallengeBuilder::build_challenge_response(req) {
168        Ok(challenge_response) => ProxyError::AuthChallenge(Box::new(challenge_response)),
169        Err(status) if status == StatusCode::UNAUTHORIZED => ProxyError::AuthenticationRequired {
170            service: req.service_name.to_owned(),
171        },
172        Err(_) => ProxyError::Forbidden {
173            service: req.service_name.to_owned(),
174        },
175    }
176}