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