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