config.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. //! Validated server configuration and optional scope registry.
  2. //!
  3. //! Loads env + optional YAML via the `config` crate: issuer, bind/health
  4. //! addresses, TLS and CA paths, Postgres/Redis connection blocks, failure
  5. //! policy, versioned key ids, TTLs, Argon2 params, cookie/CORS knobs,
  6. //! rate-limit knobs, and the optional declarative `scope_registry`.
  7. //!
  8. //! Validation is fail-fast: an insecure issuer, a missing token pepper, a
  9. //! Postgres URL without `sslmode=verify-full`, a Redis deployment without its
  10. //! no-eviction/AOF durability contract, or an invalid timeout ladder is
  11. //! rejected at load time ([`ConfigError`]). The [`Config`] `Debug` impl never
  12. //! prints secret values.
  13. //!
  14. //! The `scope_registry` is an **optional declarative input** (default empty,
  15. //! never required non-empty) applied only by `config sync`. The runtime source
  16. //! of truth for `scopes_supported` remains the enabled `scopes` table.
  17. use std::{
  18. fmt,
  19. net::SocketAddr,
  20. path::{Path, PathBuf},
  21. };
  22. use secrecy::SecretString;
  23. use thiserror::Error;
  24. use crate::keys::KeyRing;
  25. mod build;
  26. pub mod db;
  27. pub mod policy;
  28. mod raw;
  29. pub mod redis;
  30. pub mod scope;
  31. #[cfg(test)]
  32. mod tests;
  33. pub mod types;
  34. use raw::RawConfig;
  35. pub use db::DbConfig;
  36. pub use policy::{FailMode, FailurePolicy, RetryableClass};
  37. pub use redis::{DurabilityMode, RedisConfig, RedisTopology};
  38. pub use scope::{ClientClass, GrantKind, ScopeDefinition, TokenExchangeRule};
  39. pub use types::{Argon2Params, DcrRedirectPolicy, Environment, RateLimitConfig, TtlConfig};
  40. /// Environment-variable prefix for layered settings (`D_OAUTH_*`).
  41. pub const ENV_PREFIX: &str = "D_OAUTH";
  42. /// Environment variable naming an optional YAML/TOML config file.
  43. pub const ENV_CONFIG_FILE: &str = "D_OAUTH_CONFIG";
  44. /// TLS material paths (public listener) and the client-CA trust bundle.
  45. #[derive(Debug, Clone, PartialEq, Eq)]
  46. pub struct TlsConfig {
  47. /// PEM certificate chain path.
  48. pub cert_path: PathBuf,
  49. /// PEM private-key path.
  50. pub key_path: PathBuf,
  51. /// Optional client-CA bundle used to verify mTLS peer chains.
  52. pub client_ca_path: Option<PathBuf>,
  53. }
  54. /// Fully validated runtime configuration.
  55. pub struct Config {
  56. /// Public issuer URL (absolute `https`, no trailing slash).
  57. pub issuer: String,
  58. /// Public TLS listener address (default `0.0.0.0:8080`).
  59. pub bind_addr: SocketAddr,
  60. /// Plaintext pod-IP health listener address (default `0.0.0.0:8081`).
  61. pub health_addr: SocketAddr,
  62. /// Runtime environment.
  63. pub environment: Environment,
  64. /// TLS material and client-CA bundle.
  65. pub tls: TlsConfig,
  66. /// Postgres connection and pool settings.
  67. pub database: DbConfig,
  68. /// Redis topology and durability settings.
  69. pub redis: RedisConfig,
  70. /// Versioned token-pepper and TOTP-encryption keys.
  71. pub keys: KeyRing,
  72. /// Write PII plaintext alongside ciphertext during the rotation window
  73. /// (default false: encrypt-only).
  74. pub pii_dual_write: bool,
  75. /// Per-endpoint failure policy.
  76. pub failure_policy: FailurePolicy,
  77. /// Token/request lifetimes.
  78. pub ttl: TtlConfig,
  79. /// Argon2id parameters.
  80. pub argon2: Argon2Params,
  81. /// Rate-limit knobs.
  82. pub rate_limit: RateLimitConfig,
  83. /// Cookie domain (default: host-only).
  84. pub cookie_domain: Option<String>,
  85. /// Whether cookies carry the `Secure` attribute (default true).
  86. pub cookie_secure: bool,
  87. /// Whether unauthenticated dynamic registration is allowed (default false).
  88. pub allow_open_registration: bool,
  89. /// DCR redirect-URI policy (default [`DcrRedirectPolicy::Catalog`]).
  90. pub dcr_redirect_policy: DcrRedirectPolicy,
  91. /// Explicit CORS origin allowlist (default empty; no wildcard).
  92. pub cors_allowed_origins: Vec<String>,
  93. /// Optional declarative scope registry (default empty).
  94. pub scope_registry: Vec<ScopeDefinition>,
  95. /// Whether the token-exchange grant is enabled (default false).
  96. pub allow_token_exchange: bool,
  97. /// Explicit `(caller, subject, scopes)` allowlist for token exchange.
  98. pub token_exchange_allowlist: Vec<TokenExchangeRule>,
  99. }
  100. impl fmt::Debug for Config {
  101. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  102. f.debug_struct("Config")
  103. .field("issuer", &self.issuer)
  104. .field("bind_addr", &self.bind_addr)
  105. .field("health_addr", &self.health_addr)
  106. .field("environment", &self.environment)
  107. .field("tls", &self.tls)
  108. .field("database", &self.database)
  109. .field("redis", &self.redis)
  110. .field("keys", &self.keys)
  111. .field("pii_dual_write", &self.pii_dual_write)
  112. .field("failure_policy", &self.failure_policy)
  113. .field("ttl", &self.ttl)
  114. .field("argon2", &self.argon2)
  115. .field("rate_limit", &self.rate_limit)
  116. .field("cookie_domain", &self.cookie_domain)
  117. .field("cookie_secure", &self.cookie_secure)
  118. .field("allow_open_registration", &self.allow_open_registration)
  119. .field("dcr_redirect_policy", &self.dcr_redirect_policy)
  120. .field("cors_allowed_origins", &self.cors_allowed_origins)
  121. .field("scope_registry_len", &self.scope_registry.len())
  122. .field("allow_token_exchange", &self.allow_token_exchange)
  123. .field(
  124. "token_exchange_allowlist_len",
  125. &self.token_exchange_allowlist.len(),
  126. )
  127. .finish_non_exhaustive()
  128. }
  129. }
  130. impl Config {
  131. /// Load configuration from `D_OAUTH_*` env vars plus an optional
  132. /// `D_OAUTH_CONFIG` file, then validate it.
  133. pub fn load() -> Result<Self, ConfigError> {
  134. let mut builder = ::config::Config::builder().add_source(env_source());
  135. if let Some(path) = std::env::var(ENV_CONFIG_FILE)
  136. .ok()
  137. .filter(|p| !p.is_empty())
  138. {
  139. builder = builder.add_source(::config::File::with_name(&path).required(false));
  140. }
  141. // Conventional mounted-secret env vars override file paths when present.
  142. for (env_var, key) in [
  143. ("DATABASE_PASSWORD_FILE", "database.password_file"),
  144. ("REDIS_PASSWORD_FILE", "redis.password_file"),
  145. ] {
  146. if let Some(path) = std::env::var(env_var).ok().filter(|p| !p.is_empty()) {
  147. builder = builder.set_override(key, path)?;
  148. }
  149. }
  150. Self::from_source(builder.build()?)
  151. }
  152. /// Alias for [`Config::load`].
  153. pub fn from_env() -> Result<Self, ConfigError> {
  154. Self::load()
  155. }
  156. /// Validate an already-assembled `config::Config` source.
  157. pub fn from_source(source: ::config::Config) -> Result<Self, ConfigError> {
  158. let raw: RawConfig = source.try_deserialize()?;
  159. Self::build(raw)
  160. }
  161. }
  162. /// Layered `D_OAUTH_*` environment source: `__` nests, single `_` stays
  163. /// inside a snake_case key.
  164. pub(crate) fn env_source() -> ::config::Environment {
  165. ::config::Environment::default()
  166. .prefix(ENV_PREFIX)
  167. .prefix_separator("_")
  168. .separator("__")
  169. .convert_case(::config::Case::Snake)
  170. .try_parsing(true)
  171. }
  172. /// Configuration loading/validation failures.
  173. #[derive(Debug, Error)]
  174. pub enum ConfigError {
  175. /// The layered configuration source could not be read or deserialized.
  176. #[error("configuration source error: {0}")]
  177. Source(#[from] ::config::ConfigError),
  178. /// `issuer` was absent.
  179. #[error("issuer is required")]
  180. MissingIssuer,
  181. /// `issuer` was not an absolute `https` URL without a trailing slash.
  182. #[error("issuer must be absolute https without a trailing slash: {issuer}")]
  183. InsecureIssuer {
  184. /// The rejected value.
  185. issuer: String,
  186. },
  187. /// `bind_addr` was not a valid socket address.
  188. #[error("invalid bind address: {value}")]
  189. InvalidBindAddr {
  190. /// The rejected value.
  191. value: String,
  192. },
  193. /// `health_addr` was not a valid socket address.
  194. #[error("invalid health address: {value}")]
  195. InvalidHealthAddr {
  196. /// The rejected value.
  197. value: String,
  198. },
  199. /// The TLS certificate path was absent.
  200. #[error("tls cert_path is required")]
  201. MissingTlsCert,
  202. /// The TLS private-key path was absent.
  203. #[error("tls key_path is required")]
  204. MissingTlsKey,
  205. /// `database.url` was absent.
  206. #[error("database url is required")]
  207. MissingDatabaseUrl,
  208. /// A production Postgres URL lacked `sslmode=verify-full`.
  209. #[error("production database url must include sslmode=verify-full")]
  210. InsecureDatabaseTls,
  211. /// The pool min/max bounds were inconsistent.
  212. #[error("database pool min ({min}) must not exceed max ({max}) and max must be > 0")]
  213. InvalidPoolBounds {
  214. /// Configured minimum.
  215. min: u32,
  216. /// Configured maximum.
  217. max: u32,
  218. },
  219. /// The Redis no-eviction/AOF durability contract was absent or inconsistent.
  220. #[error("redis durability contract (noeviction + AOF) is required and must match the topology")]
  221. RedisDurabilityContract,
  222. /// The token and rate-limit key prefixes were identical.
  223. #[error("redis token and rate-limit key prefixes must differ")]
  224. RedisKeyPrefixConflict,
  225. /// The token key prefix lacked the `{state}` hash tag.
  226. #[error("redis token key prefix must contain the {{state}} hash tag")]
  227. RedisTokenPrefixHashTag,
  228. /// The rate-limit key prefix lacked the `{rl}` hash tag.
  229. #[error("redis rate-limit key prefix must contain the {{rl}} hash tag")]
  230. RedisRateLimitPrefixHashTag,
  231. /// The Redis topology could not be determined unambiguously.
  232. #[error("redis topology is ambiguous or missing (set exactly one of url/nodes/seeds)")]
  233. RedisTopologyAmbiguous,
  234. /// The Sentinel topology lacked a `master_name`.
  235. #[error("redis sentinel topology requires master_name")]
  236. RedisSentinelMissingMaster,
  237. /// The observed eviction policy violated the contract.
  238. #[error("redis maxmemory-policy must be noeviction (observed: {observed})")]
  239. RedisEvictionPolicy {
  240. /// The observed policy.
  241. observed: String,
  242. },
  243. /// AOF persistence was disabled while required.
  244. #[error("redis AOF persistence must be enabled")]
  245. RedisAofDisabled,
  246. /// The Redis topology was unreachable.
  247. #[error("redis topology is unreachable")]
  248. RedisUnreachable,
  249. /// The timeout ladder was invalid.
  250. #[error("invalid timeout ladder (require lock < statement <= acquire < request)")]
  251. InvalidTimeoutLadder,
  252. /// A scope name violated the scope-token grammar.
  253. #[error("invalid scope name: {name}")]
  254. InvalidScopeName {
  255. /// The rejected name.
  256. name: String,
  257. },
  258. /// The reserved `openid` scope was declared.
  259. #[error("reserved scope is not allowed: {name}")]
  260. ReservedScope {
  261. /// The reserved name.
  262. name: String,
  263. },
  264. /// A scope-registry or exchange rule was malformed.
  265. #[error("invalid scope-registry entry: {reason}")]
  266. InvalidScopeRegistry {
  267. /// Why the entry was rejected.
  268. reason: String,
  269. },
  270. /// A security endpoint was configured to fail open.
  271. #[error("fail-open is not permitted on security endpoint: {endpoint}")]
  272. FailOpenOnSecurityPath {
  273. /// The offending endpoint.
  274. endpoint: String,
  275. },
  276. /// A resolved master key was shorter than the required 32 bytes.
  277. #[error("key material for {key} must be at least 32 bytes")]
  278. WeakKeyMaterial {
  279. /// The configuration key that carried the weak material.
  280. key: &'static str,
  281. },
  282. /// Argon2 parameters were outside the safe range.
  283. #[error("invalid argon2 parameters")]
  284. InvalidArgon2Params,
  285. /// The rate-limit window was zero.
  286. #[error("invalid rate-limit window")]
  287. InvalidRateLimit,
  288. /// A TTL was zero.
  289. #[error("invalid token TTL: every lifetime must be non-zero")]
  290. InvalidTtl,
  291. /// The access-token lifetime exceeded the refresh-token lifetime.
  292. #[error("invalid token TTL: access lifetime must not exceed the refresh lifetime")]
  293. InvalidTtlLadder,
  294. /// A mounted secret file could not be read.
  295. #[error("failed to read secret file {path}: {source}")]
  296. SecretFile {
  297. /// The file path.
  298. path: PathBuf,
  299. /// The underlying I/O error.
  300. #[source]
  301. source: std::io::Error,
  302. },
  303. /// The key ring could not be constructed.
  304. #[error("key ring error: {0}")]
  305. KeyRing(#[from] crate::keys::KeyRingError),
  306. /// A retained (`*_previous_key`) key was provided without an explicit
  307. /// distinct previous key id.
  308. #[error("previous key requires an explicit distinct previous key id")]
  309. MissingPreviousKeyId,
  310. /// A key id contained characters outside `[A-Za-z0-9_-]`.
  311. #[error("invalid key id: {id}")]
  312. InvalidKeyId {
  313. /// The rejected value.
  314. id: String,
  315. },
  316. }
  317. /// Read an optional secret from a mounted file, trimming trailing newlines.
  318. pub(crate) fn read_secret_file(path: Option<&Path>) -> Result<Option<SecretString>, ConfigError> {
  319. match path {
  320. None => Ok(None),
  321. Some(path) => {
  322. let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::SecretFile {
  323. path: path.to_path_buf(),
  324. source,
  325. })?;
  326. Ok(Some(SecretString::from(
  327. contents.trim_end_matches(['\r', '\n']).to_owned(),
  328. )))
  329. }
  330. }
  331. }