tests.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. //! `test(code_issue)` suite: redirect shape, hash-only storage, TTL bound,
  2. //! entitlement subset enforcement, and the public-client DPoP matrix.
  3. use std::{collections::HashMap, time::Duration};
  4. use redis::aio::ConnectionManager;
  5. use secrecy::SecretString;
  6. use super::{CODE_TTL_CAP, CodeClientKind, CodeIssueError, CodeIssueRequest, CodeIssuer};
  7. use crate::{
  8. config::{DurabilityMode, TtlConfig, redis::RawRedisConfig},
  9. keys::{KeyId, KeyRing, KeyRingError},
  10. protocol::scope::ScopeSet,
  11. state::RedisConn,
  12. store::{Store, StoreError, codes},
  13. };
  14. /// Test body result (no `unwrap`/`expect` under the deny lints).
  15. type TestResult = Result<(), TestError>;
  16. /// Errors raised by the code-issuance test scaffolding itself.
  17. #[derive(Debug, thiserror::Error)]
  18. enum TestError {
  19. /// The Redis configuration was rejected.
  20. #[error("config error: {0}")]
  21. Config(#[from] crate::config::ConfigError),
  22. /// A Redis command failed.
  23. #[error("redis error: {0}")]
  24. Redis(#[from] redis::RedisError),
  25. /// A store operation failed.
  26. #[error("store error: {0}")]
  27. Store(#[from] StoreError),
  28. /// A key-ring build failed.
  29. #[error("keyring error: {0}")]
  30. KeyRing(#[from] KeyRingError),
  31. /// A code issuance failed.
  32. #[error("issuance error: {0}")]
  33. Issue(#[from] CodeIssueError),
  34. /// A scope value did not parse.
  35. #[error("scope error: {0}")]
  36. Scope(#[from] crate::error::AppError),
  37. /// A test assertion observed an unexpected value.
  38. #[error("unexpected test value: {0}")]
  39. Unexpected(String),
  40. }
  41. const ISSUER: &str = "https://auth.example.test";
  42. const REDIRECT: &str = "https://app.example.test/cb";
  43. const CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
  44. /// Build a key ring with one pepper generation plus the mandatory TOTP chain.
  45. fn test_ring() -> Result<KeyRing, TestError> {
  46. Ok(KeyRing::from_parts(
  47. vec![(KeyId::new("v1"), SecretString::from("code-issue-pepper"))],
  48. vec![(KeyId::new("v1"), SecretString::from("code-issue-totp"))],
  49. vec![(KeyId::new("v1"), SecretString::from("code-issue-pii"))],
  50. )?)
  51. }
  52. /// Connect a [`RedisConn`] plus [`Store`] to the shared compose Redis, scoped
  53. /// to a per-test unique prefix pair so parallel tests never share a keyspace.
  54. async fn direct_store(
  55. manager: ConnectionManager,
  56. token_prefix: &str,
  57. ratelimit_prefix: &str,
  58. ) -> Result<(RedisConn, Store), TestError> {
  59. let raw = RawRedisConfig {
  60. url: Some(crate::test_support::test_redis_url()),
  61. durability: Some(DurabilityMode::WaitAof),
  62. response_timeout_secs: 10,
  63. connect_timeout_secs: 5,
  64. token_key_prefix: Some(token_prefix.to_owned()),
  65. ratelimit_key_prefix: Some(ratelimit_prefix.to_owned()),
  66. ..Default::default()
  67. };
  68. let config = crate::config::RedisConfig::from_raw(raw)?;
  69. Ok((RedisConn::Single(manager), Store::from_config(&config)))
  70. }
  71. /// A consent-approved issuance request over caller-supplied scope sets.
  72. fn request<'a>(
  73. kind: CodeClientKind,
  74. approved: &'a ScopeSet,
  75. entitled: &'a ScopeSet,
  76. jkt: Option<&'a str>,
  77. state: Option<&'a str>,
  78. ) -> CodeIssueRequest<'a> {
  79. CodeIssueRequest {
  80. client_id: "client-abc",
  81. client_kind: kind,
  82. dpop_required: kind == CodeClientKind::Public,
  83. user_id: "user-1",
  84. redirect_uri: REDIRECT,
  85. approved_scope: approved,
  86. user_entitled: entitled,
  87. code_challenge: CHALLENGE,
  88. dpop_jkt: jkt,
  89. state,
  90. aud: &[],
  91. issuer: ISSUER,
  92. }
  93. }
  94. /// Split a redirect `Location` into its query pairs.
  95. fn query_pairs(location: &str) -> Result<HashMap<String, String>, TestError> {
  96. let query = location.split_once('?').map_or("", |(_, tail)| tail);
  97. let mut out = HashMap::new();
  98. if query.is_empty() {
  99. return Ok(out);
  100. }
  101. for pair in query.split('&') {
  102. let (name, value) = pair
  103. .split_once('=')
  104. .ok_or_else(|| TestError::Unexpected("redirect pair without a value".to_owned()))?;
  105. out.insert(name.to_owned(), value.to_owned());
  106. }
  107. Ok(out)
  108. }
  109. /// Whether any key or hash field under `state_prefix` contains `needle`.
  110. async fn keyspace_contains(
  111. conn: &mut RedisConn,
  112. state_prefix: &str,
  113. needle: &str,
  114. ) -> Result<bool, TestError> {
  115. let pattern = format!("{state_prefix}*");
  116. let keys: Vec<String> = redis::cmd("KEYS").arg(&pattern).query_async(conn).await?;
  117. for key in &keys {
  118. if key.contains(needle) {
  119. return Ok(true);
  120. }
  121. let kind: String = redis::cmd("TYPE").arg(key).query_async(conn).await?;
  122. if kind == "string" {
  123. let value: Option<String> = redis::cmd("GET").arg(key).query_async(conn).await?;
  124. if value.is_some_and(|text| text.contains(needle)) {
  125. return Ok(true);
  126. }
  127. } else if kind == "hash" {
  128. let values: Vec<String> = redis::cmd("HVALS").arg(key).query_async(conn).await?;
  129. if values.iter().any(|text| text.contains(needle)) {
  130. return Ok(true);
  131. }
  132. }
  133. }
  134. Ok(false)
  135. }
  136. #[tokio::test]
  137. async fn code_issue_redirect_carries_code_state_iss() -> TestResult {
  138. let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
  139. let (mut conn, store) = direct_store(
  140. crate::test_support::redis_manager()
  141. .await
  142. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
  143. &state_prefix,
  144. &ratelimit_prefix,
  145. )
  146. .await?;
  147. let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
  148. let approved = ScopeSet::parse("api:read")?;
  149. let entitled = ScopeSet::parse("api:read api:admin")?;
  150. let issued = issuer
  151. .issue(
  152. &mut conn,
  153. &store,
  154. &request(
  155. CodeClientKind::Confidential,
  156. &approved,
  157. &entitled,
  158. None,
  159. Some("state-xyz"),
  160. ),
  161. )
  162. .await?;
  163. if issued.code.len() != crate::crypto::tokens::TOKEN_STR_LEN {
  164. return Err(TestError::Unexpected(
  165. "code must be a 256-bit base64url value".to_owned(),
  166. ));
  167. }
  168. let params = query_pairs(&issued.location)?;
  169. let code = params
  170. .get("code")
  171. .ok_or_else(|| TestError::Unexpected("redirect must carry code".to_owned()))?;
  172. if code != &issued.code {
  173. return Err(TestError::Unexpected(
  174. "redirect code must equal the issued code".to_owned(),
  175. ));
  176. }
  177. let state = params
  178. .get("state")
  179. .ok_or_else(|| TestError::Unexpected("redirect must echo state".to_owned()))?;
  180. if state != "state-xyz" {
  181. return Err(TestError::Unexpected("redirect must echo state".to_owned()));
  182. }
  183. let iss = params
  184. .get("iss")
  185. .ok_or_else(|| TestError::Unexpected("redirect must carry RFC 9207 iss".to_owned()))?;
  186. if iss != "https%3A%2F%2Fauth.example.test" {
  187. return Err(TestError::Unexpected(
  188. "redirect iss must be the issuer".to_owned(),
  189. ));
  190. }
  191. for forbidden in ["access_token", "refresh_token", "id_token", "token"] {
  192. if params.contains_key(forbidden) {
  193. return Err(TestError::Unexpected(
  194. "redirect must never carry tokens".to_owned(),
  195. ));
  196. }
  197. }
  198. Ok(())
  199. }
  200. /// An unbound public client is issued a bearer-capable code with no DPoP
  201. /// proof: issuance succeeds and the stored binding is empty.
  202. #[tokio::test]
  203. async fn public_unbound_code_issuance_succeeds() -> TestResult {
  204. let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
  205. let (mut conn, store) = direct_store(
  206. crate::test_support::redis_manager()
  207. .await
  208. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
  209. &state_prefix,
  210. &ratelimit_prefix,
  211. )
  212. .await?;
  213. let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
  214. let approved = ScopeSet::parse("api:read")?;
  215. let entitled = ScopeSet::parse("api:read api:admin")?;
  216. let issued = issuer
  217. .issue(
  218. &mut conn,
  219. &store,
  220. &CodeIssueRequest {
  221. client_id: "client-abc",
  222. client_kind: CodeClientKind::Public,
  223. dpop_required: false,
  224. user_id: "user-1",
  225. redirect_uri: REDIRECT,
  226. approved_scope: &approved,
  227. user_entitled: &entitled,
  228. code_challenge: CHALLENGE,
  229. dpop_jkt: None,
  230. state: None,
  231. aud: &[],
  232. issuer: ISSUER,
  233. },
  234. )
  235. .await
  236. .map_err(|error| TestError::Unexpected(format!("unbound issuance rejected: {error}")))?;
  237. if issued.code.is_empty() {
  238. return Err(TestError::Unexpected(
  239. "unbound issuance must return a code".to_owned(),
  240. ));
  241. }
  242. Ok(())
  243. }
  244. #[tokio::test]
  245. async fn code_issue_stores_only_hash_with_bounded_ttl() -> TestResult {
  246. let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
  247. let (mut conn, store) = direct_store(
  248. crate::test_support::redis_manager()
  249. .await
  250. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
  251. &state_prefix,
  252. &ratelimit_prefix,
  253. )
  254. .await?;
  255. let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
  256. let approved = ScopeSet::parse("api:read")?;
  257. let entitled = ScopeSet::parse("api:read")?;
  258. let issued = issuer
  259. .issue(
  260. &mut conn,
  261. &store,
  262. &request(
  263. CodeClientKind::Confidential,
  264. &approved,
  265. &entitled,
  266. None,
  267. None,
  268. ),
  269. )
  270. .await?;
  271. if issued.code_hash == issued.code {
  272. return Err(TestError::Unexpected(
  273. "storage identity must be a hash, not the code".to_owned(),
  274. ));
  275. }
  276. if keyspace_contains(&mut conn, &state_prefix, &issued.code).await? {
  277. return Err(TestError::Unexpected(
  278. "raw code must never appear in Redis".to_owned(),
  279. ));
  280. }
  281. let key = store.code_key(&issued.code_hash);
  282. let ttl_ms: i64 = redis::cmd("PTTL").arg(&key).query_async(&mut conn).await?;
  283. if ttl_ms <= 0 || ttl_ms > CODE_TTL_CAP.as_millis() as i64 {
  284. return Err(TestError::Unexpected(
  285. "code TTL must be within (0, 60s]".to_owned(),
  286. ));
  287. }
  288. let record = codes::consume(&mut conn, &store, &issued.code_hash)
  289. .await?
  290. .ok_or_else(|| TestError::Unexpected("issued code must be consumable once".to_owned()))?;
  291. if record.client_id != "client-abc"
  292. || record.subject != "user-1"
  293. || record.redirect_uri != REDIRECT
  294. || record.scope != "api:read"
  295. || record.code_challenge != CHALLENGE
  296. || !record.cnf.is_empty()
  297. || record.grant_id != issued.grant_id
  298. {
  299. return Err(TestError::Unexpected(
  300. "stored record must carry the issuance fields".to_owned(),
  301. ));
  302. }
  303. if codes::consume(&mut conn, &store, &issued.code_hash)
  304. .await?
  305. .is_some()
  306. {
  307. return Err(TestError::Unexpected("code must be single-use".to_owned()));
  308. }
  309. Ok(())
  310. }
  311. #[tokio::test]
  312. async fn code_issue_rejects_unentitled_approval() -> TestResult {
  313. let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
  314. let (mut conn, store) = direct_store(
  315. crate::test_support::redis_manager()
  316. .await
  317. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
  318. &state_prefix,
  319. &ratelimit_prefix,
  320. )
  321. .await?;
  322. let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
  323. let approved = ScopeSet::parse("api:read api:admin")?;
  324. let entitled = ScopeSet::parse("api:read")?;
  325. let outcome = issuer
  326. .issue(
  327. &mut conn,
  328. &store,
  329. &request(
  330. CodeClientKind::Confidential,
  331. &approved,
  332. &entitled,
  333. None,
  334. None,
  335. ),
  336. )
  337. .await;
  338. if !matches!(outcome, Err(CodeIssueError::UnentitledScope)) {
  339. return Err(TestError::Unexpected(
  340. "approval wider than entitlement must be rejected".to_owned(),
  341. ));
  342. }
  343. if keyspace_contains(&mut conn, &state_prefix, "client-abc").await? {
  344. return Err(TestError::Unexpected(
  345. "rejected issuance must store nothing".to_owned(),
  346. ));
  347. }
  348. Ok(())
  349. }
  350. #[tokio::test]
  351. async fn code_issue_public_requires_dpop_jkt() -> TestResult {
  352. let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
  353. let (mut conn, store) = direct_store(
  354. crate::test_support::redis_manager()
  355. .await
  356. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
  357. &state_prefix,
  358. &ratelimit_prefix,
  359. )
  360. .await?;
  361. let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
  362. let approved = ScopeSet::parse("api:read")?;
  363. let entitled = ScopeSet::parse("api:read api:admin")?;
  364. let missing = issuer
  365. .issue(
  366. &mut conn,
  367. &store,
  368. &request(
  369. CodeClientKind::Public,
  370. &approved,
  371. &entitled,
  372. None,
  373. Some("s"),
  374. ),
  375. )
  376. .await;
  377. if !matches!(missing, Err(CodeIssueError::MissingDpopBinding)) {
  378. return Err(TestError::Unexpected(
  379. "public issuance without a jkt must be rejected".to_owned(),
  380. ));
  381. }
  382. let empty = issuer
  383. .issue(
  384. &mut conn,
  385. &store,
  386. &request(
  387. CodeClientKind::Public,
  388. &approved,
  389. &entitled,
  390. Some(""),
  391. Some("s"),
  392. ),
  393. )
  394. .await;
  395. if !matches!(empty, Err(CodeIssueError::MissingDpopBinding)) {
  396. return Err(TestError::Unexpected(
  397. "public issuance with an empty jkt must be rejected".to_owned(),
  398. ));
  399. }
  400. let issued = issuer
  401. .issue(
  402. &mut conn,
  403. &store,
  404. &request(
  405. CodeClientKind::Public,
  406. &approved,
  407. &entitled,
  408. Some("jkt-thumbprint-1"),
  409. Some("s"),
  410. ),
  411. )
  412. .await?;
  413. let record = codes::consume(&mut conn, &store, &issued.code_hash)
  414. .await?
  415. .ok_or_else(|| TestError::Unexpected("public code must be consumable".to_owned()))?;
  416. if record.cnf != "jkt-thumbprint-1" {
  417. return Err(TestError::Unexpected(
  418. "public-client code must carry the dpop_jkt binding".to_owned(),
  419. ));
  420. }
  421. Ok(())
  422. }
  423. #[tokio::test]
  424. async fn code_issue_ttl_clamped_to_sixty_seconds() -> TestResult {
  425. let ring = test_ring()?;
  426. let ttl = TtlConfig {
  427. code: Duration::from_secs(300),
  428. ..TtlConfig::default()
  429. };
  430. let issuer = CodeIssuer::new(&ttl, &ring)?;
  431. if issuer.ttl() != CODE_TTL_CAP {
  432. return Err(TestError::Unexpected(
  433. "code TTL must clamp to 60s even when configured higher".to_owned(),
  434. ));
  435. }
  436. Ok(())
  437. }
  438. #[test]
  439. fn code_issuer_debug_redacts_pepper() -> TestResult {
  440. let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
  441. let rendered = format!("{issuer:?}");
  442. if rendered.contains("code-issue-pepper") {
  443. return Err(TestError::Unexpected(
  444. "Debug must never print raw pepper bytes".to_owned(),
  445. ));
  446. }
  447. if !rendered.contains("[REDACTED]") {
  448. return Err(TestError::Unexpected(
  449. "Debug must mark pepper material as [REDACTED]".to_owned(),
  450. ));
  451. }
  452. Ok(())
  453. }
  454. #[tokio::test]
  455. async fn code_issue_debug_redacts_raw_code() -> TestResult {
  456. let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
  457. let (mut conn, store) = direct_store(
  458. crate::test_support::redis_manager()
  459. .await
  460. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
  461. &state_prefix,
  462. &ratelimit_prefix,
  463. )
  464. .await?;
  465. let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
  466. let approved = ScopeSet::parse("api:read")?;
  467. let entitled = ScopeSet::parse("api:read")?;
  468. let issued = issuer
  469. .issue(
  470. &mut conn,
  471. &store,
  472. &request(
  473. CodeClientKind::Confidential,
  474. &approved,
  475. &entitled,
  476. None,
  477. None,
  478. ),
  479. )
  480. .await?;
  481. let rendered = format!("{issued:?}");
  482. if rendered.contains(&issued.code) {
  483. return Err(TestError::Unexpected(
  484. "Debug must never print the raw code".to_owned(),
  485. ));
  486. }
  487. Ok(())
  488. }