Skip to main content

soma_auth/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::at_rest::TokenEncryptionKey;
9use crate::error::AuthError;
10
11#[path = "config_providers.rs"]
12mod config_providers;
13pub use config_providers::{AutheliaConfig, GitHubConfig, GoogleConfig};
14use config_providers::{
15    default_authelia_callback_path, default_authelia_scopes, default_github_callback_path,
16    default_github_scopes, default_google_scopes,
17};
18
19const DEFAULT_CALLBACK_PATH: &str = "/auth/google/callback";
20const DEFAULT_AUTH_DB_NAME: &str = "auth.db";
21const DEFAULT_KEY_NAME: &str = "auth-jwt.pem";
22const DEFAULT_ACCESS_TOKEN_TTL_SECS: u64 = 3600;
23const DEFAULT_REFRESH_TOKEN_TTL_SECS: u64 = 30 * 24 * 3600;
24const DEFAULT_AUTH_CODE_TTL_SECS: u64 = 300;
25const DEFAULT_REGISTER_REQUESTS_PER_MINUTE: u32 = 20;
26const DEFAULT_AUTHORIZE_REQUESTS_PER_MINUTE: u32 = 60;
27const DEFAULT_MAX_PENDING_OAUTH_STATES: usize = 1024;
28
29/// This crate's own fixed, non-configurable routes (see `routes.rs::router`).
30/// A configured provider `callback_path` colliding with any of these would
31/// make axum's route-registration hit its duplicate-route panic at startup
32/// — the same failure mode the pairwise provider-vs-provider collision check
33/// above guards against, just for a different pair of colliding paths.
34const FIXED_ROUTE_PATHS: &[&str] = &[
35    "/authorize",
36    "/token",
37    "/jwks",
38    "/auth/login",
39    "/native/callback",
40    "/native/poll",
41    "/register",
42];
43/// Prefix covering every `/.well-known/oauth-*` metadata route, including
44/// the `{*route}` wildcard variant.
45const WELL_KNOWN_PREFIX: &str = "/.well-known/";
46
47/// Default env-var prefix used when consumers do not specify one.
48/// Backward-compatible with the original `LAB_*` env scheme.
49pub const DEFAULT_ENV_PREFIX: &str = "LAB";
50/// Default browser session cookie name (preserved for the lab consumer).
51pub const DEFAULT_SESSION_COOKIE_NAME: &str = "lab_session";
52/// Default OAuth scope label applied when callers do not request one.
53pub const DEFAULT_SCOPE: &str = "lab";
54/// Default protected resource path (canonical MCP endpoint).
55pub const DEFAULT_RESOURCE_PATH: &str = "/mcp";
56/// Default browser login path mounted by the auth router.
57pub const DEFAULT_LOGIN_PATH: &str = "/auth/login";
58
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum AuthMode {
62    #[default]
63    Bearer,
64    OAuth,
65}
66
67impl AuthMode {
68    fn parse(value: Option<&str>, env_key_for_diagnostics: &str) -> Result<Self, AuthError> {
69        match value
70            .unwrap_or("bearer")
71            .trim()
72            .to_ascii_lowercase()
73            .as_str()
74        {
75            "bearer" => Ok(Self::Bearer),
76            "oauth" => Ok(Self::OAuth),
77            other => Err(AuthError::Config(format!(
78                "{env_key_for_diagnostics} must be `bearer` or `oauth`, got `{other}`"
79            ))),
80        }
81    }
82}
83
84#[derive(Clone, Debug, Default, PartialEq, Eq)]
85pub struct AuthModeConfig {
86    pub mode: AuthMode,
87}
88
89impl AuthModeConfig {
90    pub fn from_sources(
91        vars: impl IntoIterator<Item = (String, String)>,
92    ) -> Result<Self, AuthError> {
93        Self::from_sources_with_prefix(vars, DEFAULT_ENV_PREFIX)
94    }
95
96    pub fn from_sources_with_prefix(
97        vars: impl IntoIterator<Item = (String, String)>,
98        env_prefix: &str,
99    ) -> Result<Self, AuthError> {
100        let vars = normalize(vars);
101        let key = env_key(env_prefix, "AUTH_MODE");
102        Ok(Self {
103            mode: AuthMode::parse(vars.get(&key).map(String::as_str), &key)?,
104        })
105    }
106}
107
108#[derive(Clone, Debug, PartialEq, Eq)]
109pub struct AuthConfig {
110    pub mode: AuthMode,
111    pub public_url: Option<Url>,
112    pub sqlite_path: PathBuf,
113    pub key_path: PathBuf,
114    pub bootstrap_secret: Option<String>,
115    pub allowed_client_redirect_uris: Vec<String>,
116    /// Single bootstrap admin email permitted to log in through any configured
117    /// OAuth/OIDC provider.
118    /// Required when `mode == AuthMode::OAuth`. Additional users are granted
119    /// through the SQLite-backed allowlist managed via the web UI.
120    pub admin_email: String,
121    pub google: GoogleConfig,
122    pub authelia: AutheliaConfig,
123    pub github: GitHubConfig,
124    /// Which configured provider `/authorize` and `/auth/login` use when the
125    /// request omits `?provider=`. Must name a provider that is actually
126    /// configured (validated in `AuthConfig::validate`). Resolved
127    /// automatically when unset: `google` > `authelia` > `github`, in that
128    /// priority order, picking the first one that has credentials — this is
129    /// what makes every existing single-provider (Google-only) deployment
130    /// keep working with zero config changes after upgrading.
131    pub default_provider: String,
132    pub access_token_ttl: Duration,
133    pub refresh_token_ttl: Duration,
134    pub auth_code_ttl: Duration,
135    pub register_requests_per_minute: u32,
136    pub authorize_requests_per_minute: u32,
137    pub max_pending_oauth_states: usize,
138
139    // ---- Brand / consumer-specific parameterization (see L1 bead) ----
140    /// Env var prefix used for diagnostics (e.g. `"LAB"`, `"SYSLOG_MCP"`).
141    /// Set via [`AuthConfigBuilder::env_prefix`] BEFORE any env reads.
142    pub env_prefix: String,
143    /// Default base directory for `auth.db` and `auth-jwt.pem` when the
144    /// corresponding env vars are unset.
145    pub default_data_dir: PathBuf,
146    /// Browser session cookie name. Lab consumer leaves this at the default
147    /// (`"lab_session"`); other consumers override with their own brand.
148    pub session_cookie_name: String,
149    /// Scopes advertised on `/.well-known/oauth-authorization-server` and
150    /// `/.well-known/oauth-protected-resource`.
151    pub scopes_supported: Vec<String>,
152    /// Path appended to `public_url` to form the canonical resource URL
153    /// returned in the protected-resource metadata document.
154    pub resource_path: String,
155    /// Default scope applied when `/authorize` requests omit one and the
156    /// only scope accepted by the legacy single-scope validator.
157    pub default_scope: String,
158    /// Scopes minted into the static-bearer-derived AuthContext so legacy
159    /// admin tools keep functioning when the dual-mode middleware (L2) is
160    /// deployed. Lab keeps the legacy `["lab:read","lab:admin"]` defaults;
161    /// cortex will override with `["syslog:read","syslog:admin"]`.
162    pub static_token_scopes: Vec<String>,
163    /// Path of the browser login route (typically `/auth/login`).
164    pub login_path: String,
165    /// Whether `POST /register` (RFC 7591 dynamic client registration) is
166    /// mounted. Defaults to `false` (closed) — opt-in per consumer.
167    pub enable_dynamic_registration: bool,
168    /// When `true`, dual-mode middleware MUST reject the static bearer
169    /// token whenever OAuth is active. Defaults to `false` (lab keeps the
170    /// historical break-glass behavior); cortex overrides to `true`.
171    pub disable_static_token_with_oauth: bool,
172    /// Optional at-rest encryption key for upstream provider refresh tokens.
173    ///
174    /// When present, provider refresh tokens are encrypted with
175    /// ChaCha20-Poly1305 before being written to SQLite.  Set via
176    /// `{PREFIX}_TOKEN_ENCRYPTION_KEY` (64 hex digits or 43 base64url chars).
177    /// When absent, tokens are stored as plaintext (backward-compatible).
178    pub token_encryption_key: Option<TokenEncryptionKey>,
179}
180
181impl Default for AuthConfig {
182    fn default() -> Self {
183        let base_dir = default_auth_dir();
184        Self {
185            mode: AuthMode::Bearer,
186            public_url: None,
187            sqlite_path: base_dir.join(DEFAULT_AUTH_DB_NAME),
188            key_path: base_dir.join(DEFAULT_KEY_NAME),
189            bootstrap_secret: None,
190            allowed_client_redirect_uris: Vec::new(),
191            admin_email: String::new(),
192            google: GoogleConfig::default(),
193            authelia: AutheliaConfig::default(),
194            github: GitHubConfig::default(),
195            default_provider: String::new(),
196            access_token_ttl: Duration::from_secs(DEFAULT_ACCESS_TOKEN_TTL_SECS),
197            refresh_token_ttl: Duration::from_secs(DEFAULT_REFRESH_TOKEN_TTL_SECS),
198            auth_code_ttl: Duration::from_secs(DEFAULT_AUTH_CODE_TTL_SECS),
199            register_requests_per_minute: DEFAULT_REGISTER_REQUESTS_PER_MINUTE,
200            authorize_requests_per_minute: DEFAULT_AUTHORIZE_REQUESTS_PER_MINUTE,
201            max_pending_oauth_states: DEFAULT_MAX_PENDING_OAUTH_STATES,
202            env_prefix: DEFAULT_ENV_PREFIX.to_string(),
203            default_data_dir: base_dir,
204            session_cookie_name: DEFAULT_SESSION_COOKIE_NAME.to_string(),
205            // Advertise both the base scope and `:admin` so MCP clients that
206            // need destructive operations can request the elevated scope at
207            // /authorize. Allowed-emails users also receive `:admin` implicitly
208            // (see `authorize::elevate_scope_for_allowed_user`).
209            scopes_supported: vec![DEFAULT_SCOPE.to_string(), format!("{DEFAULT_SCOPE}:admin")],
210            resource_path: DEFAULT_RESOURCE_PATH.to_string(),
211            default_scope: DEFAULT_SCOPE.to_string(),
212            static_token_scopes: vec!["lab:read".to_string(), "lab:admin".to_string()],
213            login_path: DEFAULT_LOGIN_PATH.to_string(),
214            enable_dynamic_registration: false,
215            disable_static_token_with_oauth: false,
216            token_encryption_key: None,
217        }
218    }
219}
220
221impl AuthConfig {
222    /// Backward-compatible convenience: read env vars using the default
223    /// `LAB` prefix. Equivalent to `AuthConfigBuilder::new().build_from_sources(vars)`.
224    pub fn from_sources(
225        vars: impl IntoIterator<Item = (String, String)>,
226    ) -> Result<Self, AuthError> {
227        AuthConfigBuilder::new().build_from_sources(vars)
228    }
229
230    pub(crate) fn validate(&self) -> Result<(), AuthError> {
231        let prefix = &self.env_prefix;
232        if !self.google.callback_path.starts_with('/') {
233            return Err(AuthError::Config(format!(
234                "{prefix}_GOOGLE_CALLBACK_PATH must start with `/`, got `{}`",
235                self.google.callback_path
236            )));
237        }
238
239        if !self.resource_path.starts_with('/') {
240            return Err(AuthError::Config(format!(
241                "resource_path must start with `/`, got `{}`",
242                self.resource_path
243            )));
244        }
245        if !self.login_path.starts_with('/') {
246            return Err(AuthError::Config(format!(
247                "login_path must start with `/`, got `{}`",
248                self.login_path
249            )));
250        }
251        if self.session_cookie_name.is_empty() {
252            return Err(AuthError::Config(
253                "session_cookie_name must not be empty".to_string(),
254            ));
255        }
256        if self.default_scope.is_empty() {
257            return Err(AuthError::Config(
258                "default_scope must not be empty".to_string(),
259            ));
260        }
261        if self.scopes_supported.is_empty() {
262            return Err(AuthError::Config(
263                "scopes_supported must contain at least one scope".to_string(),
264            ));
265        }
266        if !self.scopes_supported.contains(&self.default_scope) {
267            return Err(AuthError::Config(format!(
268                "default_scope `{}` must be listed in scopes_supported",
269                self.default_scope
270            )));
271        }
272
273        if matches!(self.mode, AuthMode::OAuth) {
274            if self.public_url.is_none() {
275                return Err(AuthError::Config(format!(
276                    "{prefix}_PUBLIC_URL is required when {prefix}_AUTH_MODE=oauth"
277                )));
278            }
279
280            let google_configured = !self.google.client_id.is_empty();
281            let authelia_configured = !self.authelia.client_id.is_empty();
282            let github_configured = !self.github.client_id.is_empty();
283
284            if google_configured && self.google.client_secret.is_empty() {
285                return Err(AuthError::Config(format!(
286                    "{prefix}_GOOGLE_CLIENT_SECRET is required when {prefix}_GOOGLE_CLIENT_ID is set"
287                )));
288            }
289            if authelia_configured {
290                if self.authelia.issuer_url.is_none() {
291                    return Err(AuthError::Config(format!(
292                        "{prefix}_AUTHELIA_ISSUER_URL is required when {prefix}_AUTHELIA_CLIENT_ID is set"
293                    )));
294                }
295                if self.authelia.client_secret.is_empty() {
296                    return Err(AuthError::Config(format!(
297                        "{prefix}_AUTHELIA_CLIENT_SECRET is required when {prefix}_AUTHELIA_CLIENT_ID is set"
298                    )));
299                }
300                // Google's authorize/token/JWKS endpoints are hardcoded `https://`
301                // string constants — no config can downgrade them. Authelia's are
302                // entirely operator-supplied, so unlike Google this crate must
303                // enforce the scheme itself: a plaintext issuer would send
304                // authorization codes, tokens, and `client_secret` (in the token
305                // exchange POST body) over the wire unencrypted with no other
306                // signal that anything is wrong.
307                if let Some(issuer) = self.authelia.issuer_url.as_ref()
308                    && issuer.scheme() != "https"
309                {
310                    return Err(AuthError::Config(format!(
311                        "{prefix}_AUTHELIA_ISSUER_URL must use https, got `{}`",
312                        issuer.scheme()
313                    )));
314                }
315            }
316            if github_configured && self.github.client_secret.is_empty() {
317                return Err(AuthError::Config(format!(
318                    "{prefix}_GITHUB_CLIENT_SECRET is required when {prefix}_GITHUB_CLIENT_ID is set"
319                )));
320            }
321            // GitHubProvider::exchange_code's GET /user/emails call requires
322            // this scope; GitHub returns it in a hard failure (not a graceful
323            // `email: None`, unlike Google/Authelia's ID-token-derived email
324            // claim), and tokio::try_join! propagates that as a total login
325            // failure. Catch the misconfiguration here instead of at runtime.
326            if github_configured && !self.github.scopes.iter().any(|scope| scope == "user:email") {
327                return Err(AuthError::Config(format!(
328                    "{prefix}_GITHUB_SCOPES must include `user:email` (got `{:?}`)",
329                    self.github.scopes
330                )));
331            }
332            // Two configured providers with the same (possibly operator-overridden)
333            // callback_path would make routes.rs's per-provider route-mounting loop
334            // (Task 10) hit axum's duplicate-route panic at startup instead of a
335            // clean config-time error — check pairwise uniqueness among only the
336            // providers that are actually configured.
337            //
338            // Compare the NORMALIZED path (leading `/` guaranteed), not the raw
339            // config string: `build_provider_redirect_uri` (state.rs) strips any
340            // leading `/` from `callback_path` and re-adds exactly one before
341            // mounting the route, so an operator-supplied path without a leading
342            // `/` (e.g. `authorize`) mounts as `/authorize` at startup even though
343            // it wouldn't textually match `/authorize` in `FIXED_ROUTE_PATHS` or
344            // another provider's raw `callback_path`. Normalizing here first keeps
345            // this check honest about what actually gets mounted.
346            {
347                fn normalize_callback_path(path: &str) -> String {
348                    format!("/{}", path.trim_start_matches('/'))
349                }
350
351                let mut configured_paths: Vec<(&str, String)> = Vec::new();
352                if google_configured {
353                    configured_paths.push((
354                        "google",
355                        normalize_callback_path(&self.google.callback_path),
356                    ));
357                }
358                if authelia_configured {
359                    configured_paths.push((
360                        "authelia",
361                        normalize_callback_path(&self.authelia.callback_path),
362                    ));
363                }
364                if github_configured {
365                    configured_paths.push((
366                        "github",
367                        normalize_callback_path(&self.github.callback_path),
368                    ));
369                }
370                for i in 0..configured_paths.len() {
371                    for j in (i + 1)..configured_paths.len() {
372                        if configured_paths[i].1 == configured_paths[j].1 {
373                            return Err(AuthError::Config(format!(
374                                "{prefix}_{a}_CALLBACK_PATH and {prefix}_{b}_CALLBACK_PATH must not both resolve to `{path}`",
375                                a = configured_paths[i].0.to_ascii_uppercase(),
376                                b = configured_paths[j].0.to_ascii_uppercase(),
377                                path = configured_paths[i].1,
378                            )));
379                        }
380                    }
381                }
382                // Same failure mode as above, but against this crate's own
383                // fixed routes rather than another provider's callback_path.
384                for (provider, path) in &configured_paths {
385                    if FIXED_ROUTE_PATHS.contains(&path.as_str())
386                        || path.starts_with(WELL_KNOWN_PREFIX)
387                    {
388                        return Err(AuthError::Config(format!(
389                            "{prefix}_{provider_upper}_CALLBACK_PATH must not resolve to `{path}` — \
390                             that path is reserved for this crate's own `{path}` route",
391                            provider_upper = provider.to_ascii_uppercase(),
392                        )));
393                    }
394                }
395            }
396            if !google_configured && !authelia_configured && !github_configured {
397                return Err(AuthError::Config(format!(
398                    "at least one OAuth provider must be configured when {prefix}_AUTH_MODE=oauth — \
399                     set {prefix}_GOOGLE_CLIENT_ID, {prefix}_AUTHELIA_CLIENT_ID (+ {prefix}_AUTHELIA_ISSUER_URL), \
400                     or {prefix}_GITHUB_CLIENT_ID (each paired with its matching _CLIENT_SECRET)"
401                )));
402            }
403            match self.default_provider.as_str() {
404                "google" if !google_configured => {
405                    return Err(AuthError::Config(format!(
406                        "{prefix}_AUTH_DEFAULT_PROVIDER=google but {prefix}_GOOGLE_CLIENT_ID is not set"
407                    )));
408                }
409                "authelia" if !authelia_configured => {
410                    return Err(AuthError::Config(format!(
411                        "{prefix}_AUTH_DEFAULT_PROVIDER=authelia but {prefix}_AUTHELIA_CLIENT_ID is not set"
412                    )));
413                }
414                "github" if !github_configured => {
415                    return Err(AuthError::Config(format!(
416                        "{prefix}_AUTH_DEFAULT_PROVIDER=github but {prefix}_GITHUB_CLIENT_ID is not set"
417                    )));
418                }
419                "google" | "authelia" | "github" => {}
420                other => {
421                    return Err(AuthError::Config(format!(
422                        "{prefix}_AUTH_DEFAULT_PROVIDER must be `google`, `authelia`, or `github`, got `{other}`"
423                    )));
424                }
425            }
426            if self.admin_email.is_empty() {
427                return Err(AuthError::Config(format!(
428                    "{prefix}_AUTH_ADMIN_EMAIL is required when {prefix}_AUTH_MODE=oauth — \
429                     set the admin's email so no account can log in unless explicitly permitted"
430                )));
431            }
432        }
433
434        Ok(())
435    }
436}
437
438/// Consuming builder for [`AuthConfig`]. The `env_prefix` MUST be set BEFORE
439/// any env-driven `build_*` call; builder methods themselves do not read env.
440///
441/// ```ignore
442/// let cfg = AuthConfigBuilder::new()
443///     .env_prefix("SYSLOG_MCP")
444///     .session_cookie_name("syslog_session")
445///     .scopes_supported(vec!["syslog:read".to_string(), "syslog:admin".to_string()])
446///     .resource_path("/mcp")
447///     .default_scope("syslog:read")
448///     .static_token_scopes(vec!["syslog:read".to_string(), "syslog:admin".to_string()])
449///     .disable_static_token_with_oauth(true)
450///     .build_from_sources(std::env::vars())?;
451/// ```
452#[derive(Clone, Debug)]
453pub struct AuthConfigBuilder {
454    env_prefix: String,
455    default_data_dir: Option<PathBuf>,
456    session_cookie_name: String,
457    scopes_supported: Vec<String>,
458    resource_path: String,
459    default_scope: String,
460    static_token_scopes: Vec<String>,
461    login_path: String,
462    enable_dynamic_registration: bool,
463    disable_static_token_with_oauth: bool,
464}
465
466impl Default for AuthConfigBuilder {
467    fn default() -> Self {
468        Self::new()
469    }
470}
471
472impl AuthConfigBuilder {
473    pub fn new() -> Self {
474        Self {
475            env_prefix: DEFAULT_ENV_PREFIX.to_string(),
476            default_data_dir: None,
477            session_cookie_name: DEFAULT_SESSION_COOKIE_NAME.to_string(),
478            scopes_supported: vec![DEFAULT_SCOPE.to_string(), format!("{DEFAULT_SCOPE}:admin")],
479            resource_path: DEFAULT_RESOURCE_PATH.to_string(),
480            default_scope: DEFAULT_SCOPE.to_string(),
481            static_token_scopes: vec!["lab:read".to_string(), "lab:admin".to_string()],
482            login_path: DEFAULT_LOGIN_PATH.to_string(),
483            enable_dynamic_registration: false,
484            disable_static_token_with_oauth: false,
485        }
486    }
487
488    #[must_use]
489    pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
490        self.env_prefix = prefix.into();
491        self
492    }
493
494    #[must_use]
495    pub fn default_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
496        self.default_data_dir = Some(dir.into());
497        self
498    }
499
500    #[must_use]
501    pub fn session_cookie_name(mut self, name: impl Into<String>) -> Self {
502        self.session_cookie_name = name.into();
503        self
504    }
505
506    #[must_use]
507    pub fn scopes_supported(mut self, scopes: Vec<String>) -> Self {
508        self.scopes_supported = scopes;
509        self
510    }
511
512    #[must_use]
513    pub fn resource_path(mut self, path: impl Into<String>) -> Self {
514        self.resource_path = path.into();
515        self
516    }
517
518    #[must_use]
519    pub fn default_scope(mut self, scope: impl Into<String>) -> Self {
520        self.default_scope = scope.into();
521        self
522    }
523
524    #[must_use]
525    pub fn static_token_scopes(mut self, scopes: Vec<String>) -> Self {
526        self.static_token_scopes = scopes;
527        self
528    }
529
530    #[must_use]
531    pub fn login_path(mut self, path: impl Into<String>) -> Self {
532        self.login_path = path.into();
533        self
534    }
535
536    #[must_use]
537    pub const fn enable_dynamic_registration(mut self, enabled: bool) -> Self {
538        self.enable_dynamic_registration = enabled;
539        self
540    }
541
542    #[must_use]
543    pub const fn disable_static_token_with_oauth(mut self, disabled: bool) -> Self {
544        self.disable_static_token_with_oauth = disabled;
545        self
546    }
547
548    /// Read configuration from the supplied env-style key/value pairs using
549    /// the configured `env_prefix`, then validate and return [`AuthConfig`].
550    pub fn build_from_sources(
551        self,
552        vars: impl IntoIterator<Item = (String, String)>,
553    ) -> Result<AuthConfig, AuthError> {
554        let vars = normalize(vars);
555        let prefix = self.env_prefix.clone();
556        let key_mode = env_key(&prefix, "AUTH_MODE");
557        let key_admin = env_key(&prefix, "AUTH_ADMIN_EMAIL");
558        let key_public_url = env_key(&prefix, "PUBLIC_URL");
559        let key_db = env_key(&prefix, "AUTH_SQLITE_PATH");
560        let key_keypath = env_key(&prefix, "AUTH_KEY_PATH");
561        let key_secret = env_key(&prefix, "AUTH_BOOTSTRAP_SECRET");
562        let key_redirects = env_key(&prefix, "AUTH_ALLOWED_REDIRECT_URIS");
563        let key_g_id = env_key(&prefix, "GOOGLE_CLIENT_ID");
564        let key_g_secret = env_key(&prefix, "GOOGLE_CLIENT_SECRET");
565        let key_g_callback = env_key(&prefix, "GOOGLE_CALLBACK_PATH");
566        let key_g_scopes = env_key(&prefix, "GOOGLE_SCOPES");
567        let key_a_issuer = env_key(&prefix, "AUTHELIA_ISSUER_URL");
568        let key_a_id = env_key(&prefix, "AUTHELIA_CLIENT_ID");
569        let key_a_secret = env_key(&prefix, "AUTHELIA_CLIENT_SECRET");
570        let key_a_callback = env_key(&prefix, "AUTHELIA_CALLBACK_PATH");
571        let key_a_scopes = env_key(&prefix, "AUTHELIA_SCOPES");
572        let key_gh_id = env_key(&prefix, "GITHUB_CLIENT_ID");
573        let key_gh_secret = env_key(&prefix, "GITHUB_CLIENT_SECRET");
574        let key_gh_callback = env_key(&prefix, "GITHUB_CALLBACK_PATH");
575        let key_gh_scopes = env_key(&prefix, "GITHUB_SCOPES");
576        let key_default_provider = env_key(&prefix, "AUTH_DEFAULT_PROVIDER");
577        let key_at_ttl = env_key(&prefix, "AUTH_ACCESS_TOKEN_TTL_SECS");
578        let key_rt_ttl = env_key(&prefix, "AUTH_REFRESH_TOKEN_TTL_SECS");
579        let key_code_ttl = env_key(&prefix, "AUTH_CODE_TTL_SECS");
580        let key_reg_rpm = env_key(&prefix, "AUTH_REGISTER_REQUESTS_PER_MINUTE");
581        let key_az_rpm = env_key(&prefix, "AUTH_AUTHORIZE_REQUESTS_PER_MINUTE");
582        let key_max_pending = env_key(&prefix, "AUTH_MAX_PENDING_OAUTH_STATES");
583        let key_enc_key = env_key(&prefix, "TOKEN_ENCRYPTION_KEY");
584
585        let mode = AuthMode::parse(vars.get(&key_mode).map(String::as_str), &key_mode)?;
586        let admin_email = read_string(&vars, &key_admin)
587            .map(|raw| raw.trim().to_ascii_lowercase())
588            .unwrap_or_default();
589        let base_dir = self
590            .default_data_dir
591            .clone()
592            .unwrap_or_else(default_auth_dir);
593        let google_client_id = read_string(&vars, &key_g_id).unwrap_or_default();
594        let authelia_client_id = read_string(&vars, &key_a_id).unwrap_or_default();
595        let github_client_id = read_string(&vars, &key_gh_id).unwrap_or_default();
596        let default_provider = read_string(&vars, &key_default_provider)
597            .map(|raw| raw.trim().to_ascii_lowercase())
598            .filter(|value| !value.is_empty())
599            .unwrap_or_else(|| {
600                if !google_client_id.is_empty() {
601                    "google".to_string()
602                } else if !authelia_client_id.is_empty() {
603                    "authelia".to_string()
604                } else if !github_client_id.is_empty() {
605                    "github".to_string()
606                } else {
607                    "google".to_string()
608                }
609            });
610        let config = AuthConfig {
611            mode,
612            public_url: read_url(&vars, &key_public_url)?,
613            sqlite_path: read_path(&vars, &key_db)
614                .unwrap_or_else(|| base_dir.join(DEFAULT_AUTH_DB_NAME)),
615            key_path: read_path(&vars, &key_keypath)
616                .unwrap_or_else(|| base_dir.join(DEFAULT_KEY_NAME)),
617            bootstrap_secret: read_string(&vars, &key_secret),
618            allowed_client_redirect_uris: read_csv(&vars, &key_redirects).unwrap_or_default(),
619            admin_email,
620            google: GoogleConfig {
621                client_id: google_client_id.clone(),
622                client_secret: read_string(&vars, &key_g_secret).unwrap_or_default(),
623                callback_path: read_string(&vars, &key_g_callback)
624                    .unwrap_or_else(|| DEFAULT_CALLBACK_PATH.to_string()),
625                scopes: read_csv(&vars, &key_g_scopes).unwrap_or_else(default_google_scopes),
626            },
627            authelia: AutheliaConfig {
628                issuer_url: read_url(&vars, &key_a_issuer)?,
629                client_id: read_string(&vars, &key_a_id).unwrap_or_default(),
630                client_secret: read_string(&vars, &key_a_secret).unwrap_or_default(),
631                callback_path: read_string(&vars, &key_a_callback)
632                    .unwrap_or_else(default_authelia_callback_path),
633                scopes: read_csv(&vars, &key_a_scopes).unwrap_or_else(default_authelia_scopes),
634            },
635            github: GitHubConfig {
636                client_id: read_string(&vars, &key_gh_id).unwrap_or_default(),
637                client_secret: read_string(&vars, &key_gh_secret).unwrap_or_default(),
638                callback_path: read_string(&vars, &key_gh_callback)
639                    .unwrap_or_else(default_github_callback_path),
640                scopes: read_csv(&vars, &key_gh_scopes).unwrap_or_else(default_github_scopes),
641            },
642            default_provider,
643            access_token_ttl: Duration::from_secs(
644                read_u64(&vars, &key_at_ttl)?.unwrap_or(DEFAULT_ACCESS_TOKEN_TTL_SECS),
645            ),
646            refresh_token_ttl: Duration::from_secs(
647                read_u64(&vars, &key_rt_ttl)?.unwrap_or(DEFAULT_REFRESH_TOKEN_TTL_SECS),
648            ),
649            auth_code_ttl: Duration::from_secs(
650                read_u64(&vars, &key_code_ttl)?.unwrap_or(DEFAULT_AUTH_CODE_TTL_SECS),
651            ),
652            register_requests_per_minute: read_u32(&vars, &key_reg_rpm)?
653                .unwrap_or(DEFAULT_REGISTER_REQUESTS_PER_MINUTE),
654            authorize_requests_per_minute: read_u32(&vars, &key_az_rpm)?
655                .unwrap_or(DEFAULT_AUTHORIZE_REQUESTS_PER_MINUTE),
656            max_pending_oauth_states: read_usize(&vars, &key_max_pending)?
657                .unwrap_or(DEFAULT_MAX_PENDING_OAUTH_STATES),
658            env_prefix: prefix,
659            default_data_dir: base_dir,
660            session_cookie_name: self.session_cookie_name,
661            scopes_supported: self.scopes_supported,
662            resource_path: self.resource_path,
663            default_scope: self.default_scope,
664            static_token_scopes: self.static_token_scopes,
665            login_path: self.login_path,
666            enable_dynamic_registration: self.enable_dynamic_registration,
667            disable_static_token_with_oauth: self.disable_static_token_with_oauth,
668            token_encryption_key: read_string(&vars, &key_enc_key)
669                .map(|raw| {
670                    TokenEncryptionKey::from_encoded(&raw)
671                        .map_err(|e| AuthError::Config(format!("invalid {key_enc_key}: {e}")))
672                })
673                .transpose()?,
674        };
675
676        config.validate()?;
677        Ok(config)
678    }
679}
680
681fn env_key(prefix: &str, suffix: &str) -> String {
682    let trimmed = prefix.trim_end_matches('_');
683    if trimmed.is_empty() {
684        suffix.to_string()
685    } else {
686        format!("{trimmed}_{suffix}")
687    }
688}
689
690fn normalize(vars: impl IntoIterator<Item = (String, String)>) -> HashMap<String, String> {
691    vars.into_iter()
692        .filter_map(|(key, value)| {
693            let trimmed = value.trim();
694            if trimmed.is_empty() {
695                None
696            } else {
697                Some((key, trimmed.to_string()))
698            }
699        })
700        .collect()
701}
702
703fn default_auth_dir() -> PathBuf {
704    home_dir().map_or_else(|| PathBuf::from(".soma"), |home| home.join(".soma"))
705}
706
707fn home_dir() -> Option<PathBuf> {
708    std::env::var_os("HOME")
709        .or_else(|| std::env::var_os("USERPROFILE"))
710        .map(PathBuf::from)
711}
712
713fn read_string(vars: &HashMap<String, String>, key: &str) -> Option<String> {
714    vars.get(key).cloned()
715}
716
717fn read_path(vars: &HashMap<String, String>, key: &str) -> Option<PathBuf> {
718    read_string(vars, key).map(PathBuf::from)
719}
720
721fn read_csv(vars: &HashMap<String, String>, key: &str) -> Option<Vec<String>> {
722    read_string(vars, key).map(|value| {
723        value
724            .split(',')
725            .map(str::trim)
726            .filter(|entry| !entry.is_empty())
727            .map(ToOwned::to_owned)
728            .collect()
729    })
730}
731
732fn read_url(vars: &HashMap<String, String>, key: &str) -> Result<Option<Url>, AuthError> {
733    read_string(vars, key)
734        .map(|value| {
735            Url::parse(&value)
736                .map_err(|error| AuthError::Config(format!("{key} must be a valid URL: {error}")))
737        })
738        .transpose()
739}
740
741fn read_u64(vars: &HashMap<String, String>, key: &str) -> Result<Option<u64>, AuthError> {
742    read_string(vars, key)
743        .map(|value| {
744            value.parse::<u64>().map_err(|error| {
745                AuthError::Config(format!(
746                    "{key} must be an integer number of seconds: {error}"
747                ))
748            })
749        })
750        .transpose()
751}
752
753fn read_u32(vars: &HashMap<String, String>, key: &str) -> Result<Option<u32>, AuthError> {
754    read_string(vars, key)
755        .map(|value| {
756            value.parse::<u32>().map_err(|error| {
757                AuthError::Config(format!(
758                    "{key} must be an integer number of requests per minute: {error}"
759                ))
760            })
761        })
762        .transpose()
763}
764
765fn read_usize(vars: &HashMap<String, String>, key: &str) -> Result<Option<usize>, AuthError> {
766    read_string(vars, key)
767        .map(|value| {
768            value.parse::<usize>().map_err(|error| {
769                AuthError::Config(format!("{key} must be a positive integer: {error}"))
770            })
771        })
772        .transpose()
773}
774
775#[cfg(test)]
776mod tests {
777    use super::{AuthConfig, AuthConfigBuilder, AuthMode, AuthModeConfig, AutheliaConfig};
778
779    /// Guards against a regression where `GoogleConfig`/`AutheliaConfig`/
780    /// `GitHubConfig` derived `Default` (giving `callback_path: String::new()`
781    /// instead of the `#[serde(default = "fn")]` value) made `validate()`'s
782    /// unconditional Google callback-path check reject ANY struct-literal
783    /// `AuthConfig` that configures only Authelia/GitHub and relies on
784    /// `..AuthConfig::default()` for the unused `google` field — a shape that
785    /// bypasses `AuthConfigBuilder` entirely (test fixtures, or a downstream
786    /// consumer constructing `AuthConfig` directly).
787    #[test]
788    fn validate_accepts_a_struct_literal_config_configuring_only_authelia() {
789        let cfg = AuthConfig {
790            mode: AuthMode::OAuth,
791            public_url: Some(url::Url::parse("https://lab.example.com").unwrap()),
792            admin_email: "admin@example.com".to_string(),
793            authelia: AutheliaConfig {
794                issuer_url: Some(url::Url::parse("https://auth.example.com").unwrap()),
795                client_id: "id".to_string(),
796                client_secret: "secret".to_string(),
797                ..AutheliaConfig::default()
798            },
799            default_provider: "authelia".to_string(),
800            ..AuthConfig::default()
801        };
802        cfg.validate().expect(
803            "google's untouched defaults must not block validation of an authelia-only config",
804        );
805    }
806
807    #[test]
808    fn bearer_mode_preserves_existing_http_token_behavior() {
809        let cfg = AuthModeConfig::from_sources(fake_env_with("LAB_AUTH_MODE", "bearer")).unwrap();
810        assert!(matches!(cfg.mode, AuthMode::Bearer));
811    }
812
813    #[test]
814    fn oauth_mode_requires_public_url_and_google_credentials() {
815        let err = AuthConfig::from_sources(fake_env_with_many([
816            ("LAB_AUTH_MODE", "oauth"),
817            ("LAB_GOOGLE_CLIENT_ID", "id"),
818        ]))
819        .unwrap_err();
820        assert!(err.to_string().contains("LAB_PUBLIC_URL"));
821    }
822
823    #[test]
824    fn oauth_mode_requires_at_least_one_configured_provider() {
825        let err = AuthConfig::from_sources(fake_env_with_many([
826            ("LAB_AUTH_MODE", "oauth"),
827            ("LAB_PUBLIC_URL", "https://lab.example.com"),
828            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
829        ]))
830        .unwrap_err();
831        assert!(err.to_string().contains("at least one OAuth provider"));
832    }
833
834    #[test]
835    fn oauth_mode_accepts_authelia_only_configuration() {
836        let cfg = AuthConfig::from_sources(fake_env_with_many([
837            ("LAB_AUTH_MODE", "oauth"),
838            ("LAB_PUBLIC_URL", "https://lab.example.com"),
839            ("LAB_AUTHELIA_ISSUER_URL", "https://auth.example.com"),
840            ("LAB_AUTHELIA_CLIENT_ID", "id"),
841            ("LAB_AUTHELIA_CLIENT_SECRET", "secret"),
842            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
843        ]))
844        .unwrap();
845        assert_eq!(cfg.default_provider, "authelia");
846    }
847
848    #[test]
849    fn oauth_mode_accepts_github_only_configuration() {
850        let cfg = AuthConfig::from_sources(fake_env_with_many([
851            ("LAB_AUTH_MODE", "oauth"),
852            ("LAB_PUBLIC_URL", "https://lab.example.com"),
853            ("LAB_GITHUB_CLIENT_ID", "id"),
854            ("LAB_GITHUB_CLIENT_SECRET", "secret"),
855            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
856        ]))
857        .unwrap();
858        assert_eq!(cfg.default_provider, "github");
859    }
860
861    #[test]
862    fn oauth_mode_rejects_github_scopes_missing_user_email() {
863        let err = AuthConfig::from_sources(fake_env_with_many([
864            ("LAB_AUTH_MODE", "oauth"),
865            ("LAB_PUBLIC_URL", "https://lab.example.com"),
866            ("LAB_GITHUB_CLIENT_ID", "id"),
867            ("LAB_GITHUB_CLIENT_SECRET", "secret"),
868            ("LAB_GITHUB_SCOPES", "read:user"),
869            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
870        ]))
871        .unwrap_err();
872        assert!(err.to_string().contains("user:email"));
873    }
874
875    #[test]
876    fn oauth_mode_default_provider_prefers_google_when_multiple_are_configured() {
877        let cfg = AuthConfig::from_sources(fake_env_with_many([
878            ("LAB_AUTH_MODE", "oauth"),
879            ("LAB_PUBLIC_URL", "https://lab.example.com"),
880            ("LAB_GOOGLE_CLIENT_ID", "id"),
881            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
882            ("LAB_GITHUB_CLIENT_ID", "gh-id"),
883            ("LAB_GITHUB_CLIENT_SECRET", "gh-secret"),
884            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
885        ]))
886        .unwrap();
887        assert_eq!(cfg.default_provider, "google");
888    }
889
890    #[test]
891    fn oauth_mode_rejects_default_provider_naming_an_unconfigured_provider() {
892        let err = AuthConfig::from_sources(fake_env_with_many([
893            ("LAB_AUTH_MODE", "oauth"),
894            ("LAB_PUBLIC_URL", "https://lab.example.com"),
895            ("LAB_GOOGLE_CLIENT_ID", "id"),
896            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
897            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
898            ("LAB_AUTH_DEFAULT_PROVIDER", "github"),
899        ]))
900        .unwrap_err();
901        assert!(err.to_string().contains("LAB_AUTH_DEFAULT_PROVIDER=github"));
902    }
903
904    #[test]
905    fn oauth_mode_rejects_a_non_https_authelia_issuer_url() {
906        let err = AuthConfig::from_sources(fake_env_with_many([
907            ("LAB_AUTH_MODE", "oauth"),
908            ("LAB_PUBLIC_URL", "https://lab.example.com"),
909            ("LAB_AUTHELIA_ISSUER_URL", "http://auth.internal"),
910            ("LAB_AUTHELIA_CLIENT_ID", "id"),
911            ("LAB_AUTHELIA_CLIENT_SECRET", "secret"),
912            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
913        ]))
914        .unwrap_err();
915        assert!(
916            err.to_string()
917                .contains("LAB_AUTHELIA_ISSUER_URL must use https")
918        );
919    }
920
921    #[test]
922    fn oauth_mode_rejects_two_configured_providers_sharing_a_callback_path() {
923        let err = AuthConfig::from_sources(fake_env_with_many([
924            ("LAB_AUTH_MODE", "oauth"),
925            ("LAB_PUBLIC_URL", "https://lab.example.com"),
926            ("LAB_GOOGLE_CLIENT_ID", "id"),
927            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
928            ("LAB_GITHUB_CLIENT_ID", "gh-id"),
929            ("LAB_GITHUB_CLIENT_SECRET", "gh-secret"),
930            ("LAB_GITHUB_CALLBACK_PATH", "/auth/google/callback"),
931            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
932        ]))
933        .unwrap_err();
934        assert!(
935            err.to_string()
936                .contains("must not both resolve to `/auth/google/callback`")
937        );
938    }
939
940    #[test]
941    fn oauth_mode_rejects_two_configured_providers_sharing_a_callback_path_missing_a_leading_slash()
942    {
943        // A `callback_path` without a leading `/` still mounts at the same
944        // normalized route as one that has it (build_provider_redirect_uri
945        // in state.rs prepends the missing `/`), so the collision check must
946        // catch this even though the raw strings don't textually match.
947        let err = AuthConfig::from_sources(fake_env_with_many([
948            ("LAB_AUTH_MODE", "oauth"),
949            ("LAB_PUBLIC_URL", "https://lab.example.com"),
950            ("LAB_GOOGLE_CLIENT_ID", "id"),
951            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
952            ("LAB_GITHUB_CLIENT_ID", "gh-id"),
953            ("LAB_GITHUB_CLIENT_SECRET", "gh-secret"),
954            ("LAB_GITHUB_CALLBACK_PATH", "auth/google/callback"),
955            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
956        ]))
957        .unwrap_err();
958        assert!(
959            err.to_string()
960                .contains("must not both resolve to `/auth/google/callback`"),
961            "unexpected error: {err}"
962        );
963    }
964
965    #[test]
966    fn oauth_mode_rejects_a_callback_path_colliding_with_a_fixed_crate_route() {
967        let err = AuthConfig::from_sources(fake_env_with_many([
968            ("LAB_AUTH_MODE", "oauth"),
969            ("LAB_PUBLIC_URL", "https://lab.example.com"),
970            ("LAB_GOOGLE_CLIENT_ID", "id"),
971            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
972            ("LAB_GOOGLE_CALLBACK_PATH", "/authorize"),
973            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
974        ]))
975        .unwrap_err();
976        assert!(
977            err.to_string().contains("must not resolve to `/authorize`"),
978            "unexpected error: {err}"
979        );
980    }
981
982    #[test]
983    fn oauth_mode_rejects_a_callback_path_colliding_with_a_fixed_crate_route_missing_a_leading_slash()
984     {
985        // Same as above but without the leading `/` on the operator-supplied
986        // value. Uses GitHub, not Google: Google's callback_path has its own
987        // unconditional "must start with `/`" check earlier in validate()
988        // (a different guard than the one under test here), so a Google
989        // fixture would never reach the collision-check normalization this
990        // test exists to cover. GitHub/Authelia have no such standalone
991        // check, so this is the only path that exercises it — the value
992        // still mounts at `/authorize` once state.rs builds the redirect URI.
993        let err = AuthConfig::from_sources(fake_env_with_many([
994            ("LAB_AUTH_MODE", "oauth"),
995            ("LAB_PUBLIC_URL", "https://lab.example.com"),
996            ("LAB_GITHUB_CLIENT_ID", "id"),
997            ("LAB_GITHUB_CLIENT_SECRET", "secret"),
998            ("LAB_GITHUB_CALLBACK_PATH", "authorize"),
999            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
1000        ]))
1001        .unwrap_err();
1002        assert!(
1003            err.to_string().contains("must not resolve to `/authorize`"),
1004            "unexpected error: {err}"
1005        );
1006    }
1007
1008    #[test]
1009    fn oauth_mode_rejects_a_callback_path_under_the_well_known_prefix() {
1010        let err = AuthConfig::from_sources(fake_env_with_many([
1011            ("LAB_AUTH_MODE", "oauth"),
1012            ("LAB_PUBLIC_URL", "https://lab.example.com"),
1013            ("LAB_GOOGLE_CLIENT_ID", "id"),
1014            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
1015            (
1016                "LAB_GOOGLE_CALLBACK_PATH",
1017                "/.well-known/oauth-authorization-server",
1018            ),
1019            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
1020        ]))
1021        .unwrap_err();
1022        assert!(
1023            err.to_string()
1024                .contains("must not resolve to `/.well-known/oauth-authorization-server`"),
1025            "unexpected error: {err}"
1026        );
1027    }
1028
1029    #[test]
1030    fn oauth_mode_defaults_paths_and_callback() {
1031        let cfg = AuthConfig::from_sources(fake_env_with_many([
1032            ("LAB_AUTH_MODE", "oauth"),
1033            ("LAB_PUBLIC_URL", "https://lab.example.com"),
1034            ("LAB_GOOGLE_CLIENT_ID", "id"),
1035            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
1036            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
1037        ]))
1038        .unwrap();
1039        assert_eq!(cfg.sqlite_path.file_name().unwrap(), "auth.db");
1040        assert_eq!(cfg.key_path.file_name().unwrap(), "auth-jwt.pem");
1041        assert_eq!(cfg.google.callback_path, "/auth/google/callback");
1042    }
1043
1044    #[test]
1045    fn oauth_mode_requires_admin_email() {
1046        let err = AuthConfig::from_sources(fake_env_with_many([
1047            ("LAB_AUTH_MODE", "oauth"),
1048            ("LAB_PUBLIC_URL", "https://lab.example.com"),
1049            ("LAB_GOOGLE_CLIENT_ID", "id"),
1050            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
1051        ]))
1052        .unwrap_err();
1053        assert!(err.to_string().contains("LAB_AUTH_ADMIN_EMAIL"));
1054    }
1055
1056    #[test]
1057    fn admin_email_normalizes_case_and_trims_whitespace() {
1058        let cfg = AuthConfig::from_sources(fake_env_with_many([
1059            ("LAB_AUTH_MODE", "oauth"),
1060            ("LAB_PUBLIC_URL", "https://lab.example.com"),
1061            ("LAB_GOOGLE_CLIENT_ID", "id"),
1062            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
1063            ("LAB_AUTH_ADMIN_EMAIL", "  Admin@Example.COM  "),
1064        ]))
1065        .unwrap();
1066        assert_eq!(cfg.admin_email, "admin@example.com");
1067    }
1068
1069    #[test]
1070    fn oauth_mode_parses_allowed_client_redirect_uris() {
1071        let cfg = AuthConfig::from_sources(fake_env_with_many([
1072            ("LAB_AUTH_MODE", "oauth"),
1073            ("LAB_PUBLIC_URL", "https://lab.example.com"),
1074            ("LAB_GOOGLE_CLIENT_ID", "id"),
1075            ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
1076            ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
1077            (
1078                "LAB_AUTH_ALLOWED_REDIRECT_URIS",
1079                "https://callback.tootie.tv/callback/*,https://claude.ai/api/mcp/auth_callback",
1080            ),
1081        ]))
1082        .unwrap();
1083        assert_eq!(
1084            cfg.allowed_client_redirect_uris,
1085            vec![
1086                "https://callback.tootie.tv/callback/*".to_string(),
1087                "https://claude.ai/api/mcp/auth_callback".to_string()
1088            ]
1089        );
1090    }
1091
1092    #[test]
1093    fn default_config_preserves_lab_brand_for_backward_compat() {
1094        let cfg = AuthConfig::default();
1095        assert_eq!(cfg.env_prefix, "LAB");
1096        assert_eq!(cfg.session_cookie_name, "lab_session");
1097        assert_eq!(
1098            cfg.scopes_supported,
1099            vec!["lab".to_string(), "lab:admin".to_string()]
1100        );
1101        assert_eq!(cfg.resource_path, "/mcp");
1102        assert_eq!(cfg.default_scope, "lab");
1103        assert_eq!(
1104            cfg.static_token_scopes,
1105            vec!["lab:read".to_string(), "lab:admin".to_string()]
1106        );
1107        assert_eq!(cfg.login_path, "/auth/login");
1108        assert!(!cfg.enable_dynamic_registration);
1109        assert!(!cfg.disable_static_token_with_oauth);
1110    }
1111
1112    #[test]
1113    fn builder_env_prefix_resolves_consumer_env_vars() {
1114        let cfg = AuthConfigBuilder::new()
1115            .env_prefix("SYSLOG_MCP")
1116            .session_cookie_name("syslog_session")
1117            .scopes_supported(vec!["syslog:read".to_string(), "syslog:admin".to_string()])
1118            .default_scope("syslog:read")
1119            .static_token_scopes(vec!["syslog:read".to_string(), "syslog:admin".to_string()])
1120            .disable_static_token_with_oauth(true)
1121            .build_from_sources(fake_env_with_many([
1122                ("SYSLOG_MCP_AUTH_MODE", "oauth"),
1123                ("SYSLOG_MCP_PUBLIC_URL", "https://syslog.example.com"),
1124                ("SYSLOG_MCP_GOOGLE_CLIENT_ID", "id"),
1125                ("SYSLOG_MCP_GOOGLE_CLIENT_SECRET", "secret"),
1126                ("SYSLOG_MCP_AUTH_ADMIN_EMAIL", "admin@example.com"),
1127            ]))
1128            .unwrap();
1129        assert!(matches!(cfg.mode, AuthMode::OAuth));
1130        assert_eq!(cfg.env_prefix, "SYSLOG_MCP");
1131        assert_eq!(cfg.session_cookie_name, "syslog_session");
1132        assert_eq!(cfg.default_scope, "syslog:read");
1133        assert!(cfg.disable_static_token_with_oauth);
1134        assert_eq!(
1135            cfg.scopes_supported,
1136            vec!["syslog:read".to_string(), "syslog:admin".to_string()]
1137        );
1138    }
1139
1140    #[test]
1141    fn builder_lab_env_vars_ignored_when_prefix_is_overridden() {
1142        // Vars use LAB_*; builder is set to SYSLOG_MCP — so AUTH_MODE goes
1143        // unread, defaults to bearer, and PUBLIC_URL stays None.
1144        let cfg = AuthConfigBuilder::new()
1145            .env_prefix("SYSLOG_MCP")
1146            .build_from_sources(fake_env_with_many([
1147                ("LAB_AUTH_MODE", "oauth"),
1148                ("LAB_PUBLIC_URL", "https://lab.example.com"),
1149                ("LAB_GOOGLE_CLIENT_ID", "id"),
1150                ("LAB_GOOGLE_CLIENT_SECRET", "secret"),
1151                ("LAB_AUTH_ADMIN_EMAIL", "admin@example.com"),
1152            ]))
1153            .unwrap();
1154        assert!(matches!(cfg.mode, AuthMode::Bearer));
1155        assert!(cfg.public_url.is_none());
1156    }
1157
1158    #[test]
1159    fn builder_validates_resource_path_starts_with_slash() {
1160        let err = AuthConfigBuilder::new()
1161            .resource_path("mcp")
1162            .build_from_sources(Vec::<(String, String)>::new())
1163            .unwrap_err();
1164        assert!(err.to_string().contains("resource_path"));
1165    }
1166
1167    #[test]
1168    fn builder_validates_login_path_starts_with_slash() {
1169        let err = AuthConfigBuilder::new()
1170            .login_path("auth/login")
1171            .build_from_sources(Vec::<(String, String)>::new())
1172            .unwrap_err();
1173        assert!(err.to_string().contains("login_path"));
1174    }
1175
1176    fn fake_env_with(key: &'static str, value: &'static str) -> Vec<(String, String)> {
1177        vec![(key.to_string(), value.to_string())]
1178    }
1179
1180    fn fake_env_with_many<const N: usize>(
1181        pairs: [(&'static str, &'static str); N],
1182    ) -> Vec<(String, String)> {
1183        pairs
1184            .into_iter()
1185            .map(|(key, value)| (key.to_string(), value.to_string()))
1186            .collect()
1187    }
1188}