//! Server configuration, read from the environment. //! //! Environment variables rather than a file because the server is meant to run //! in a container behind a reverse proxy, where that is the native way to //! configure things — and there are few enough settings that a file would be //! all ceremony. use std::net::SocketAddr; use std::path::PathBuf; use anyhow::{bail, Context}; #[derive(Debug, Clone)] pub struct Config { /// The URL users reach the server at, as the reverse proxy serves it. Used /// to build the OIDC redirect URI, so it must match what the provider has /// registered exactly. pub public_url: String, pub bind: SocketAddr, pub db_path: PathBuf, /// Username/password accounts, managed with `mtgc-server user ...`. pub local_auth: bool, pub oidc: Option, } #[derive(Debug, Clone)] pub struct OidcConfig { pub issuer: String, pub client_id: String, pub client_secret: String, pub scopes: Vec, /// Shown on the sign-in button: "Sign in with ". pub display_name: String, /// When non-empty, only these (verified) addresses may sign in. Empty means /// the provider alone decides who gets in, which is usually right: limiting /// access to the application is a provider-side setting in Authentik and /// Keycloak alike. pub allowed_emails: Vec, } impl Config { pub fn from_env() -> anyhow::Result { Self::from_lookup(|k| std::env::var(k).ok()) } /// Read settings through `get`, so tests can supply them without touching /// the process environment. pub fn from_lookup(get: impl Fn(&str) -> Option) -> anyhow::Result { let get = |k: &str| get(k).map(|v| v.trim().to_string()).filter(|v| !v.is_empty()); let public_url = get("MTGC_PUBLIC_URL") .context("MTGC_PUBLIC_URL is required — the https:// address the server is reached at")? .trim_end_matches('/') .to_string(); url::Url::parse(&public_url).context("MTGC_PUBLIC_URL isn't a valid URL")?; let bind = get("MTGC_BIND") .unwrap_or_else(|| "0.0.0.0:8080".into()) .parse() .context("MTGC_BIND must look like 0.0.0.0:8080")?; let db_path = PathBuf::from(get("MTGC_DB").unwrap_or_else(|| "mtgc-server.db".into())); let local_auth = match get("MTGC_LOCAL_AUTH").as_deref() { None => true, Some(v) => parse_bool(v).context("MTGC_LOCAL_AUTH must be true or false")?, }; let oidc = match get("MTGC_OIDC_ISSUER") { None => None, Some(issuer) => Some(OidcConfig { issuer, client_id: get("MTGC_OIDC_CLIENT_ID") .context("MTGC_OIDC_CLIENT_ID is required when MTGC_OIDC_ISSUER is set")?, client_secret: get("MTGC_OIDC_CLIENT_SECRET") .context("MTGC_OIDC_CLIENT_SECRET is required when MTGC_OIDC_ISSUER is set")?, scopes: get("MTGC_OIDC_SCOPES") .unwrap_or_else(|| "openid email profile".into()) .split_whitespace() .filter(|s| *s != "openid") // always requested .map(str::to_string) .collect(), display_name: get("MTGC_OIDC_NAME").unwrap_or_else(|| "single sign-on".into()), allowed_emails: get("MTGC_ALLOWED_EMAILS") .map(|v| { v.split(',') .map(|e| e.trim().to_lowercase()) .filter(|e| !e.is_empty()) .collect() }) .unwrap_or_default(), }), }; if !local_auth && oidc.is_none() { bail!( "no way to sign in: set MTGC_OIDC_ISSUER (and its client settings), \ or leave MTGC_LOCAL_AUTH enabled" ); } Ok(Self { public_url, bind, db_path, local_auth, oidc }) } pub fn oidc_redirect_uri(&self) -> String { format!("{}/auth/callback", self.public_url) } } fn parse_bool(v: &str) -> Option { match v.to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), _ => None, } } #[cfg(test)] mod tests { use std::collections::HashMap; use super::*; fn cfg(vars: &[(&str, &str)]) -> anyhow::Result { let map: HashMap = vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); Config::from_lookup(|k| map.get(k).cloned()) } #[test] fn local_accounts_alone_need_only_the_public_url() { let c = cfg(&[("MTGC_PUBLIC_URL", "https://mtgc.example/")]).unwrap(); assert!(c.local_auth); assert!(c.oidc.is_none()); assert_eq!(c.oidc_redirect_uri(), "https://mtgc.example/auth/callback"); } #[test] fn disabling_every_sign_in_method_is_refused() { let err = cfg(&[("MTGC_PUBLIC_URL", "https://m.example"), ("MTGC_LOCAL_AUTH", "false")]) .unwrap_err(); assert!(err.to_string().contains("no way to sign in")); } #[test] fn oidc_needs_its_client_settings() { let err = cfg(&[ ("MTGC_PUBLIC_URL", "https://m.example"), ("MTGC_OIDC_ISSUER", "https://id.example/realms/home"), ]) .unwrap_err(); assert!(err.to_string().contains("MTGC_OIDC_CLIENT_ID")); } #[test] fn oidc_settings_are_read() { let c = cfg(&[ ("MTGC_PUBLIC_URL", "https://m.example"), ("MTGC_LOCAL_AUTH", "off"), ("MTGC_OIDC_ISSUER", "https://id.example/realms/home"), ("MTGC_OIDC_CLIENT_ID", "mtgc"), ("MTGC_OIDC_CLIENT_SECRET", "s3cret"), ("MTGC_OIDC_NAME", "Authentik"), ("MTGC_ALLOWED_EMAILS", "Me@Example.com, you@example.com"), ]) .unwrap(); let o = c.oidc.unwrap(); assert!(!c.local_auth); assert_eq!(o.scopes, vec!["email", "profile"]); assert_eq!(o.allowed_emails, vec!["me@example.com", "you@example.com"]); assert_eq!(o.display_name, "Authentik"); } }