systemprompt_api/services/proxy/auth/
challenge.rs1use 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 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 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}