Skip to main content

soma_runtime/
server.rs

1//! HTTP server application state and auth policy.
2//!
3//! `AppState` is injected into every request handler via axum's `State` extractor.
4//! `AuthPolicy` determines which auth middleware (if any) is mounted on the router.
5
6use std::sync::Arc;
7
8use anyhow::Result;
9
10use soma_application::SomaApplication;
11use soma_config::{AuthMode, Config, McpConfig, TraceHeaderMode};
12#[cfg(feature = "protected-routes")]
13use soma_gateway::config::ProtectedMcpRouteConfig;
14use soma_gateway::{
15    config::{GatewayConfig, GatewayPaths, UpstreamConfig},
16    gateway::{config_store::FsGatewayConfigStore, manager::GatewayManager},
17};
18pub use soma_mcp_server::ResponsePageStore;
19
20pub type GatewayProductState = Arc<GatewayManager>;
21
22pub fn gateway_product_state_from_config(config: GatewayConfig) -> Result<GatewayProductState> {
23    Ok(Arc::new(GatewayManager::new(config)?))
24}
25
26pub fn gateway_product_state_from_env() -> Result<GatewayProductState> {
27    let paths = if std::env::var_os("MCP_GATEWAY_HOME").is_none() {
28        match std::env::var_os("SOMA_HOME") {
29            Some(home) => GatewayPaths::new(std::path::PathBuf::from(home).join(".mcp-gateway"))?,
30            None => GatewayPaths::from_env()?,
31        }
32    } else {
33        GatewayPaths::from_env()?
34    };
35    gateway_product_state_from_store(FsGatewayConfigStore::from_paths(paths))
36}
37
38pub fn gateway_product_state_from_store(
39    store: FsGatewayConfigStore,
40) -> Result<GatewayProductState> {
41    Ok(Arc::new(GatewayManager::from_store(store)?))
42}
43
44#[must_use]
45pub fn empty_gateway_product_state() -> GatewayProductState {
46    gateway_product_state_from_config(GatewayConfig::default())
47        .expect("empty gateway config should build")
48}
49
50/// Authentication policy attached to [`AppState`].
51///
52/// Intentionally an enum — constructing `AppState` requires an explicit choice.
53/// There is no `Default` impl.
54#[derive(Clone)]
55pub enum AuthPolicy {
56    /// No authentication. Only legal when bound to a loopback address.
57    /// Scope checks are bypassed — the bind itself is the trust boundary.
58    LoopbackDev,
59    /// No local authentication or scope checks. The deployment must enforce
60    /// both authentication and authorization before traffic reaches this server.
61    TrustedGatewayUnscoped,
62    /// Authentication middleware is mounted. Scope checks MUST run.
63    /// - `Some(auth_state)`: OAuth mode (Google flow + JWKS issuance)
64    /// - `None`: static bearer token only
65    Mounted {
66        #[cfg(feature = "auth")]
67        auth_state: Option<Arc<soma_auth::state::AuthState>>,
68    },
69}
70
71impl std::fmt::Debug for AuthPolicy {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            AuthPolicy::LoopbackDev => f.write_str("AuthPolicy::LoopbackDev"),
75            AuthPolicy::TrustedGatewayUnscoped => f.write_str("AuthPolicy::TrustedGatewayUnscoped"),
76            #[cfg(feature = "auth")]
77            AuthPolicy::Mounted {
78                auth_state: Some(_),
79            } => f.write_str("AuthPolicy::Mounted { auth_state: Some(<AuthState>) }"),
80            #[cfg(feature = "auth")]
81            AuthPolicy::Mounted { auth_state: None } => {
82                f.write_str("AuthPolicy::Mounted { auth_state: None /* bearer-only */ }")
83            }
84            #[cfg(not(feature = "auth"))]
85            AuthPolicy::Mounted {} => f.write_str("AuthPolicy::Mounted"),
86        }
87    }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum AuthPolicyKind {
92    LoopbackDev,
93    TrustedGatewayUnscoped,
94    MountedBearer,
95    MountedOAuth,
96}
97
98/// Read SOMA_NOAUTH from the environment directly.
99///
100/// Prefer `config.mcp.trusted_gateway` (loaded via `Config::load`) when a
101/// typed config is available. This function exists for call sites that need the
102/// value before config is fully loaded (e.g. early startup guards).
103pub fn trusted_gateway_from_env() -> bool {
104    std::env::var("SOMA_NOAUTH")
105        .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
106        .unwrap_or(false)
107}
108
109pub fn resolve_auth_policy_kind(config: &Config, trusted_gateway: bool) -> Result<AuthPolicyKind> {
110    validate_public_url(config)?;
111    let kind = resolve_auth_policy_kind_unchecked(config, trusted_gateway)?;
112    validate_trace_headers_trust(&config.mcp, kind, trusted_gateway)?;
113    Ok(kind)
114}
115
116fn resolve_auth_policy_kind_unchecked(
117    config: &Config,
118    trusted_gateway: bool,
119) -> Result<AuthPolicyKind> {
120    if config.mcp.is_loopback() {
121        return Ok(AuthPolicyKind::LoopbackDev);
122    }
123
124    let has_token = config
125        .mcp
126        .api_token
127        .as_deref()
128        .map(|token| !token.is_empty())
129        .unwrap_or(false);
130    let has_oauth = config.mcp.auth.mode == AuthMode::OAuth;
131
132    if config.mcp.no_auth {
133        if trusted_gateway {
134            return Ok(AuthPolicyKind::TrustedGatewayUnscoped);
135        }
136        anyhow::bail!(
137            "Refusing to bind MCP server to {} with SOMA_MCP_NO_AUTH=true.\n\
138             \n\
139             SOMA_MCP_NO_AUTH is only allowed on loopback binds. For a trusted \
140             upstream proxy deployment, also set SOMA_NOAUTH=true.",
141            config.mcp.host
142        );
143    }
144
145    if has_oauth {
146        #[cfg(not(feature = "auth"))]
147        anyhow::bail!(
148            "SOMA_MCP_AUTH_MODE=oauth requires compiling with the `auth`/`oauth` feature"
149        );
150        #[cfg(feature = "auth")]
151        Ok(AuthPolicyKind::MountedOAuth)
152    } else if has_token {
153        Ok(AuthPolicyKind::MountedBearer)
154    } else if trusted_gateway {
155        Ok(AuthPolicyKind::TrustedGatewayUnscoped)
156    } else {
157        anyhow::bail!(
158            "Refusing to bind MCP server to {} without authentication.\n\
159             \n\
160             Choose one of:\n\
161             1. Bind to loopback:    SOMA_MCP_HOST=127.0.0.1\n\
162             2. Set a bearer token:  SOMA_MCP_TOKEN=$(openssl rand -hex 32)\n\
163             3. Enable OAuth:        SOMA_MCP_AUTH_MODE=oauth (+ OAuth credentials)\n\
164             4. Local no-auth dev:   SOMA_MCP_HOST=127.0.0.1 SOMA_MCP_NO_AUTH=true\n\
165             5. Upstream gateway:    SOMA_NOAUTH=true  (if a proxy handles auth)\n\
166             \n\
167             CUSTOMIZE: Replace SOMA_ prefix with your service's prefix throughout.",
168            config.mcp.host
169        );
170    }
171}
172
173/// Bearer/OAuth authentication is not a trace-header trust boundary: a
174/// client presenting a valid token says nothing about whether an upstream
175/// gateway/proxy stripped or overwrote inbound `traceparent`/`tracestate`/
176/// `baggage` headers from untrusted clients before the request reached this
177/// server. Only a real transport-level trust boundary (loopback bind, or an
178/// explicitly trusted upstream gateway/proxy) may enable HTTP trace-header
179/// extraction.
180fn validate_trace_headers_trust(
181    mcp: &McpConfig,
182    kind: AuthPolicyKind,
183    trusted_gateway: bool,
184) -> Result<()> {
185    if mcp.trace_headers == TraceHeaderMode::Off || trusted_gateway {
186        return Ok(());
187    }
188    match kind {
189        AuthPolicyKind::LoopbackDev | AuthPolicyKind::TrustedGatewayUnscoped => Ok(()),
190        AuthPolicyKind::MountedBearer | AuthPolicyKind::MountedOAuth => {
191            anyhow::bail!(
192                "Refusing to start with SOMA_MCP_TRACE_HEADERS={} on a {:?} deployment.\n\
193                 \n\
194                 Bearer/OAuth authentication is not a trace-header trust boundary — a caller \
195                 presenting a valid token says nothing about whether an upstream gateway or \
196                 proxy stripped or overwrote inbound traceparent/tracestate/baggage headers \
197                 from untrusted clients before the request reached this server.\n\
198                 \n\
199                 Choose one of:\n\
200                 1. Set SOMA_MCP_TRACE_HEADERS=off (default; disables HTTP trace-header extraction).\n\
201                 2. Bind to loopback:  SOMA_MCP_HOST=127.0.0.1\n\
202                 3. Deploy behind a trusted proxy that strips/overwrites inbound trace headers \
203                    from untrusted clients, and set SOMA_NOAUTH=true.",
204                mcp.trace_headers,
205                kind,
206            );
207        }
208    }
209}
210
211fn validate_public_url(config: &Config) -> Result<()> {
212    let Some(public_url) = config.mcp.auth.public_url.as_deref() else {
213        return Ok(());
214    };
215    let parsed = url::Url::parse(public_url)
216        .map_err(|error| anyhow::anyhow!("SOMA_MCP_PUBLIC_URL is invalid: {error}"))?;
217    let Some(host) = parsed.host_str() else {
218        anyhow::bail!("SOMA_MCP_PUBLIC_URL must include a host");
219    };
220    if host.contains('*') {
221        anyhow::bail!("SOMA_MCP_PUBLIC_URL must not contain wildcard hosts");
222    }
223    Ok(())
224}
225
226/// Shared application state injected into every request handler.
227#[derive(Clone)]
228pub struct SomaRuntime {
229    application: Arc<SomaApplication>,
230    gateway: GatewayProductState,
231}
232
233impl SomaRuntime {
234    pub fn new(application: Arc<SomaApplication>, gateway: GatewayProductState) -> Self {
235        Self {
236            application,
237            gateway,
238        }
239    }
240
241    pub fn application(&self) -> &SomaApplication {
242        self.application.as_ref()
243    }
244
245    pub fn application_handle(&self) -> Arc<SomaApplication> {
246        self.application.clone()
247    }
248
249    #[cfg(feature = "protected-routes")]
250    pub fn resolve_protected_route(
251        &self,
252        host: &str,
253        path: &str,
254    ) -> Option<ProtectedMcpRouteConfig> {
255        self.gateway.resolve_protected_route(host, path)
256    }
257
258    #[cfg(feature = "protected-routes")]
259    pub fn resolve_protected_route_metadata(
260        &self,
261        host: &str,
262        path: &str,
263    ) -> Option<ProtectedMcpRouteConfig> {
264        self.gateway.resolve_protected_route_metadata(host, path)
265    }
266
267    #[cfg(feature = "protected-routes")]
268    pub fn protected_route_list(&self) -> Vec<ProtectedMcpRouteConfig> {
269        self.gateway.protected_route_list()
270    }
271
272    pub fn upstream_config(&self, name: &str) -> Option<UpstreamConfig> {
273        self.gateway.upstream_config(name)
274    }
275
276    #[cfg(feature = "oauth")]
277    pub async fn upstream_oauth_access_token(
278        &self,
279        upstream: &UpstreamConfig,
280        subject: &str,
281    ) -> Result<Option<String>, soma_gateway::gateway::manager::GatewayManagerError> {
282        self.gateway
283            .upstream_oauth_access_token(upstream, subject)
284            .await
285    }
286}
287
288/// Shared transport state injected into every request handler.
289#[derive(Clone)]
290pub struct AppState {
291    pub config: McpConfig,
292    pub auth_policy: AuthPolicy,
293    runtime: Arc<SomaRuntime>,
294    pub response_pages: ResponsePageStore,
295}
296
297impl AppState {
298    pub fn new(
299        config: McpConfig,
300        auth_policy: AuthPolicy,
301        runtime: Arc<SomaRuntime>,
302        response_pages: ResponsePageStore,
303    ) -> Self {
304        Self {
305            config,
306            auth_policy,
307            runtime,
308            response_pages,
309        }
310    }
311
312    pub fn runtime(&self) -> &SomaRuntime {
313        self.runtime.as_ref()
314    }
315
316    pub fn application(&self) -> &SomaApplication {
317        self.runtime.application()
318    }
319
320    pub fn application_handle(&self) -> Arc<SomaApplication> {
321        self.runtime.application_handle()
322    }
323
324    #[cfg(feature = "protected-routes")]
325    pub fn resolve_protected_route(
326        &self,
327        host: &str,
328        path: &str,
329    ) -> Option<ProtectedMcpRouteConfig> {
330        self.runtime.resolve_protected_route(host, path)
331    }
332
333    #[cfg(feature = "protected-routes")]
334    pub fn resolve_protected_route_metadata(
335        &self,
336        host: &str,
337        path: &str,
338    ) -> Option<ProtectedMcpRouteConfig> {
339        self.runtime.resolve_protected_route_metadata(host, path)
340    }
341
342    #[cfg(feature = "protected-routes")]
343    pub fn protected_route_list(&self) -> Vec<ProtectedMcpRouteConfig> {
344        self.runtime.protected_route_list()
345    }
346
347    pub fn upstream_config(&self, name: &str) -> Option<UpstreamConfig> {
348        self.runtime.upstream_config(name)
349    }
350
351    #[cfg(feature = "oauth")]
352    pub async fn upstream_oauth_access_token(
353        &self,
354        upstream: &UpstreamConfig,
355        subject: &str,
356    ) -> Result<Option<String>, soma_gateway::gateway::manager::GatewayManagerError> {
357        self.runtime
358            .upstream_oauth_access_token(upstream, subject)
359            .await
360    }
361}
362
363/// Build a [`soma_auth::AuthLayer`] from an [`AuthPolicy`], or `None` when the trust
364/// boundary is outside the mounted HTTP auth layer.
365///
366/// The static bearer token is read-only (`soma:read`) unless
367/// `static_token_write` (SOMA_MCP_STATIC_TOKEN_WRITE) grants `soma:write`.
368/// Both bearer-only and OAuth-hybrid deployments flow through this single
369/// call site, so the static-token scope grant can never drift between the
370/// two modes (the parity bug cortex fixed separately in each of its paths).
371#[cfg(feature = "auth")]
372pub fn build_auth_layer(
373    policy: &AuthPolicy,
374    static_token: Option<Arc<str>>,
375    resource_url: Option<Arc<str>>,
376    static_token_write: bool,
377) -> Option<soma_auth::AuthLayer> {
378    match policy {
379        AuthPolicy::LoopbackDev | AuthPolicy::TrustedGatewayUnscoped => None,
380        AuthPolicy::Mounted { auth_state } => {
381            if static_token.is_none() && auth_state.is_none() {
382                tracing::warn!(
383                    "auth layer mounted but no static_token or auth_state configured — \
384                     all requests will be rejected; set SOMA_MCP_TOKEN or configure OAuth"
385                );
386            }
387            let mut static_token_scopes = vec![soma_domain::actions::READ_SCOPE.to_string()];
388            if static_token_write {
389                static_token_scopes.push(soma_domain::actions::WRITE_SCOPE.to_string());
390            }
391            Some(
392                soma_auth::AuthLayer::new()
393                    .with_static_token(static_token)
394                    .with_auth_state(auth_state.clone())
395                    .with_static_token_scopes(static_token_scopes)
396                    .with_resource_url(resource_url)
397                    .with_allow_session_cookie(false),
398            )
399        }
400    }
401}
402
403#[cfg(not(feature = "auth"))]
404pub fn build_auth_layer(
405    _policy: &AuthPolicy,
406    _static_token: Option<Arc<str>>,
407    _resource_url: Option<Arc<str>>,
408    _static_token_write: bool,
409) -> Option<()> {
410    None
411}
412
413#[cfg(test)]
414#[path = "server_tests.rs"]
415mod tests;