tests.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. //! Acceptance tests for configuration loading and validation.
  2. //!
  3. //! Named so `cargo nextest run -E 'test(config)'` selects them. No test reads
  4. //! the real process environment or a live database/Redis.
  5. use std::{error::Error, time::Duration};
  6. use serde_json::{Map, Value, json};
  7. use super::{Config, ConfigError, DcrRedirectPolicy, RedisTopology};
  8. use crate::keys::KeyRingError;
  9. fn base() -> Map<String, Value> {
  10. match json!({
  11. "issuer": "https://auth.example.com",
  12. "environment": "production",
  13. "tls": {
  14. "cert_path": "/etc/d-oauth/tls.crt",
  15. "key_path": "/etc/d-oauth/tls.key",
  16. "client_ca_path": "/etc/d-oauth/client-ca.pem"
  17. },
  18. "database": {
  19. "url": "postgres://oauth:DB_PASSWORD_XYZ@db:5432/oauth?sslmode=verify-full",
  20. "root_cert_path": "/etc/d-oauth/pg-ca.pem"
  21. },
  22. "redis": {
  23. "url": "rediss://redis:6379",
  24. "durability": "waitaof",
  25. "root_cert_path": "/etc/d-oauth/redis-ca.pem"
  26. },
  27. "token_pepper": "SUPER_SECRET_PEPPER_VALUE-0123456789abcdef",
  28. "token_pepper_id": "v1",
  29. "totp_encryption_key": "SUPER_SECRET_TOTP_KEY-0123456789abcdef",
  30. "totp_key_id": "v1",
  31. "pii_encryption_key": "SUPER_SECRET_PII_KEY-0123456789abcdef",
  32. "pii_key_id": "v1",
  33. "cookie_secure": true,
  34. "allow_open_registration": false,
  35. "dcr_redirect_policy": "catalog",
  36. "cors_allowed_origins": [],
  37. "scope_registry": [],
  38. "allow_token_exchange": false
  39. }) {
  40. Value::Object(map) => map,
  41. _ => Map::new(),
  42. }
  43. }
  44. fn try_load(map: &Map<String, Value>) -> Result<Config, ConfigError> {
  45. let source = ::config::Config::try_from(map).map_err(ConfigError::from)?;
  46. Config::from_source(source)
  47. }
  48. fn without(key: &str) -> Map<String, Value> {
  49. let mut map = base();
  50. map.remove(key);
  51. map
  52. }
  53. fn with_database_field(key: &str, value: Value) -> Map<String, Value> {
  54. let mut map = base();
  55. if let Some(database) = map.get_mut("database").and_then(Value::as_object_mut) {
  56. database.insert(key.to_owned(), value);
  57. }
  58. map
  59. }
  60. fn without_redis_durability() -> Map<String, Value> {
  61. let mut map = base();
  62. if let Some(redis) = map.get_mut("redis").and_then(Value::as_object_mut) {
  63. redis.remove("durability");
  64. }
  65. map
  66. }
  67. fn with_ttl(access_secs: u64, refresh_secs: u64) -> Map<String, Value> {
  68. let mut map = base();
  69. map.insert(
  70. "ttl".to_owned(),
  71. json!({ "access_secs": access_secs, "refresh_secs": refresh_secs }),
  72. );
  73. map
  74. }
  75. #[test]
  76. fn config_loads_with_empty_scope_registry() -> Result<(), Box<dyn Error>> {
  77. let cfg = try_load(&base())?;
  78. assert!(cfg.scope_registry.is_empty());
  79. assert_eq!(cfg.ttl.access, Duration::from_secs(300));
  80. assert_eq!(cfg.ttl.refresh, Duration::from_secs(2_592_000));
  81. assert_eq!(cfg.ttl.code, Duration::from_secs(60));
  82. assert_eq!(cfg.ttl.par, Duration::from_secs(90));
  83. assert_eq!(cfg.ttl.device, Duration::from_secs(600));
  84. assert_eq!(cfg.ttl.dcr_initial_access, Duration::from_secs(86_400));
  85. assert_eq!(cfg.ttl.session, Duration::from_secs(43_200));
  86. assert!(cfg.cookie_secure);
  87. assert_eq!(cfg.dcr_redirect_policy, DcrRedirectPolicy::Catalog);
  88. assert!(!cfg.allow_open_registration);
  89. assert!(cfg.cors_allowed_origins.is_empty());
  90. Ok(())
  91. }
  92. #[test]
  93. fn config_defaults_bind_and_health_addresses() -> Result<(), Box<dyn Error>> {
  94. let cfg = try_load(&base())?;
  95. assert_eq!(cfg.bind_addr.to_string(), "0.0.0.0:8080");
  96. assert_eq!(cfg.health_addr.to_string(), "0.0.0.0:8081");
  97. Ok(())
  98. }
  99. #[test]
  100. fn config_rejects_missing_issuer() {
  101. let result = try_load(&without("issuer"));
  102. assert!(matches!(result, Err(ConfigError::MissingIssuer)));
  103. }
  104. #[test]
  105. fn config_rejects_insecure_http_issuer() {
  106. let mut map = base();
  107. map.insert("issuer".to_owned(), Value::String("http://x".to_owned()));
  108. let result = try_load(&map);
  109. assert!(matches!(result, Err(ConfigError::InsecureIssuer { .. })));
  110. }
  111. #[test]
  112. fn config_rejects_issuer_with_trailing_slash() {
  113. let mut map = base();
  114. map.insert(
  115. "issuer".to_owned(),
  116. Value::String("https://auth.example.com/".to_owned()),
  117. );
  118. let result = try_load(&map);
  119. assert!(matches!(result, Err(ConfigError::InsecureIssuer { .. })));
  120. }
  121. #[test]
  122. fn config_rejects_missing_token_pepper() {
  123. let result = try_load(&without("token_pepper"));
  124. assert!(matches!(
  125. result,
  126. Err(ConfigError::KeyRing(KeyRingError::MissingTokenPepper))
  127. ));
  128. }
  129. #[test]
  130. fn config_rejects_redis_without_durability_contract() {
  131. let result = try_load(&without_redis_durability());
  132. assert!(matches!(result, Err(ConfigError::RedisDurabilityContract)));
  133. }
  134. #[test]
  135. fn config_rejects_single_node_with_replicated_durability() {
  136. let mut map = base();
  137. if let Some(redis) = map.get_mut("redis").and_then(Value::as_object_mut) {
  138. redis.insert("durability".to_owned(), Value::String("wait".to_owned()));
  139. }
  140. let result = try_load(&map);
  141. assert!(matches!(result, Err(ConfigError::RedisDurabilityContract)));
  142. }
  143. #[test]
  144. fn config_accepts_cluster_with_wait_durability() -> Result<(), Box<dyn Error>> {
  145. let mut map = base();
  146. if let Some(redis) = map.get_mut("redis").and_then(Value::as_object_mut) {
  147. redis.remove("url");
  148. redis.insert("seeds".to_owned(), json!(["redis-0:6379", "redis-1:6379"]));
  149. redis.insert("tls".to_owned(), Value::Bool(true));
  150. redis.insert("durability".to_owned(), Value::String("wait".to_owned()));
  151. }
  152. let cfg = try_load(&map)?;
  153. assert!(matches!(cfg.redis.topology, RedisTopology::Cluster { .. }));
  154. Ok(())
  155. }
  156. #[test]
  157. fn config_rejects_production_postgres_without_verify_full() {
  158. let map = with_database_field(
  159. "url",
  160. Value::String("postgres://oauth:pw@db:5432/oauth".to_owned()),
  161. );
  162. let result = try_load(&map);
  163. assert!(matches!(result, Err(ConfigError::InsecureDatabaseTls)));
  164. }
  165. #[test]
  166. fn config_allows_development_postgres_without_verify_full() -> Result<(), Box<dyn Error>> {
  167. let mut map = with_database_field(
  168. "url",
  169. Value::String("postgres://oauth:pw@127.0.0.1:5432/oauth".to_owned()),
  170. );
  171. map.insert(
  172. "environment".to_owned(),
  173. Value::String("development".to_owned()),
  174. );
  175. let cfg = try_load(&map)?;
  176. assert_eq!(cfg.environment, super::Environment::Development);
  177. Ok(())
  178. }
  179. #[test]
  180. fn config_rejects_invalid_timeout_ladder() {
  181. let map = with_database_field("lock_timeout_secs", json!(5));
  182. let result = try_load(&map);
  183. assert!(matches!(result, Err(ConfigError::InvalidTimeoutLadder)));
  184. }
  185. #[test]
  186. fn config_rejects_access_ttl_longer_than_refresh() {
  187. let map = with_ttl(2_592_001, 2_592_000);
  188. let result = try_load(&map);
  189. assert!(matches!(result, Err(ConfigError::InvalidTtlLadder)));
  190. }
  191. #[test]
  192. fn config_accepts_access_ttl_equal_to_refresh() -> Result<(), Box<dyn Error>> {
  193. let cfg = try_load(&with_ttl(3_600, 3_600))?;
  194. assert_eq!(cfg.ttl.access, Duration::from_secs(3_600));
  195. Ok(())
  196. }
  197. #[test]
  198. fn config_rejects_reserved_openid_scope() {
  199. let mut map = base();
  200. map.insert("scope_registry".to_owned(), json!([{ "name": "openid" }]));
  201. let result = try_load(&map);
  202. assert!(matches!(result, Err(ConfigError::ReservedScope { .. })));
  203. }
  204. #[test]
  205. fn config_accepts_namespaced_scope_registry() -> Result<(), Box<dyn Error>> {
  206. let mut map = base();
  207. map.insert(
  208. "scope_registry".to_owned(),
  209. json!([
  210. { "name": "api:read", "description": "read the API" },
  211. {
  212. "name": "api:write",
  213. "allowed_grants": ["authorization_code"],
  214. "allowed_client_classes": ["confidential"]
  215. }
  216. ]),
  217. );
  218. let cfg = try_load(&map)?;
  219. assert_eq!(cfg.scope_registry.len(), 2);
  220. assert_eq!(cfg.scope_registry[0].name, "api:read");
  221. Ok(())
  222. }
  223. #[test]
  224. fn config_redis_startup_assertions_are_callable() -> Result<(), Box<dyn Error>> {
  225. let cfg = try_load(&base())?;
  226. assert!(cfg.redis.assert_noeviction_policy("noeviction").is_ok());
  227. assert!(matches!(
  228. cfg.redis.assert_noeviction_policy("allkeys-lru"),
  229. Err(ConfigError::RedisEvictionPolicy { .. })
  230. ));
  231. assert!(cfg.redis.assert_aof_persistence(true).is_ok());
  232. assert!(matches!(
  233. cfg.redis.assert_aof_persistence(false),
  234. Err(ConfigError::RedisAofDisabled)
  235. ));
  236. assert!(cfg.redis.assert_topology_reachable(true).is_ok());
  237. assert!(matches!(
  238. cfg.redis.assert_topology_reachable(false),
  239. Err(ConfigError::RedisUnreachable)
  240. ));
  241. Ok(())
  242. }
  243. #[test]
  244. fn config_debug_redacts_secret_values() -> Result<(), Box<dyn Error>> {
  245. let cfg = try_load(&base())?;
  246. let rendered = format!("{cfg:?}");
  247. assert!(!rendered.contains("SUPER_SECRET_PEPPER_VALUE-0123456789abcdef"));
  248. assert!(!rendered.contains("SUPER_SECRET_TOTP_KEY-0123456789abcdef"));
  249. assert!(!rendered.contains("SUPER_SECRET_PII_KEY-0123456789abcdef"));
  250. assert!(!rendered.contains("DB_PASSWORD_XYZ"));
  251. assert!(rendered.contains("REDACTED"));
  252. Ok(())
  253. }
  254. #[test]
  255. fn config_env_double_underscore_maps_to_nested_snake_keys() -> Result<(), Box<dyn Error>> {
  256. let mut env = std::collections::HashMap::new();
  257. env.insert(
  258. "D_OAUTH_TLS__CERT_PATH".to_owned(),
  259. "/certs/tls.crt".to_owned(),
  260. );
  261. env.insert(
  262. "D_OAUTH_TLS__KEY_PATH".to_owned(),
  263. "/certs/tls.key".to_owned(),
  264. );
  265. env.insert(
  266. "D_OAUTH_TOKEN_PEPPER".to_owned(),
  267. "dev-only-pepper-ROTATE-IN-PROD-000000".to_owned(),
  268. );
  269. let built = ::config::Config::builder()
  270. .add_source(super::env_source().source(Some(env)))
  271. .build()?;
  272. let raw: super::raw::RawConfig = built.try_deserialize()?;
  273. assert_eq!(
  274. raw.tls.cert_path,
  275. Some(std::path::PathBuf::from("/certs/tls.crt"))
  276. );
  277. assert_eq!(
  278. raw.tls.key_path,
  279. Some(std::path::PathBuf::from("/certs/tls.key"))
  280. );
  281. assert_eq!(
  282. raw.token_pepper.as_deref(),
  283. Some("dev-only-pepper-ROTATE-IN-PROD-000000")
  284. );
  285. Ok(())
  286. }
  287. #[test]
  288. fn config_rejects_missing_pii_key() {
  289. let result = try_load(&without("pii_encryption_key"));
  290. assert!(matches!(
  291. result,
  292. Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
  293. ));
  294. }
  295. #[test]
  296. fn config_rejects_empty_active_pii_key() {
  297. let mut map = base();
  298. map.insert(
  299. "pii_encryption_key".to_owned(),
  300. Value::String(" ".to_owned()),
  301. );
  302. let result = try_load(&map);
  303. assert!(matches!(
  304. result,
  305. Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
  306. ));
  307. let path = std::env::temp_dir().join(format!("d-oauth-empty-pii-key-{}.txt", std::process::id()));
  308. if std::fs::write(&path, "").is_ok() {
  309. let mut file_map = base();
  310. file_map.remove("pii_encryption_key");
  311. file_map.insert(
  312. "pii_encryption_key_file".to_owned(),
  313. Value::String(path.to_string_lossy().into_owned()),
  314. );
  315. let result = try_load(&file_map);
  316. let _ = std::fs::remove_file(&path);
  317. assert!(matches!(
  318. result,
  319. Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
  320. ));
  321. }
  322. }
  323. /// A resolved pepper/TOTP/PII key shorter than 32 bytes is rejected with the
  324. /// offending key named; retained previous keys are checked too.
  325. #[test]
  326. fn config_rejects_weak_key_material() {
  327. for (key, value) in [
  328. ("token_pepper", "short-pepper-material"),
  329. ("totp_encryption_key", "short-totp-key-material"),
  330. ("pii_encryption_key", "short-pii-key-material"),
  331. ] {
  332. let mut map = base();
  333. map.insert(key.to_owned(), Value::String(value.to_owned()));
  334. assert!(
  335. matches!(
  336. try_load(&map),
  337. Err(ConfigError::WeakKeyMaterial { key: rejected }) if rejected == key
  338. ),
  339. "{key} shorter than 32 bytes must be rejected"
  340. );
  341. }
  342. for key in ["totp_previous_key", "pii_previous_key"] {
  343. let mut map = base();
  344. map.insert(
  345. key.to_owned(),
  346. Value::String("short-previous-key".to_owned()),
  347. );
  348. map.insert(format!("{key}_id"), Value::String("v0".to_owned()));
  349. assert!(
  350. matches!(
  351. try_load(&map),
  352. Err(ConfigError::WeakKeyMaterial { key: rejected }) if rejected == key
  353. ),
  354. "previous key {key} shorter than 32 bytes must be rejected"
  355. );
  356. }
  357. }
  358. /// The key-length boundary is exact: 31 bytes is rejected, 32 accepted.
  359. #[test]
  360. fn config_key_length_boundary_is_32_bytes() -> Result<(), Box<dyn Error>> {
  361. let mut exact = base();
  362. exact.insert("token_pepper".to_owned(), Value::String("p".repeat(32)));
  363. let cfg = try_load(&exact)?;
  364. assert!(cfg.keys.active_pepper().is_some());
  365. let mut short = base();
  366. short.insert("token_pepper".to_owned(), Value::String("p".repeat(31)));
  367. assert!(matches!(
  368. try_load(&short),
  369. Err(ConfigError::WeakKeyMaterial {
  370. key: "token_pepper"
  371. })
  372. ));
  373. Ok(())
  374. }
  375. #[test]
  376. fn config_builds_pii_chain() -> Result<(), Box<dyn Error>> {
  377. let mut map = base();
  378. map.insert(
  379. "pii_previous_key".to_owned(),
  380. Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
  381. );
  382. map.insert(
  383. "pii_previous_key_id".to_owned(),
  384. Value::String("v0".to_owned()),
  385. );
  386. map.insert("pii_dual_write".to_owned(), Value::Bool(true));
  387. let cfg = try_load(&map)?;
  388. let active = cfg.keys.active_pii_key().ok_or("missing active pii key")?;
  389. assert_eq!(active.0.as_str(), "v1");
  390. let retained = cfg
  391. .keys
  392. .pii_key_by_id("v0")
  393. .ok_or("missing retained pii key")?;
  394. assert!(!cfg.keys.pii_key_by_id("v9").is_some());
  395. let _ = retained;
  396. let ids: Vec<&str> = cfg
  397. .keys
  398. .pii_keys_newest_first()
  399. .map(|(id, _)| id.as_str())
  400. .collect();
  401. assert_eq!(ids, vec!["v1", "v0"]);
  402. assert!(cfg.pii_dual_write);
  403. let dual_default = try_load(&base())?;
  404. assert!(!dual_default.pii_dual_write);
  405. Ok(())
  406. }
  407. #[test]
  408. fn config_rejects_previous_key_without_id() {
  409. let mut map = base();
  410. map.insert(
  411. "pii_previous_key".to_owned(),
  412. Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
  413. );
  414. let result = try_load(&map);
  415. assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
  416. let mut same_id = base();
  417. same_id.insert(
  418. "pii_previous_key".to_owned(),
  419. Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
  420. );
  421. same_id.insert(
  422. "pii_previous_key_id".to_owned(),
  423. Value::String("v1".to_owned()),
  424. );
  425. let result = try_load(&same_id);
  426. assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
  427. let mut totp = base();
  428. totp.insert(
  429. "totp_previous_key".to_owned(),
  430. Value::String("SUPER_SECRET_TOTP_PREVIOUS-0123456789abcdef".to_owned()),
  431. );
  432. let result = try_load(&totp);
  433. assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
  434. }
  435. #[test]
  436. fn config_rejects_previous_key_without_active() {
  437. let mut pii = without("pii_encryption_key");
  438. pii.insert(
  439. "pii_previous_key".to_owned(),
  440. Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
  441. );
  442. pii.insert(
  443. "pii_previous_key_id".to_owned(),
  444. Value::String("v0".to_owned()),
  445. );
  446. let result = try_load(&pii);
  447. assert!(matches!(
  448. result,
  449. Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
  450. ));
  451. let mut totp = without("totp_encryption_key");
  452. totp.insert(
  453. "totp_previous_key".to_owned(),
  454. Value::String("SUPER_SECRET_TOTP_PREVIOUS-0123456789abcdef".to_owned()),
  455. );
  456. totp.insert(
  457. "totp_previous_key_id".to_owned(),
  458. Value::String("v0".to_owned()),
  459. );
  460. let result = try_load(&totp);
  461. assert!(matches!(
  462. result,
  463. Err(ConfigError::KeyRing(KeyRingError::MissingTotpKey))
  464. ));
  465. }
  466. #[test]
  467. fn config_treats_empty_previous_key_as_absent() -> Result<(), Box<dyn Error>> {
  468. let mut map = base();
  469. map.insert(
  470. "pii_previous_key".to_owned(),
  471. Value::String(" ".to_owned()),
  472. );
  473. map.insert(
  474. "pii_previous_key_id".to_owned(),
  475. Value::String("v0".to_owned()),
  476. );
  477. let cfg = try_load(&map)?;
  478. let ids: Vec<&str> = cfg
  479. .keys
  480. .pii_keys_newest_first()
  481. .map(|(id, _)| id.as_str())
  482. .collect();
  483. assert_eq!(ids, vec!["v1"]);
  484. let path = std::env::temp_dir().join(format!(
  485. "d-oauth-empty-pii-previous-{}.txt",
  486. std::process::id()
  487. ));
  488. if std::fs::write(&path, "").is_ok() {
  489. let mut file_map = base();
  490. file_map.insert(
  491. "pii_previous_key_file".to_owned(),
  492. Value::String(path.to_string_lossy().into_owned()),
  493. );
  494. file_map.insert(
  495. "pii_previous_key_id".to_owned(),
  496. Value::String("v0".to_owned()),
  497. );
  498. let cfg = try_load(&file_map)?;
  499. let _ = std::fs::remove_file(&path);
  500. let ids: Vec<&str> = cfg
  501. .keys
  502. .pii_keys_newest_first()
  503. .map(|(id, _)| id.as_str())
  504. .collect();
  505. assert_eq!(ids, vec!["v1"]);
  506. }
  507. Ok(())
  508. }
  509. #[test]
  510. fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>> {
  511. use secrecy::ExposeSecret as _;
  512. let path = std::env::temp_dir().join(format!(
  513. "d-oauth-read-secret-file-{}.txt",
  514. std::process::id()
  515. ));
  516. std::fs::write(&path, " spaced-secret \n")?;
  517. let secret = super::read_secret_file(Some(path.as_path()))?;
  518. let _ = std::fs::remove_file(&path);
  519. let secret = secret.ok_or("secret file returned None")?;
  520. assert_eq!(secret.expose_secret(), " spaced-secret ");
  521. Ok(())
  522. }