1use 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#[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;