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
29const FIXED_ROUTE_PATHS: &[&str] = &[
35 "/authorize",
36 "/token",
37 "/jwks",
38 "/auth/login",
39 "/native/callback",
40 "/native/poll",
41 "/register",
42];
43const WELL_KNOWN_PREFIX: &str = "/.well-known/";
46
47pub const DEFAULT_ENV_PREFIX: &str = "LAB";
50pub const DEFAULT_SESSION_COOKIE_NAME: &str = "lab_session";
52pub const DEFAULT_SCOPE: &str = "lab";
54pub const DEFAULT_RESOURCE_PATH: &str = "/mcp";
56pub 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 pub admin_email: String,
121 pub google: GoogleConfig,
122 pub authelia: AutheliaConfig,
123 pub github: GitHubConfig,
124 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 pub env_prefix: String,
143 pub default_data_dir: PathBuf,
146 pub session_cookie_name: String,
149 pub scopes_supported: Vec<String>,
152 pub resource_path: String,
155 pub default_scope: String,
158 pub static_token_scopes: Vec<String>,
163 pub login_path: String,
165 pub enable_dynamic_registration: bool,
168 pub disable_static_token_with_oauth: bool,
172 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 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 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 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 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 {
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 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#[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 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 #[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 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 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 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}