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_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 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 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}