| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- //! 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<Self> {
- 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<F>(&mut self, lookup: F) -> Result<()>
- where
- F: Fn(&str) -> Result<Option<String>>,
- {
- 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<bool> {
- 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);
- }
- }
|