//! Runtime configuration for an OAuth client profile. //! //! [`OAuthProfile`] is the single source of truth for *where* to authenticate //! (`issuer`), *how* the callback returns (`redirect_uri`), *what* is requested //! (`scope`, `client`), *which optional features* are available //! (`capabilities`), and *how permissive* the developer environment is //! (`dev`). //! //! Values may be supplied programmatically, loaded from a file, or overlaid //! from environment variables via [`OAuthProfile::from_env`] and //! [`OAuthProfile::apply_env_overrides`]. Secret material (the client secret) //! is held in a [`SecretString`] and is never emitted by [`fmt::Debug`]. use std::{env, fmt}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use crate::{ error::{ConfigError, Result}, redact::redact_secret, }; /// The complete client configuration for one authorization server. #[derive(Clone, Default)] pub struct OAuthProfile { /// The authorization server issuer identifier. pub issuer: String, /// The registered redirect URI the authorization server calls back. pub redirect_uri: String, /// The space-delimited scope string to request. pub scope: String, /// The confidential client's credentials. pub client: ClientCredentials, /// Optional server features discovered or configured for this issuer. pub capabilities: Capabilities, /// Development-only settings. pub dev: DevSettings, } /// The confidential client's credentials. /// /// `client_secret` is wrapped in a [`SecretString`] so it can only be read /// through a deliberate [`ExposeSecret`] call, and this type's [`fmt::Debug`] /// renders the secret as a redaction marker. #[derive(Clone)] pub struct ClientCredentials { /// The registered public client identifier. pub client_id: String, /// The registered client secret. pub client_secret: SecretString, } /// Boolean capability flags for a target authorization server. /// /// These are derived from discovery metadata (or overridden in configuration) /// and let the flow degrade explicitly instead of silently faking success. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct Capabilities { /// Pushed authorization requests (RFC 9126) are available. pub par: bool, /// DPoP sender-constrained tokens (RFC 9449) are available. pub dpop: bool, /// Token introspection (RFC 7662) is available. pub introspection: bool, /// An OIDC `userinfo_endpoint` is available. pub userinfo: bool, /// Dynamic client registration (RFC 7591) is available. pub dcr: bool, /// Refresh tokens are supported by the token endpoint. pub refresh: bool, } /// Development-only settings. All defaults are fail-closed. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct DevSettings { /// Trust the authorization server's certificate even if it is self-signed /// or otherwise unverifiable. Defaults to `false`; only enable for a local /// development server. pub insecure_tls: bool, } impl OAuthProfile { /// Environment variable for the issuer. pub const ENV_ISSUER: &'static str = "OAUTH_ISSUER"; /// Environment variable for the redirect URI. pub const ENV_REDIRECT_URI: &'static str = "OAUTH_REDIRECT_URI"; /// Environment variable for the requested scope. pub const ENV_SCOPE: &'static str = "OAUTH_SCOPE"; /// Environment variable for the client id. pub const ENV_CLIENT_ID: &'static str = "OAUTH_CLIENT_ID"; /// Environment variable for the client secret. pub const ENV_CLIENT_SECRET: &'static str = "OAUTH_CLIENT_SECRET"; /// Environment variable to opt into insecure TLS (dev only). pub const ENV_DEV_INSECURE_TLS: &'static str = "OAUTH_DEV_INSECURE_TLS"; /// Build a profile from the process environment, starting from /// [`OAuthProfile::default`]. /// /// Missing variables leave the corresponding default in place; this does not /// validate that required values are present (callers that need strict /// validation should use [`ConfigError::Missing`]). pub fn from_env() -> Result { let mut profile = Self::default(); profile.apply_env_overrides()?; Ok(profile) } /// Overlay the process environment onto this profile. /// /// Reads [`OAuthProfile::ENV_ISSUER`], [`OAuthProfile::ENV_REDIRECT_URI`], /// [`OAuthProfile::ENV_SCOPE`], [`OAuthProfile::ENV_CLIENT_ID`], /// [`OAuthProfile::ENV_CLIENT_SECRET`], and /// [`OAuthProfile::ENV_DEV_INSECURE_TLS`]. Absent variables are ignored. /// /// # Errors /// /// Returns [`ConfigError::Invalid`] if a present value is not valid UTF-8 or /// the TLS flag is not a boolean. pub fn apply_env_overrides(&mut self) -> Result<()> { self.apply_env_overrides_from(|key| match env::var(key) { Ok(value) => Ok(Some(value)), Err(env::VarError::NotPresent) => Ok(None), Err(env::VarError::NotUnicode(_)) => Err( ConfigError::Invalid { key: key.to_owned(), reason: "environment value is not valid UTF-8".to_owned(), } .into(), ), }) } /// Overlay values from an arbitrary lookup, keyed by the same environment /// variable names [`OAuthProfile::apply_env_overrides`] uses. /// /// This is the testable, side-effect-free form of the env overlay; the real /// loader passes a closure over [`env::var`]. /// /// # Errors /// /// Propagates lookup errors and rejects non-boolean TLS values. pub fn apply_env_overrides_from(&mut self, lookup: F) -> Result<()> where F: Fn(&str) -> Result>, { if let Some(value) = lookup(Self::ENV_ISSUER)? { self.issuer = value; } if let Some(value) = lookup(Self::ENV_REDIRECT_URI)? { self.redirect_uri = value; } if let Some(value) = lookup(Self::ENV_SCOPE)? { self.scope = value; } if let Some(value) = lookup(Self::ENV_CLIENT_ID)? { self.client.client_id = value; } if let Some(value) = lookup(Self::ENV_CLIENT_SECRET)? { self.client.client_secret = SecretString::from(value); } if let Some(value) = lookup(Self::ENV_DEV_INSECURE_TLS)? { self.dev.insecure_tls = parse_bool(Self::ENV_DEV_INSECURE_TLS, &value)?; } Ok(()) } } impl Default for ClientCredentials { fn default() -> Self { Self { client_id: String::new(), client_secret: SecretString::from(String::new()), } } } impl fmt::Debug for OAuthProfile { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("OAuthProfile") .field("issuer", &self.issuer) .field("redirect_uri", &self.redirect_uri) .field("scope", &self.scope) .field("client", &self.client) .field("capabilities", &self.capabilities) .field("dev", &self.dev) .finish() } } impl fmt::Debug for ClientCredentials { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ClientCredentials") .field("client_id", &self.client_id) .field( "client_secret", &redact_secret(self.client_secret.expose_secret()), ) .finish() } } /// Parse a permissive boolean (`true`/`false`/`1`/`0`, case-insensitive). fn parse_bool(key: &str, value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "true" | "1" => Ok(true), "false" | "0" => Ok(false), _ => Err( ConfigError::Invalid { key: key.to_owned(), reason: format!("expected a boolean, got `{value}`"), } .into(), ), } } #[cfg(test)] mod tests { use std::collections::HashMap; use super::*; use crate::error::OAuthError; fn profile_with_secret(secret: &str) -> OAuthProfile { OAuthProfile { issuer: "https://as.example".to_owned(), redirect_uri: "http://127.0.0.1:3000/callback".to_owned(), scope: "demo.read".to_owned(), client: ClientCredentials { client_id: "demo-client".to_owned(), client_secret: SecretString::from(secret.to_owned()), }, capabilities: Capabilities::default(), dev: DevSettings::default(), } } #[test] fn debug_omits_client_secret() { let profile = profile_with_secret("S3CRET"); let rendered = format!("{profile:?}"); // Assert absence of the secret; the marker must take its place. assert!( !rendered.contains("S3CRET"), "client secret leaked into Debug output: {rendered}" ); assert!(rendered.contains("[REDACTED]")); assert!(rendered.contains("demo-client")); assert!(rendered.contains("https://as.example")); } #[test] fn debug_omits_client_secret_when_nested_directly() { let credentials = ClientCredentials { client_id: "demo-client".to_owned(), client_secret: SecretString::from("S3CRET".to_owned()), }; let rendered = format!("{credentials:?}"); assert!(!rendered.contains("S3CRET")); assert!(rendered.contains("[REDACTED]")); } #[test] fn insecure_tls_defaults_to_false() { assert!(!DevSettings::default().insecure_tls); assert!(!OAuthProfile::default().dev.insecure_tls); } #[test] fn env_overlay_from_lookup_updates_fields() { let env = HashMap::from([ (OAuthProfile::ENV_ISSUER, "https://env.example"), ( OAuthProfile::ENV_REDIRECT_URI, "http://127.0.0.1:4280/callback", ), (OAuthProfile::ENV_SCOPE, "env.read"), (OAuthProfile::ENV_CLIENT_ID, "env-client"), (OAuthProfile::ENV_CLIENT_SECRET, "ENVS3CRET"), (OAuthProfile::ENV_DEV_INSECURE_TLS, "true"), ]); let mut profile = OAuthProfile::default(); profile .apply_env_overrides_from(|key| Ok(env.get(key).map(|value| (*value).to_owned()))) .expect("lookup cannot fail"); assert_eq!(profile.issuer, "https://env.example"); assert_eq!(profile.redirect_uri, "http://127.0.0.1:4280/callback"); assert_eq!(profile.scope, "env.read"); assert_eq!(profile.client.client_id, "env-client"); assert_eq!(profile.client.client_secret.expose_secret(), "ENVS3CRET"); assert!(profile.dev.insecure_tls); assert!(!format!("{profile:?}").contains("ENVS3CRET")); } #[test] fn env_overlay_reads_the_process_environment() { // SAFETY: this is the only test that mutates these process-wide variables, // and no other test reads them concurrently. unsafe { env::set_var(OAuthProfile::ENV_SCOPE, "process.read"); } let result = OAuthProfile::from_env(); unsafe { env::remove_var(OAuthProfile::ENV_SCOPE); } let profile = result.expect("from_env reads only present variables"); assert_eq!(profile.scope, "process.read"); } #[test] fn non_boolean_tls_value_is_a_typed_error() { let env = HashMap::from([(OAuthProfile::ENV_DEV_INSECURE_TLS, "yes")]); let mut profile = OAuthProfile::default(); let result = profile.apply_env_overrides_from(|key| Ok(env.get(key).map(|value| (*value).to_owned()))); assert!(matches!( result, Err(OAuthError::Config(ConfigError::Invalid { key, .. })) if key == OAuthProfile::ENV_DEV_INSECURE_TLS )); assert!(!profile.dev.insecure_tls); } }