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
34const FIXED_ROUTE_PATHS: &[&str] = &[
40 "/authorize",
41 "/token",
42 "/revoke",
43 "/jwks",
44 "/auth/login",
45 "/native/callback",
46 "/native/poll",
47 "/register",
48];
49const WELL_KNOWN_PREFIX: &str = "/.well-known/";
52
53pub const DEFAULT_ENV_PREFIX: &str = "LAB";
56pub const DEFAULT_SESSION_COOKIE_NAME: &str = "lab_session";
58pub const DEFAULT_SCOPE: &str = "lab";
60pub const DEFAULT_RESOURCE_PATH: &str = "/mcp";
62pub 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 pub admin_email: String,
127 pub google: GoogleConfig,
128 pub authelia: AutheliaConfig,
129 pub github: GitHubConfig,
130 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 pub env_prefix: String,
150 pub default_data_dir: PathBuf,
153 pub session_cookie_name: String,
156 pub scopes_supported: Vec<String>,
159 pub resource_path: String,
162 pub default_scope: String,
165 pub static_token_scopes: Vec<String>,
170 pub login_path: String,
172 pub enable_dynamic_registration: bool,
175 pub disable_static_token_with_oauth: bool,
179 pub token_encryption_key: Option<TokenEncryptionKey>,
186 pub machine_clients: Vec<MachineClientConfig>,
188 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 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 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 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 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 {
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 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#[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 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 #[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 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 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 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}