Skip to main content

systemprompt_api/services/proxy/auth/
access.rs

1//! Access enforcement for proxied MCP and agent requests.
2//!
3//! [`AccessValidator`] resolves whether a service requires OAuth, validates the
4//! caller's bearer token and scopes, and either returns the authenticated user
5//! or converts the failure into an RFC 9728 challenge. For MCP it permits a
6//! session-only fallback when a prior authenticated initialize established the
7//! identity in the proxy cache.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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}