Skip to main content

soma_runtime/
protected_routes.rs

1//! Protected-route HTTP middleware: bearer-token authentication, OAuth-scope
2//! authorization, resource metadata responses, and gateway-subset dispatch
3//! for this crate's protected MCP routes (`protected-routes` feature).
4//!
5//! Moved here from `crates/soma/integrations` as a PR 19 review fix.
6//! `soma-integrations` (`product-integration`) previously carried this
7//! module behind a `protected-http` feature that pulled in `soma-runtime`
8//! and `soma-mcp`, inverting plan section 3.20's target dependency shape
9//! (`soma-integrations` depends on application ports and concrete shared
10//! engines only — auth, observability, client, provider-adapters, gateway,
11//! codemode, openapi — never on the runtime or surface layers built on top
12//! of it) and contradicting this crate's own `gateway.rs`, which documents
13//! taking the gateway manager directly "rather than depending on
14//! `soma-runtime`... keeping this crate's dependency shape limited". This
15//! module needs both `AppState` (already native to `soma-runtime`, which
16//! also already owns `AuthPolicy`/`build_auth_layer`) and `soma-mcp`'s
17//! `McpState`/HTTP router, so it lives in `soma-runtime` behind the
18//! `protected-routes` feature instead — the crate that was going to own the
19//! `AppState` half of this either way, now additionally depending on
20//! `soma-mcp` (a `product-surface` crate) only under that feature. No
21//! behavior change: bodies are unmodified, only import paths.
22
23use std::{convert::Infallible, str::FromStr};
24
25use axum::{
26    body::Body,
27    extract::State,
28    http::{header, HeaderValue, Method, Request, StatusCode, Uri},
29    middleware::Next,
30    response::{IntoResponse, Response},
31    Json, Router,
32};
33use serde_json::json;
34use soma_gateway::{
35    config::{protected_routes::normalize_public_host, ProtectedMcpRouteConfig},
36    gateway::protected_routes::resolve_scope,
37};
38use tower::ServiceExt;
39
40use crate::server::{AppState, AuthPolicy};
41
42use crate::protected_routes_proxy::proxy_protected_mcp_route;
43
44/// State for the protected-MCP-route interceptor middleware: pairs the
45/// composition root's `AppState` with the MCP transport's `McpState` the
46/// gateway-subset dispatch path needs. Fields stay private; construct via
47/// [`ProtectedMcpState::new`].
48#[derive(Clone)]
49pub struct ProtectedMcpState {
50    runtime: AppState,
51    mcp: soma_mcp::McpState,
52}
53
54impl ProtectedMcpState {
55    pub fn new(runtime: AppState, mcp: soma_mcp::McpState) -> Self {
56        Self { runtime, mcp }
57    }
58}
59
60pub async fn protected_route_resource_metadata(
61    State(state): State<AppState>,
62    request: Request<Body>,
63) -> Response {
64    let Some(host) = request_host(&request) else {
65        return StatusCode::NOT_FOUND.into_response();
66    };
67    let Some(route) = state.resolve_protected_route_metadata(&host, request.uri().path()) else {
68        return StatusCode::NOT_FOUND.into_response();
69    };
70    protected_route_metadata_response(&state, route)
71}
72
73pub async fn protected_mcp_intercept(
74    State(state): State<ProtectedMcpState>,
75    request: Request<Body>,
76    next: Next,
77) -> Result<Response, Infallible> {
78    if is_reserved_public_path(request.uri().path()) {
79        return Ok(next.run(request).await);
80    }
81    let route = request_host(&request).and_then(|host| {
82        state
83            .runtime
84            .resolve_protected_route(&host, request.uri().path())
85    });
86    let Some(route) = route else {
87        return Ok(next.run(request).await);
88    };
89    if is_route_well_known_path(&route, request.uri().path())
90        && !is_route_metadata_path(&route, request.uri().path())
91    {
92        return Ok(next.run(request).await);
93    }
94    Ok(protected_mcp_route_entry(state, request, route).await)
95}
96
97async fn protected_mcp_route_entry(
98    state: ProtectedMcpState,
99    mut request: Request<Body>,
100    route: ProtectedMcpRouteConfig,
101) -> Response {
102    if *request.method() == Method::GET && is_route_metadata_path(&route, request.uri().path()) {
103        return protected_route_metadata_response(&state.runtime, route);
104    }
105    if !matches!(
106        *request.method(),
107        Method::GET | Method::POST | Method::DELETE
108    ) {
109        return StatusCode::METHOD_NOT_ALLOWED.into_response();
110    }
111    if let Err(response) =
112        authenticate_protected_route_request(&state.runtime, &mut request, &route)
113    {
114        return *response;
115    }
116    if route.target.is_some() {
117        return dispatch_gateway_subset(state, request, route).await;
118    }
119    proxy_protected_mcp_route(&state.runtime, request, route).await
120}
121
122fn protected_route_metadata_response(state: &AppState, route: ProtectedMcpRouteConfig) -> Response {
123    let Some(auth_state) = auth_state(state) else {
124        return json_error(
125            StatusCode::INTERNAL_SERVER_ERROR,
126            "oauth_missing",
127            "OAuth auth state is not configured",
128        );
129    };
130    refresh_protected_route_resource_scopes(state, &auth_state);
131    let Some(public_url) = auth_state.config.public_url.as_ref() else {
132        return json_error(
133            StatusCode::INTERNAL_SERVER_ERROR,
134            "public_url_missing",
135            "OAuth public URL is not configured",
136        );
137    };
138    let mut response = Json(soma_auth::types::ProtectedResourceMetadata {
139        resource: route.public_resource(),
140        authorization_servers: vec![public_url.as_str().trim_end_matches('/').to_owned()],
141        scopes_supported: route.scopes,
142        bearer_methods_supported: vec!["header".to_owned()],
143    })
144    .into_response();
145    response.headers_mut().insert(
146        header::CACHE_CONTROL,
147        HeaderValue::from_static("public, max-age=3600"),
148    );
149    response
150}
151
152fn authenticate_protected_route_request(
153    state: &AppState,
154    request: &mut Request<Body>,
155    route: &ProtectedMcpRouteConfig,
156) -> Result<(), Box<Response>> {
157    let Some(auth_state) = auth_state(state) else {
158        return Err(Box::new(auth_error(
159            route,
160            "OAuth auth state is not configured",
161        )));
162    };
163    refresh_protected_route_resource_scopes(state, &auth_state);
164    let token = request
165        .headers()
166        .get(header::AUTHORIZATION)
167        .and_then(|value| value.to_str().ok())
168        .and_then(soma_auth::parse_bearer_token)
169        .ok_or_else(|| Box::new(auth_error(route, "missing bearer token")))?;
170    let issuer = auth_state
171        .config
172        .public_url
173        .as_ref()
174        .map(|url| url.as_str().trim_end_matches('/').to_owned())
175        .ok_or_else(|| Box::new(auth_error(route, "OAuth public URL is not configured")))?;
176    let claims = auth_state
177        .signing_keys
178        .validate_access_token_with_issuer(&token, &route.public_resource(), &issuer)
179        .map_err(|_| Box::new(auth_error(route, "invalid bearer token")))?;
180    let granted = claims
181        .scope
182        .split_whitespace()
183        .map(ToOwned::to_owned)
184        .collect::<Vec<_>>();
185    let is_admin = granted
186        .iter()
187        .any(|scope| scope == soma_domain::scopes::ADMIN_SCOPE);
188    if !is_admin
189        && !route
190            .scopes
191            .iter()
192            .all(|required| granted.contains(required))
193    {
194        return Err(Box::new(json_error(
195            StatusCode::FORBIDDEN,
196            "insufficient_scope",
197            "insufficient OAuth scope for protected MCP route",
198        )));
199    }
200    request.extensions_mut().insert(soma_auth::AuthContext {
201        sub: claims.sub,
202        actor_key: None,
203        scopes: granted,
204        issuer: claims.iss,
205        via_session: false,
206        csrf_token: None,
207        email: None,
208    });
209    Ok(())
210}
211
212async fn dispatch_gateway_subset(
213    state: ProtectedMcpState,
214    mut request: Request<Body>,
215    route: ProtectedMcpRouteConfig,
216) -> Response {
217    let scope = resolve_scope(&route, &serde_json::Value::Null);
218    request.extensions_mut().insert(soma_mcp::McpRouteScope {
219        upstreams: scope.upstreams,
220        services: scope.services,
221        expose_code_mode: scope.expose_code_mode,
222    });
223    if let Err(response) = rewrite_to_internal_mcp_path(&mut request, &route.public_path) {
224        return *response;
225    }
226    let config = soma_mcp::streamable_http_config(&state.runtime.config);
227    let router = Router::new()
228        .nest_service(
229            "/mcp",
230            soma_mcp::streamable_http_service(state.mcp.clone(), config),
231        )
232        .with_state(state.mcp);
233    router.oneshot(request).await.unwrap_or_else(|error| {
234        json_error(
235            StatusCode::BAD_GATEWAY,
236            "gateway_subset_failed",
237            format!("protected MCP gateway subset failed: {error}"),
238        )
239    })
240}
241
242fn rewrite_to_internal_mcp_path(
243    request: &mut Request<Body>,
244    public_path: &str,
245) -> Result<(), Box<Response>> {
246    let suffix = request.uri().path().strip_prefix(public_path).unwrap_or("");
247    let mut path = "/mcp".to_owned();
248    if !suffix.is_empty() {
249        path.push('/');
250        path.push_str(suffix.trim_start_matches('/'));
251    }
252    if let Some(query) = request.uri().query() {
253        path.push('?');
254        path.push_str(query);
255    }
256    *request.uri_mut() = Uri::from_str(&path).map_err(|error| {
257        Box::new(json_error(
258            StatusCode::BAD_REQUEST,
259            "invalid_proxy_path",
260            format!("failed to rewrite protected MCP path: {error}"),
261        ))
262    })?;
263    Ok(())
264}
265
266fn refresh_protected_route_resource_scopes(
267    state: &AppState,
268    auth_state: &soma_auth::state::AuthState,
269) {
270    auth_state.set_allowed_resource_scopes(
271        state
272            .protected_route_list()
273            .into_iter()
274            .filter(|route| route.enabled)
275            .map(|route| (route.public_resource(), route.scopes)),
276    );
277}
278
279fn auth_state(state: &AppState) -> Option<std::sync::Arc<soma_auth::state::AuthState>> {
280    match &state.auth_policy {
281        AuthPolicy::Mounted {
282            auth_state: Some(auth_state),
283        } => Some(auth_state.clone()),
284        AuthPolicy::LoopbackDev | AuthPolicy::TrustedGatewayUnscoped => None,
285        AuthPolicy::Mounted { auth_state: None } => None,
286    }
287}
288
289fn request_host(request: &Request<Body>) -> Option<String> {
290    request
291        .headers()
292        .get("x-forwarded-host")
293        .or_else(|| request.headers().get(header::HOST))
294        .and_then(|value| value.to_str().ok())
295        .and_then(|value| value.split(',').next())
296        .map(str::trim)
297        .filter(|host| !host.is_empty())
298        .map(ToOwned::to_owned)
299}
300
301fn is_reserved_public_path(path: &str) -> bool {
302    path.starts_with("/.well-known/")
303        || matches!(path, "/authorize" | "/token" | "/register" | "/jwks")
304        || path.starts_with("/native/")
305}
306
307fn is_route_well_known_path(route: &ProtectedMcpRouteConfig, path: &str) -> bool {
308    let prefix = format!("{}/.well-known/", route.public_path.trim_end_matches('/'));
309    path.starts_with(&prefix)
310}
311
312fn is_route_metadata_path(route: &ProtectedMcpRouteConfig, path: &str) -> bool {
313    path == format!(
314        "{}/.well-known/oauth-protected-resource",
315        route.public_path.trim_end_matches('/')
316    )
317}
318
319fn route_metadata_url(route: &ProtectedMcpRouteConfig) -> String {
320    format!(
321        "https://{}/.well-known/oauth-protected-resource{}",
322        normalize_public_host(&route.public_host),
323        route.public_path.trim_end_matches('/')
324    )
325}
326
327fn auth_error(route: &ProtectedMcpRouteConfig, message: &str) -> Response {
328    let mut response = json_error(StatusCode::UNAUTHORIZED, "unauthorized", message);
329    let header = format!(
330        "Bearer resource_metadata=\"{}\", scope=\"{}\"",
331        route_metadata_url(route),
332        route.scopes.join(" ")
333    );
334    if let Ok(value) = HeaderValue::from_str(&header) {
335        response
336            .headers_mut()
337            .insert(header::WWW_AUTHENTICATE, value);
338    }
339    response
340}
341
342pub(crate) fn json_error(status: StatusCode, code: &str, message: impl Into<String>) -> Response {
343    (
344        status,
345        Json(json!({
346            "error": code,
347            "message": message.into(),
348        })),
349    )
350        .into_response()
351}
352
353#[cfg(test)]
354#[path = "protected_routes_tests.rs"]
355mod tests;