Skip to main content

soma_integrations/
auth.rs

1//! Soma's product auth default mapping: the env prefix, session cookie name,
2//! supported/default scopes, and resource path that turn `soma-auth`'s
3//! generic, product-agnostic `AuthConfigBuilder` into Soma's own OAuth
4//! configuration (plan section 3.20).
5//!
6//! Moved out of `apps/soma/src/runtime.rs` (formerly the private
7//! `soma_mcp_auth_config_builder` helper) — this crate is its permanent home
8//! per PR 11's acceptance criterion that `apps/soma` constructs adapters but
9//! contains none of their implementation logic.
10
11/// Soma's default `soma_auth::config::AuthConfigBuilder`: `SOMA_MCP` env
12/// prefix, `soma_mcp_session` cookie, the three Soma scopes, `soma:read` as
13/// default scope, `/mcp` as the OAuth resource path, and dynamic client
14/// registration enabled.
15pub fn soma_auth_config_builder() -> soma_auth::config::AuthConfigBuilder {
16    soma_auth::config::AuthConfigBuilder::new()
17        .env_prefix("SOMA_MCP")
18        .session_cookie_name("soma_mcp_session")
19        .scopes_supported(vec![
20            soma_domain::actions::READ_SCOPE.into(),
21            soma_domain::actions::WRITE_SCOPE.into(),
22            soma_domain::scopes::ADMIN_SCOPE.into(),
23        ])
24        .default_scope("soma:read")
25        .resource_path("/mcp")
26        .enable_dynamic_registration(true)
27}
28
29/// Build Soma's `soma_auth::config::AuthConfig` from the typed
30/// `soma_config::AuthConfig` — the only supported path for the OAuth runtime.
31///
32/// `soma_config::Config::load` is the single reader of `SOMA_MCP_*` process
33/// env; this function feeds [`soma_auth_config_builder`] a var list
34/// synthesized from those typed fields via [`soma_auth_env_vars`], so
35/// `soma-auth` itself never touches process env in Soma's OAuth path (the
36/// pattern cortex uses with lab-auth).
37pub fn soma_auth_config(
38    auth: &soma_config::AuthConfig,
39) -> Result<soma_auth::config::AuthConfig, soma_auth::error::AuthError> {
40    soma_auth_config_builder().build_from_sources(soma_auth_env_vars(auth))
41}
42
43/// Synthesize the `SOMA_MCP_*` var list `soma_auth::AuthConfigBuilder`
44/// expects from typed config fields.
45///
46/// Unset options (and empty strings/lists) are omitted entirely so the auth
47/// crate applies its own defaults (`crates/shared/auth/src/config.rs`) —
48/// soma-config deliberately duplicates none of those default values.
49pub fn soma_auth_env_vars(auth: &soma_config::AuthConfig) -> Vec<(String, String)> {
50    let mut vars: Vec<(String, String)> = Vec::new();
51    let mut push = |suffix: &str, value: String| {
52        if !value.trim().is_empty() {
53            vars.push((format!("SOMA_MCP_{suffix}"), value));
54        }
55    };
56    let push_opt = |push: &mut dyn FnMut(&str, String), suffix: &str, value: &Option<String>| {
57        if let Some(value) = value {
58            push(suffix, value.clone());
59        }
60    };
61    let push_csv = |push: &mut dyn FnMut(&str, String), suffix: &str, values: &[String]| {
62        if !values.is_empty() {
63            push(suffix, values.join(","));
64        }
65    };
66
67    push(
68        "AUTH_MODE",
69        match auth.mode {
70            soma_config::AuthMode::Bearer => "bearer".to_owned(),
71            soma_config::AuthMode::OAuth => "oauth".to_owned(),
72        },
73    );
74    push_opt(&mut push, "PUBLIC_URL", &auth.public_url);
75    push("AUTH_ADMIN_EMAIL", auth.admin_email.clone());
76    push_opt(&mut push, "AUTH_BOOTSTRAP_SECRET", &auth.bootstrap_secret);
77    push_opt(&mut push, "AUTH_SQLITE_PATH", &auth.sqlite_path);
78    push_opt(&mut push, "AUTH_KEY_PATH", &auth.key_path);
79    push_csv(
80        &mut push,
81        "AUTH_ALLOWED_REDIRECT_URIS",
82        &auth.allowed_client_redirect_uris,
83    );
84    push_opt(&mut push, "GOOGLE_CLIENT_ID", &auth.google_client_id);
85    push_opt(
86        &mut push,
87        "GOOGLE_CLIENT_SECRET",
88        &auth.google_client_secret,
89    );
90    push_opt(
91        &mut push,
92        "GOOGLE_CALLBACK_PATH",
93        &auth.google_callback_path,
94    );
95    push_csv(&mut push, "GOOGLE_SCOPES", &auth.google_scopes);
96    push_opt(&mut push, "AUTHELIA_ISSUER_URL", &auth.authelia_issuer_url);
97    push_opt(&mut push, "AUTHELIA_CLIENT_ID", &auth.authelia_client_id);
98    push_opt(
99        &mut push,
100        "AUTHELIA_CLIENT_SECRET",
101        &auth.authelia_client_secret,
102    );
103    push_opt(
104        &mut push,
105        "AUTHELIA_CALLBACK_PATH",
106        &auth.authelia_callback_path,
107    );
108    push_csv(&mut push, "AUTHELIA_SCOPES", &auth.authelia_scopes);
109    push_opt(&mut push, "GITHUB_CLIENT_ID", &auth.github_client_id);
110    push_opt(
111        &mut push,
112        "GITHUB_CLIENT_SECRET",
113        &auth.github_client_secret,
114    );
115    push_opt(
116        &mut push,
117        "GITHUB_CALLBACK_PATH",
118        &auth.github_callback_path,
119    );
120    push_csv(&mut push, "GITHUB_SCOPES", &auth.github_scopes);
121    push_opt(&mut push, "AUTH_DEFAULT_PROVIDER", &auth.default_provider);
122    if let Some(ttl) = auth.access_token_ttl_secs {
123        push("AUTH_ACCESS_TOKEN_TTL_SECS", ttl.to_string());
124    }
125    if let Some(ttl) = auth.refresh_token_ttl_secs {
126        push("AUTH_REFRESH_TOKEN_TTL_SECS", ttl.to_string());
127    }
128    if let Some(ttl) = auth.auth_code_ttl_secs {
129        push("AUTH_CODE_TTL_SECS", ttl.to_string());
130    }
131    if let Some(rpm) = auth.register_rpm {
132        push("AUTH_REGISTER_REQUESTS_PER_MINUTE", rpm.to_string());
133    }
134    if let Some(rpm) = auth.authorize_rpm {
135        push("AUTH_AUTHORIZE_REQUESTS_PER_MINUTE", rpm.to_string());
136    }
137    if let Some(max) = auth.max_pending_oauth_states {
138        push("AUTH_MAX_PENDING_OAUTH_STATES", max.to_string());
139    }
140    push_opt(
141        &mut push,
142        "TOKEN_ENCRYPTION_KEY",
143        &auth.token_encryption_key,
144    );
145    vars
146}
147
148#[cfg(test)]
149#[path = "auth_tests.rs"]
150mod tests;