config.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. //! Runtime configuration for an OAuth client profile.
  2. //!
  3. //! [`OAuthProfile`] is the single source of truth for *where* to authenticate
  4. //! (`issuer`), *how* the callback returns (`redirect_uri`), *what* is requested
  5. //! (`scope`, `client`), *which optional features* are available
  6. //! (`capabilities`), and *how permissive* the developer environment is
  7. //! (`dev`).
  8. //!
  9. //! Values may be supplied programmatically, loaded from a file, or overlaid
  10. //! from environment variables via [`OAuthProfile::from_env`] and
  11. //! [`OAuthProfile::apply_env_overrides`]. Secret material (the client secret)
  12. //! is held in a [`SecretString`] and is never emitted by [`fmt::Debug`].
  13. use std::{env, fmt};
  14. use secrecy::{ExposeSecret, SecretString};
  15. use serde::{Deserialize, Serialize};
  16. use crate::{
  17. error::{ConfigError, Result},
  18. redact::redact_secret,
  19. };
  20. /// The complete client configuration for one authorization server.
  21. #[derive(Clone, Default)]
  22. pub struct OAuthProfile {
  23. /// The authorization server issuer identifier.
  24. pub issuer: String,
  25. /// The registered redirect URI the authorization server calls back.
  26. pub redirect_uri: String,
  27. /// The space-delimited scope string to request.
  28. pub scope: String,
  29. /// The confidential client's credentials.
  30. pub client: ClientCredentials,
  31. /// Optional server features discovered or configured for this issuer.
  32. pub capabilities: Capabilities,
  33. /// Development-only settings.
  34. pub dev: DevSettings,
  35. }
  36. /// The confidential client's credentials.
  37. ///
  38. /// `client_secret` is wrapped in a [`SecretString`] so it can only be read
  39. /// through a deliberate [`ExposeSecret`] call, and this type's [`fmt::Debug`]
  40. /// renders the secret as a redaction marker.
  41. #[derive(Clone)]
  42. pub struct ClientCredentials {
  43. /// The registered public client identifier.
  44. pub client_id: String,
  45. /// The registered client secret.
  46. pub client_secret: SecretString,
  47. }
  48. /// Boolean capability flags for a target authorization server.
  49. ///
  50. /// These are derived from discovery metadata (or overridden in configuration)
  51. /// and let the flow degrade explicitly instead of silently faking success.
  52. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
  53. #[serde(default)]
  54. pub struct Capabilities {
  55. /// Pushed authorization requests (RFC 9126) are available.
  56. pub par: bool,
  57. /// DPoP sender-constrained tokens (RFC 9449) are available.
  58. pub dpop: bool,
  59. /// Token introspection (RFC 7662) is available.
  60. pub introspection: bool,
  61. /// An OIDC `userinfo_endpoint` is available.
  62. pub userinfo: bool,
  63. /// Dynamic client registration (RFC 7591) is available.
  64. pub dcr: bool,
  65. /// Refresh tokens are supported by the token endpoint.
  66. pub refresh: bool,
  67. }
  68. /// Development-only settings. All defaults are fail-closed.
  69. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
  70. pub struct DevSettings {
  71. /// Trust the authorization server's certificate even if it is self-signed
  72. /// or otherwise unverifiable. Defaults to `false`; only enable for a local
  73. /// development server.
  74. pub insecure_tls: bool,
  75. }
  76. impl OAuthProfile {
  77. /// Environment variable for the issuer.
  78. pub const ENV_ISSUER: &'static str = "OAUTH_ISSUER";
  79. /// Environment variable for the redirect URI.
  80. pub const ENV_REDIRECT_URI: &'static str = "OAUTH_REDIRECT_URI";
  81. /// Environment variable for the requested scope.
  82. pub const ENV_SCOPE: &'static str = "OAUTH_SCOPE";
  83. /// Environment variable for the client id.
  84. pub const ENV_CLIENT_ID: &'static str = "OAUTH_CLIENT_ID";
  85. /// Environment variable for the client secret.
  86. pub const ENV_CLIENT_SECRET: &'static str = "OAUTH_CLIENT_SECRET";
  87. /// Environment variable to opt into insecure TLS (dev only).
  88. pub const ENV_DEV_INSECURE_TLS: &'static str = "OAUTH_DEV_INSECURE_TLS";
  89. /// Build a profile from the process environment, starting from
  90. /// [`OAuthProfile::default`].
  91. ///
  92. /// Missing variables leave the corresponding default in place; this does not
  93. /// validate that required values are present (callers that need strict
  94. /// validation should use [`ConfigError::Missing`]).
  95. pub fn from_env() -> Result<Self> {
  96. let mut profile = Self::default();
  97. profile.apply_env_overrides()?;
  98. Ok(profile)
  99. }
  100. /// Overlay the process environment onto this profile.
  101. ///
  102. /// Reads [`OAuthProfile::ENV_ISSUER`], [`OAuthProfile::ENV_REDIRECT_URI`],
  103. /// [`OAuthProfile::ENV_SCOPE`], [`OAuthProfile::ENV_CLIENT_ID`],
  104. /// [`OAuthProfile::ENV_CLIENT_SECRET`], and
  105. /// [`OAuthProfile::ENV_DEV_INSECURE_TLS`]. Absent variables are ignored.
  106. ///
  107. /// # Errors
  108. ///
  109. /// Returns [`ConfigError::Invalid`] if a present value is not valid UTF-8 or
  110. /// the TLS flag is not a boolean.
  111. pub fn apply_env_overrides(&mut self) -> Result<()> {
  112. self.apply_env_overrides_from(|key| match env::var(key) {
  113. Ok(value) => Ok(Some(value)),
  114. Err(env::VarError::NotPresent) => Ok(None),
  115. Err(env::VarError::NotUnicode(_)) => Err(
  116. ConfigError::Invalid {
  117. key: key.to_owned(),
  118. reason: "environment value is not valid UTF-8".to_owned(),
  119. }
  120. .into(),
  121. ),
  122. })
  123. }
  124. /// Overlay values from an arbitrary lookup, keyed by the same environment
  125. /// variable names [`OAuthProfile::apply_env_overrides`] uses.
  126. ///
  127. /// This is the testable, side-effect-free form of the env overlay; the real
  128. /// loader passes a closure over [`env::var`].
  129. ///
  130. /// # Errors
  131. ///
  132. /// Propagates lookup errors and rejects non-boolean TLS values.
  133. pub fn apply_env_overrides_from<F>(&mut self, lookup: F) -> Result<()>
  134. where
  135. F: Fn(&str) -> Result<Option<String>>,
  136. {
  137. if let Some(value) = lookup(Self::ENV_ISSUER)? {
  138. self.issuer = value;
  139. }
  140. if let Some(value) = lookup(Self::ENV_REDIRECT_URI)? {
  141. self.redirect_uri = value;
  142. }
  143. if let Some(value) = lookup(Self::ENV_SCOPE)? {
  144. self.scope = value;
  145. }
  146. if let Some(value) = lookup(Self::ENV_CLIENT_ID)? {
  147. self.client.client_id = value;
  148. }
  149. if let Some(value) = lookup(Self::ENV_CLIENT_SECRET)? {
  150. self.client.client_secret = SecretString::from(value);
  151. }
  152. if let Some(value) = lookup(Self::ENV_DEV_INSECURE_TLS)? {
  153. self.dev.insecure_tls = parse_bool(Self::ENV_DEV_INSECURE_TLS, &value)?;
  154. }
  155. Ok(())
  156. }
  157. }
  158. impl Default for ClientCredentials {
  159. fn default() -> Self {
  160. Self {
  161. client_id: String::new(),
  162. client_secret: SecretString::from(String::new()),
  163. }
  164. }
  165. }
  166. impl fmt::Debug for OAuthProfile {
  167. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  168. f.debug_struct("OAuthProfile")
  169. .field("issuer", &self.issuer)
  170. .field("redirect_uri", &self.redirect_uri)
  171. .field("scope", &self.scope)
  172. .field("client", &self.client)
  173. .field("capabilities", &self.capabilities)
  174. .field("dev", &self.dev)
  175. .finish()
  176. }
  177. }
  178. impl fmt::Debug for ClientCredentials {
  179. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  180. f.debug_struct("ClientCredentials")
  181. .field("client_id", &self.client_id)
  182. .field(
  183. "client_secret",
  184. &redact_secret(self.client_secret.expose_secret()),
  185. )
  186. .finish()
  187. }
  188. }
  189. /// Parse a permissive boolean (`true`/`false`/`1`/`0`, case-insensitive).
  190. fn parse_bool(key: &str, value: &str) -> Result<bool> {
  191. match value.trim().to_ascii_lowercase().as_str() {
  192. "true" | "1" => Ok(true),
  193. "false" | "0" => Ok(false),
  194. _ => Err(
  195. ConfigError::Invalid {
  196. key: key.to_owned(),
  197. reason: format!("expected a boolean, got `{value}`"),
  198. }
  199. .into(),
  200. ),
  201. }
  202. }
  203. #[cfg(test)]
  204. mod tests {
  205. use std::collections::HashMap;
  206. use super::*;
  207. use crate::error::OAuthError;
  208. fn profile_with_secret(secret: &str) -> OAuthProfile {
  209. OAuthProfile {
  210. issuer: "https://as.example".to_owned(),
  211. redirect_uri: "http://127.0.0.1:3000/callback".to_owned(),
  212. scope: "demo.read".to_owned(),
  213. client: ClientCredentials {
  214. client_id: "demo-client".to_owned(),
  215. client_secret: SecretString::from(secret.to_owned()),
  216. },
  217. capabilities: Capabilities::default(),
  218. dev: DevSettings::default(),
  219. }
  220. }
  221. #[test]
  222. fn debug_omits_client_secret() {
  223. let profile = profile_with_secret("S3CRET");
  224. let rendered = format!("{profile:?}");
  225. // Assert absence of the secret; the marker must take its place.
  226. assert!(
  227. !rendered.contains("S3CRET"),
  228. "client secret leaked into Debug output: {rendered}"
  229. );
  230. assert!(rendered.contains("[REDACTED]"));
  231. assert!(rendered.contains("demo-client"));
  232. assert!(rendered.contains("https://as.example"));
  233. }
  234. #[test]
  235. fn debug_omits_client_secret_when_nested_directly() {
  236. let credentials = ClientCredentials {
  237. client_id: "demo-client".to_owned(),
  238. client_secret: SecretString::from("S3CRET".to_owned()),
  239. };
  240. let rendered = format!("{credentials:?}");
  241. assert!(!rendered.contains("S3CRET"));
  242. assert!(rendered.contains("[REDACTED]"));
  243. }
  244. #[test]
  245. fn insecure_tls_defaults_to_false() {
  246. assert!(!DevSettings::default().insecure_tls);
  247. assert!(!OAuthProfile::default().dev.insecure_tls);
  248. }
  249. #[test]
  250. fn env_overlay_from_lookup_updates_fields() {
  251. let env = HashMap::from([
  252. (OAuthProfile::ENV_ISSUER, "https://env.example"),
  253. (
  254. OAuthProfile::ENV_REDIRECT_URI,
  255. "http://127.0.0.1:4280/callback",
  256. ),
  257. (OAuthProfile::ENV_SCOPE, "env.read"),
  258. (OAuthProfile::ENV_CLIENT_ID, "env-client"),
  259. (OAuthProfile::ENV_CLIENT_SECRET, "ENVS3CRET"),
  260. (OAuthProfile::ENV_DEV_INSECURE_TLS, "true"),
  261. ]);
  262. let mut profile = OAuthProfile::default();
  263. profile
  264. .apply_env_overrides_from(|key| Ok(env.get(key).map(|value| (*value).to_owned())))
  265. .expect("lookup cannot fail");
  266. assert_eq!(profile.issuer, "https://env.example");
  267. assert_eq!(profile.redirect_uri, "http://127.0.0.1:4280/callback");
  268. assert_eq!(profile.scope, "env.read");
  269. assert_eq!(profile.client.client_id, "env-client");
  270. assert_eq!(profile.client.client_secret.expose_secret(), "ENVS3CRET");
  271. assert!(profile.dev.insecure_tls);
  272. assert!(!format!("{profile:?}").contains("ENVS3CRET"));
  273. }
  274. #[test]
  275. fn env_overlay_reads_the_process_environment() {
  276. // SAFETY: this is the only test that mutates these process-wide variables,
  277. // and no other test reads them concurrently.
  278. unsafe {
  279. env::set_var(OAuthProfile::ENV_SCOPE, "process.read");
  280. }
  281. let result = OAuthProfile::from_env();
  282. unsafe {
  283. env::remove_var(OAuthProfile::ENV_SCOPE);
  284. }
  285. let profile = result.expect("from_env reads only present variables");
  286. assert_eq!(profile.scope, "process.read");
  287. }
  288. #[test]
  289. fn non_boolean_tls_value_is_a_typed_error() {
  290. let env = HashMap::from([(OAuthProfile::ENV_DEV_INSECURE_TLS, "yes")]);
  291. let mut profile = OAuthProfile::default();
  292. let result =
  293. profile.apply_env_overrides_from(|key| Ok(env.get(key).map(|value| (*value).to_owned())));
  294. assert!(matches!(
  295. result,
  296. Err(OAuthError::Config(ConfigError::Invalid { key, .. }))
  297. if key == OAuthProfile::ENV_DEV_INSECURE_TLS
  298. ));
  299. assert!(!profile.dev.insecure_tls);
  300. }
  301. }