systemprompt_api/services/proxy/auth/
access.rs1use axum::http::header::AUTHORIZATION;
13use axum::http::{HeaderMap, StatusCode};
14use std::str::FromStr;
15
16use crate::services::proxy::backend::ProxyError;
17use systemprompt_agent::services::AgentRegistryProviderService;
18use systemprompt_database::ServiceConfig;
19use systemprompt_identifiers::ServiceName;
20use systemprompt_manifest::services::ServiceModule;
21use systemprompt_models::RequestContext;
22use systemprompt_models::auth::{AuthenticatedUser, Permission};
23use systemprompt_models::modules::ApiPaths;
24use systemprompt_oauth::services::AuthService;
25use systemprompt_runtime::AppContext;
26use systemprompt_traits::{AgentRegistryProvider, McpRegistryProvider, RegistryError};
27
28use super::challenge::{AuthValidator, ChallengeRequest, challenge_or_error};
29
30#[derive(Debug)]
31pub struct OAuthRequirement {
32 pub module: ServiceModule,
33 pub required: bool,
34 pub scopes: Vec<String>,
35 pub audience: String,
36}
37
38#[derive(Debug, Clone, Copy)]
39pub struct AccessValidator;
40
41impl AccessValidator {
42 pub(crate) async fn validate(
43 headers: &HeaderMap,
44 service_name: &ServiceName,
45 service: &ServiceConfig,
46 ctx: &AppContext,
47 req_context: Option<&RequestContext>,
48 ) -> Result<Option<AuthenticatedUser>, ProxyError> {
49 let requirement = lookup_oauth_requirement(service, service_name, ctx).await?;
50 Self::validate_with_requirement(headers, service_name, &requirement, ctx, req_context)
51 }
52
53 pub fn validate_with_requirement(
54 headers: &HeaderMap,
55 service_name: &ServiceName,
56 requirement: &OAuthRequirement,
57 ctx: &AppContext,
58 req_context: Option<&RequestContext>,
59 ) -> Result<Option<AuthenticatedUser>, ProxyError> {
60 if !requirement.required {
61 return Ok(None);
62 }
63 let resource_path = resource_path_for(requirement.module, service_name);
64 let has_authorization = headers.get(AUTHORIZATION).is_some();
65 let challenge = |status_code: StatusCode| {
66 challenge_or_error(&ChallengeRequest {
67 service_name,
68 resource_path: &resource_path,
69 headers,
70 ctx,
71 status_code,
72 has_authorization,
73 })
74 };
75 let authenticated_user =
76 match AuthValidator::validate_service_access(headers, service_name, req_context) {
77 Ok(user) => user,
78 Err(status_code) => {
79 if let Some(outcome) =
80 mcp_session_fallback(requirement.module, service_name, headers, status_code)
81 {
82 return outcome;
83 }
84 return Err(challenge(status_code));
85 },
86 };
87 if let Err(status_code) =
88 enforce_required_audience(headers, service_name, &requirement.audience)
89 {
90 return Err(challenge(status_code));
91 }
92 ensure_required_scopes(service_name, &requirement.scopes, &authenticated_user)?;
93 Ok(Some(authenticated_user))
94 }
95}
96
97async fn lookup_oauth_requirement(
98 service: &ServiceConfig,
99 service_name: &ServiceName,
100 ctx: &AppContext,
101) -> Result<OAuthRequirement, ProxyError> {
102 match service.module_name {
103 ServiceModule::Agent => {
104 let registry = AgentRegistryProviderService::new()
105 .map_err(|error| registry_lookup_error(service_name, error))?;
106 let info = registry
107 .get_agent(service_name.as_str())
108 .await
109 .map_err(|error| registry_lookup_error(service_name, error))?;
110 Ok(OAuthRequirement {
111 module: ServiceModule::Agent,
112 required: info.oauth.required,
113 scopes: info.oauth.scopes,
114 audience: info.oauth.audience,
115 })
116 },
117 ServiceModule::Mcp => mcp_oauth_requirement(ctx, service_name).await,
118 }
119}
120
121pub(crate) async fn mcp_oauth_requirement(
122 ctx: &AppContext,
123 service_name: &ServiceName,
124) -> Result<OAuthRequirement, ProxyError> {
125 let registry = ctx.mcp_registry();
126 registry
127 .validate()
128 .map_err(|source| ProxyError::RegistryUnavailable {
129 service: service_name.to_string(),
130 source,
131 })?;
132 let info = McpRegistryProvider::get_server(registry, service_name.as_str())
133 .await
134 .map_err(|error| registry_lookup_error(service_name, error))?;
135 Ok(OAuthRequirement {
136 module: ServiceModule::Mcp,
137 required: info.oauth.required,
138 scopes: info.oauth.scopes,
139 audience: info.oauth.audience,
140 })
141}
142
143fn registry_lookup_error(service_name: &ServiceName, error: RegistryError) -> ProxyError {
144 match error {
145 RegistryError::NotFound(_) => ProxyError::ServiceNotFound {
146 service: service_name.to_string(),
147 },
148 source => ProxyError::RegistryLookupFailed {
149 service: service_name.to_string(),
150 source,
151 },
152 }
153}
154
155fn enforce_required_audience(
156 headers: &HeaderMap,
157 service_name: &ServiceName,
158 audience: &str,
159) -> Result<(), StatusCode> {
160 if audience.is_empty() {
161 return Ok(());
162 }
163 AuthService::authorize_required_audience(headers, audience)
164 .map(|_user| ())
165 .inspect_err(|status| {
166 tracing::warn!(
167 service = %service_name,
168 audience = %audience,
169 status = %status,
170 "Token lacks the service's required audience"
171 );
172 })
173}
174
175fn resource_path_for(module: ServiceModule, service_name: &ServiceName) -> String {
176 match module {
177 ServiceModule::Mcp => ApiPaths::mcp_server_endpoint(service_name.as_str()),
178 ServiceModule::Agent => ApiPaths::agent_endpoint(
179 &systemprompt_identifiers::AgentName::new(service_name.as_str()),
180 ),
181 }
182}
183
184fn mcp_session_fallback(
185 module: ServiceModule,
186 service_name: &ServiceName,
187 headers: &HeaderMap,
188 status_code: StatusCode,
189) -> Option<Result<Option<AuthenticatedUser>, ProxyError>> {
190 if module != ServiceModule::Mcp || status_code != StatusCode::UNAUTHORIZED {
191 return None;
192 }
193 let has_session = headers
194 .get("mcp-session-id")
195 .and_then(|v| v.to_str().ok())
196 .is_some_and(|v| !v.is_empty());
197 if !has_session {
198 return None;
199 }
200 let has_bearer_token = headers
201 .get("authorization")
202 .and_then(|v| v.to_str().ok())
203 .is_some_and(|v| v.starts_with("Bearer "));
204 if has_bearer_token {
205 tracing::info!(
206 service = %service_name,
207 session_id = ?headers.get("mcp-session-id"),
208 "MCP request has expired/invalid Bearer token — returning 401 for client token refresh"
209 );
210 return None;
211 }
212 tracing::info!(
213 service = %service_name,
214 session_id = ?headers.get("mcp-session-id"),
215 "Allowing MCP request with session ID (session-based auth, identity from proxy cache)"
216 );
217 Some(Ok(None))
218}
219
220fn ensure_required_scopes(
221 service_name: &ServiceName,
222 required_scopes: &[String],
223 user: &AuthenticatedUser,
224) -> Result<(), ProxyError> {
225 if required_scopes.is_empty() {
226 tracing::error!(service = %service_name, "OAuth required but no scopes are declared");
227 return Err(ProxyError::Forbidden {
228 service: format!("{service_name} requires OAuth but declares no scopes"),
229 });
230 }
231 let has_required_scope = required_scopes.iter().any(|required_scope_str| {
232 Permission::from_str(required_scope_str).map_or_else(
233 |_| {
234 user.permissions
235 .iter()
236 .any(|p| p.as_str() == required_scope_str)
237 },
238 |required_permission| {
239 user.permissions
240 .iter()
241 .any(|p| *p == required_permission || p.implies(&required_permission))
242 },
243 )
244 });
245 if !has_required_scope {
246 return Err(ProxyError::Forbidden {
247 service: format!(
248 "Insufficient permissions for {}. Required: {:?}, User has: {:?}",
249 service_name, required_scopes, user.permissions
250 ),
251 });
252 }
253 Ok(())
254}