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