tests.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. //! `test(jwt_)` suite: mint/verify round-trips, header discipline, rotation,
  2. //! allowlist markers, and disabled-subject rejection for AS-minted JWT
  3. //! access tokens (RFC 9068 profile subset).
  4. use std::sync::atomic::{AtomicU64, Ordering};
  5. use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
  6. use jsonwebtoken::{Algorithm, Header, decode_header, encode};
  7. use secrecy::SecretString;
  8. use time::OffsetDateTime;
  9. use super::{
  10. ActClaim, JwtAccessSigner, JwtClaims, JwtError, VerifiedJwt, jti_hash, marker_live,
  11. subject_active,
  12. };
  13. use crate::{
  14. config::{DurabilityMode, JwtSigningAlg, JwtSigningKey},
  15. crypto::secret::{Argon2Params, hash_secret},
  16. db::{
  17. test_support::migrated_pool,
  18. user_repo::{NewUser, create_user, disable_user},
  19. },
  20. keys::{KeyId, KeyRing},
  21. protocol::token::Cnf,
  22. state::RedisConn,
  23. store::Store,
  24. };
  25. /// Test body result (no `unwrap`/`expect` under the deny lints).
  26. type TestError = Box<dyn std::error::Error + Send + Sync>;
  27. type TestResult = Result<(), TestError>;
  28. /// Issuer every test signer asserts.
  29. const TEST_ISSUER: &str = "https://as.example.test";
  30. /// Access-token TTL stand-in: the round-trip pins `exp == iat + TTL`.
  31. const TEST_TTL: i64 = 3_600;
  32. static JTI_NONCE: AtomicU64 = AtomicU64::new(1);
  33. /// A `jti` no other test uses, so allowlist markers never collide.
  34. fn unique_jti() -> String {
  35. let n = JTI_NONCE.fetch_add(1, Ordering::Relaxed);
  36. format!("jwt-test-jti-{n}")
  37. }
  38. /// Current time as seconds since the Unix epoch.
  39. fn now_unix() -> i64 {
  40. OffsetDateTime::now_utc().unix_timestamp()
  41. }
  42. /// A validated signing key from a fixed 32-byte printable scalar.
  43. fn signing_key(kid: &str, seed: &str, alg: JwtSigningAlg) -> Result<JwtSigningKey, TestError> {
  44. let raw = SecretString::from(seed.repeat(32));
  45. Ok(JwtSigningKey::from_raw(
  46. KeyId::new(kid),
  47. alg,
  48. &raw,
  49. "jwt_signing_key",
  50. )?)
  51. }
  52. /// An ES256 signer with no previous keys.
  53. fn test_signer() -> Result<(JwtAccessSigner, JwtSigningKey), TestError> {
  54. let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
  55. let signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), current.clone(), Vec::new());
  56. Ok((signer, current))
  57. }
  58. /// A well-formed claim set; tests override fields via struct-update syntax.
  59. fn valid_claims(now: i64) -> JwtClaims {
  60. JwtClaims {
  61. iss: TEST_ISSUER.to_owned(),
  62. sub: "subject@example.test".to_owned(),
  63. aud: vec!["https://rs.example.test".to_owned()],
  64. scope: "orders:read orders:write".to_owned(),
  65. exp: now + TEST_TTL,
  66. iat: now,
  67. jti: unique_jti(),
  68. client_id: "test-client".to_owned(),
  69. cnf: None,
  70. act: None,
  71. }
  72. }
  73. /// Fresh connection plus store handle against the shared compose Redis.
  74. async fn test_redis() -> Result<(RedisConn, Store), TestError> {
  75. let manager = crate::test_support::redis_manager().await?;
  76. let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
  77. let store = Store::from_parts(state_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
  78. Ok((RedisConn::Single(manager), store))
  79. }
  80. /// Test PII key ring for the disabled-subject re-check.
  81. fn test_keys() -> Result<KeyRing, TestError> {
  82. Ok(KeyRing::from_parts(
  83. vec![(KeyId::new("v1"), SecretString::from("jwt-test-pepper"))],
  84. vec![(KeyId::new("v1"), SecretString::from("jwt-test-totp"))],
  85. vec![(KeyId::new("v1"), SecretString::from("jwt-test-pii"))],
  86. )?)
  87. }
  88. /// Write the `jwt:<jti-hash>` allowlist marker hash for `jti`.
  89. async fn write_marker(
  90. conn: &mut RedisConn,
  91. store: &Store,
  92. jti: &str,
  93. subject: &str,
  94. ) -> Result<(), TestError> {
  95. let key = store.jwt_marker_key(&jti_hash(jti));
  96. let hash = jti_hash(jti);
  97. redis::cmd("HSET")
  98. .arg(&key)
  99. .arg("grant_id")
  100. .arg("grant-1")
  101. .arg("client_id")
  102. .arg("test-client")
  103. .arg("subject")
  104. .arg(subject)
  105. .query_async::<()>(conn)
  106. .await?;
  107. redis::cmd("SADD")
  108. .arg(store.grant_tokens_key("grant-1"))
  109. .arg(format!("jwt:{hash}"))
  110. .query_async::<()>(conn)
  111. .await?;
  112. Ok(())
  113. }
  114. /// Assert a verification outcome is exactly the expected `JwtError`.
  115. fn assert_jwt_error(result: Result<VerifiedJwt, JwtError>, want: JwtError) -> TestResult {
  116. match result {
  117. Ok(_) => Err(format!("expected {want:?}, token was accepted").into()),
  118. Err(got) => {
  119. if got == want {
  120. Ok(())
  121. } else {
  122. Err(format!("expected {want:?}, got {got:?}").into())
  123. }
  124. }
  125. }
  126. }
  127. /// Sign with the configured key but a forged `typ: "JWT"` header: the
  128. /// signature is valid, yet verification must reject the type.
  129. #[test]
  130. fn jwt_verify_rejects_wrong_typ() -> TestResult {
  131. let (signer, key) = test_signer()?;
  132. let claims = valid_claims(now_unix());
  133. let mut header = Header::new(Algorithm::ES256);
  134. header.kid = Some(key.kid().as_str().to_owned());
  135. header.typ = Some("JWT".to_owned());
  136. let forged = encode(&header, &claims, key.encoding_key())?;
  137. assert_jwt_error(signer.verify(&forged, None), JwtError::WrongTyp)
  138. }
  139. /// A token whose `aud` lacks a required value is rejected, even when the
  140. /// signature and every other claim verify.
  141. #[test]
  142. fn jwt_verify_rejects_wrong_aud() -> TestResult {
  143. let (signer, _) = test_signer()?;
  144. let claims = valid_claims(now_unix());
  145. let token = signer.sign(&claims)?;
  146. assert_jwt_error(
  147. signer.verify(&token, Some(&["https://other.example.test".to_owned()])),
  148. JwtError::WrongAudience,
  149. )
  150. }
  151. /// Signature and claims valid, but no marker hash in Redis: the token is
  152. /// unusable (revoked, expired, or purged).
  153. #[tokio::test]
  154. async fn jwt_verify_rejects_unknown_marker() -> TestResult {
  155. let (signer, _) = test_signer()?;
  156. let (mut conn, store) = test_redis().await?;
  157. let (_db, pool) = migrated_pool().await?;
  158. let keys = test_keys()?;
  159. let claims = JwtClaims {
  160. sub: String::new(),
  161. ..valid_claims(now_unix())
  162. };
  163. let token = signer.sign(&claims)?;
  164. if marker_live(&mut conn, &store, &jti_hash(&claims.jti)).await? {
  165. return Err("marker exists for a fresh jti".into());
  166. }
  167. match signer
  168. .verify_live(&mut conn, &store, &pool, &keys, &token, None)
  169. .await
  170. {
  171. Err(JwtError::MarkerAbsent) => Ok(()),
  172. Err(got) => Err(format!("expected MarkerAbsent, got {got:?}").into()),
  173. Ok(_) => Err("markerless token was accepted".into()),
  174. }
  175. }
  176. /// A token with no `cnf` (unbound bearer, e.g. client-credentials) verifies,
  177. /// and `cnf` round-trips to `None` on the wire.
  178. #[test]
  179. fn jwt_verify_accepts_absent_cnf() -> TestResult {
  180. let (signer, _) = test_signer()?;
  181. let claims = valid_claims(now_unix());
  182. let token = signer.sign(&claims)?;
  183. let verified = signer.verify(&token, None)?;
  184. if verified.claims.cnf.is_some() {
  185. return Err("absent cnf did not round-trip to None".into());
  186. }
  187. let segments: Vec<&str> = token.split('.').collect();
  188. if segments.len() != 3 {
  189. return Err("minted token is not three segments".into());
  190. }
  191. let payload: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[1])?)?;
  192. if payload.get("cnf").is_some() {
  193. return Err("absent cnf was serialized onto the wire".into());
  194. }
  195. let bound = JwtClaims {
  196. cnf: Some(Cnf {
  197. jkt: Some("test-thumbprint".to_owned()),
  198. x5t_s256: None,
  199. }),
  200. ..valid_claims(now_unix())
  201. };
  202. let bound_verified = signer.verify(&signer.sign(&bound)?, None)?;
  203. if bound_verified.claims.cnf != bound.cnf {
  204. return Err("present cnf did not survive the round-trip".into());
  205. }
  206. Ok(())
  207. }
  208. /// Mint-to-verify round-trip: header `typ`/`kid`/`alg`, `client_id`, `scope`,
  209. /// `aud`, `jti`, `act`, and `iat`/`exp` pinned to now plus the access TTL.
  210. #[test]
  211. fn jwt_sign_verify_round_trip() -> TestResult {
  212. let (signer, key) = test_signer()?;
  213. let now = now_unix();
  214. let claims = JwtClaims {
  215. act: Some(ActClaim {
  216. sub: "subject-client".to_owned(),
  217. }),
  218. ..valid_claims(now)
  219. };
  220. let token = signer.sign(&claims)?;
  221. let header = decode_header(&token)?;
  222. if header.typ.as_deref() != Some("at+jwt") {
  223. return Err("header typ is not at+jwt".into());
  224. }
  225. if header.kid.as_deref() != Some(key.kid().as_str()) {
  226. return Err("header kid is not the current key".into());
  227. }
  228. if header.alg != Algorithm::ES256 {
  229. return Err("header alg is not the configured algorithm".into());
  230. }
  231. let verified = signer.verify(
  232. &token,
  233. Some(&[
  234. "https://rs.example.test".to_owned(),
  235. "https://rs.example.test".to_owned(),
  236. ]),
  237. )?;
  238. if verified.claims != claims {
  239. return Err("claims did not survive the round-trip".into());
  240. }
  241. if verified.claims.iat != now || verified.claims.exp != now + TEST_TTL {
  242. return Err("iat/exp are not now plus the access TTL".into());
  243. }
  244. if verified.claims.client_id != "test-client" {
  245. return Err("client_id did not survive the round-trip".into());
  246. }
  247. Ok(())
  248. }
  249. /// Rotation: a token minted under the previous kid verifies once that kid is
  250. /// retained verify-only; the current kid still signs.
  251. #[test]
  252. fn jwt_verify_rotation_previous_kid_accepted() -> TestResult {
  253. let old = signing_key("v1", "P", JwtSigningAlg::Es256)?;
  254. let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
  255. let old_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), old.clone(), Vec::new());
  256. let rotated = JwtAccessSigner::new(TEST_ISSUER.to_owned(), current.clone(), vec![old.clone()]);
  257. let token = old_signer.sign(&valid_claims(now_unix()))?;
  258. let verified = rotated.verify(&token, None)?;
  259. if decode_header(&token)?.kid.as_deref() != Some("v1") {
  260. return Err("rotated token does not carry the previous kid".into());
  261. }
  262. if verified.claims.jti.is_empty() {
  263. return Err("rotated token claims are empty".into());
  264. }
  265. assert_jwt_error(
  266. JwtAccessSigner::new(TEST_ISSUER.to_owned(), current, Vec::new()).verify(&token, None),
  267. JwtError::UnknownKid,
  268. )
  269. }
  270. /// A single flipped signature character rejects with `Signature`.
  271. #[test]
  272. fn jwt_verify_rejects_tampered_signature() -> TestResult {
  273. let (signer, _) = test_signer()?;
  274. let token = signer.sign(&valid_claims(now_unix()))?;
  275. assert_jwt_error(
  276. signer.verify(&tamper_signature(&token)?, None),
  277. JwtError::Signature,
  278. )
  279. }
  280. /// An expired token is rejected with zero leeway.
  281. #[test]
  282. fn jwt_verify_rejects_expired() -> TestResult {
  283. let (signer, _) = test_signer()?;
  284. let now = now_unix();
  285. let claims = JwtClaims {
  286. iat: now - TEST_TTL - 20,
  287. exp: now - 10,
  288. ..valid_claims(now)
  289. };
  290. assert_jwt_error(
  291. signer.verify(&signer.sign(&claims)?, None),
  292. JwtError::Expired,
  293. )
  294. }
  295. /// Flip the first signature character: every base64url symbol there carries
  296. /// full data bits, so the token still parses and only the signature breaks.
  297. fn tamper_signature(token: &str) -> Result<String, TestError> {
  298. let segments: Vec<&str> = token.split('.').collect();
  299. if segments.len() != 3 {
  300. return Err("minted token is not three segments".into());
  301. }
  302. let mut sig = segments[2].to_owned();
  303. let first = sig.remove(0);
  304. sig.insert(0, if first == 'a' { 'b' } else { 'a' });
  305. Ok(format!("{}.{}.{}", segments[0], segments[1], sig))
  306. }
  307. /// A token minted for another issuer is rejected.
  308. #[test]
  309. fn jwt_verify_rejects_wrong_issuer() -> TestResult {
  310. let (signer, _) = test_signer()?;
  311. let claims = JwtClaims {
  312. iss: "https://other.example.test".to_owned(),
  313. ..valid_claims(now_unix())
  314. };
  315. assert_jwt_error(
  316. signer.verify(&signer.sign(&claims)?, None),
  317. JwtError::WrongIssuer,
  318. )
  319. }
  320. /// A header `kid` naming no current or previous key fails with no fallback.
  321. #[test]
  322. fn jwt_verify_rejects_unknown_kid() -> TestResult {
  323. let (signer, _) = test_signer()?;
  324. let stranger = signing_key("v9", "S", JwtSigningAlg::Es256)?;
  325. let stranger_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), stranger, Vec::new());
  326. let token = stranger_signer.sign(&valid_claims(now_unix()))?;
  327. assert_jwt_error(signer.verify(&token, None), JwtError::UnknownKid)
  328. }
  329. /// A token signed under another algorithm is rejected even with a valid
  330. /// signature for that algorithm: the header never picks the algorithm.
  331. #[test]
  332. fn jwt_verify_rejects_wrong_alg() -> TestResult {
  333. let (signer, _) = test_signer()?;
  334. let ed_key = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
  335. let ed_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), ed_key, Vec::new());
  336. let token = ed_signer.sign(&valid_claims(now_unix()))?;
  337. assert_jwt_error(signer.verify(&token, None), JwtError::WrongAlg)
  338. }
  339. /// EdDSA keys mint and verify under the same header discipline.
  340. #[test]
  341. fn jwt_eddsa_sign_verify_round_trip() -> TestResult {
  342. let key = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
  343. let signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), key.clone(), Vec::new());
  344. let token = signer.sign(&valid_claims(now_unix()))?;
  345. let header = decode_header(&token)?;
  346. if header.alg != Algorithm::EdDSA || header.typ.as_deref() != Some("at+jwt") {
  347. return Err("eddsa header does not carry alg EdDSA and typ at+jwt".into());
  348. }
  349. signer.verify(&token, None)?;
  350. Ok(())
  351. }
  352. /// Marker present, subject enabled: live. After `disable_user`: the same
  353. /// token reports `SubjectDisabled`. Empty and unmapped subjects stay live.
  354. #[tokio::test]
  355. async fn jwt_verify_live_rejects_disabled_subject() -> TestResult {
  356. let (signer, _) = test_signer()?;
  357. let (mut conn, store) = test_redis().await?;
  358. let (_db, pool) = migrated_pool().await?;
  359. let keys = test_keys()?;
  360. let params = Argon2Params::new(8, 1, 1, 32)?;
  361. let email = format!("jwt-subject-{}@example.test", now_unix());
  362. let record = create_user(
  363. &pool,
  364. &keys,
  365. false,
  366. &NewUser {
  367. email: email.clone(),
  368. password_hash: hash_secret("correct-horse-01", &params)?,
  369. is_admin: false,
  370. must_change_password: false,
  371. },
  372. )
  373. .await?;
  374. let claims = JwtClaims {
  375. sub: email.clone(),
  376. ..valid_claims(now_unix())
  377. };
  378. let token = signer.sign(&claims)?;
  379. write_marker(&mut conn, &store, &claims.jti, &email).await?;
  380. signer
  381. .verify_live(&mut conn, &store, &pool, &keys, &token, None)
  382. .await?;
  383. if !subject_active(&pool, &keys, "").await? {
  384. return Err("empty subject must stay live".into());
  385. }
  386. if !subject_active(&pool, &keys, "synthetic-subject").await? {
  387. return Err("unmapped subject must stay live".into());
  388. }
  389. if !disable_user(&pool, record.id).await? {
  390. return Err("disable_user reported no change".into());
  391. }
  392. match signer
  393. .verify_live(&mut conn, &store, &pool, &keys, &token, None)
  394. .await
  395. {
  396. Err(JwtError::SubjectDisabled) => Ok(()),
  397. Err(got) => Err(format!("expected SubjectDisabled, got {got:?}").into()),
  398. Ok(_) => Err("disabled subject token was accepted".into()),
  399. }
  400. }
  401. /// Strict liveness: a marker hash without grant membership is dead
  402. /// (`MarkerAbsent`), even though the marker-only check reads live.
  403. #[tokio::test]
  404. async fn jwt_verify_live_rejects_orphan_marker() -> TestResult {
  405. let (signer, _) = test_signer()?;
  406. let (mut conn, store) = test_redis().await?;
  407. let (_db, pool) = migrated_pool().await?;
  408. let keys = test_keys()?;
  409. let claims = JwtClaims {
  410. sub: String::new(),
  411. ..valid_claims(now_unix())
  412. };
  413. let token = signer.sign(&claims)?;
  414. let hash = jti_hash(&claims.jti);
  415. let key = store.jwt_marker_key(&hash);
  416. redis::cmd("HSET")
  417. .arg(&key)
  418. .arg("grant_id")
  419. .arg("orphan-grant")
  420. .arg("client_id")
  421. .arg("test-client")
  422. .arg("subject")
  423. .arg("")
  424. .query_async::<()>(&mut conn)
  425. .await?;
  426. if !marker_live(&mut conn, &store, &hash).await? {
  427. return Err("orphan marker must read live on the marker-only check".into());
  428. }
  429. match signer
  430. .verify_live(&mut conn, &store, &pool, &keys, &token, None)
  431. .await
  432. {
  433. Err(JwtError::MarkerAbsent) => Ok(()),
  434. Err(got) => Err(format!("expected MarkerAbsent, got {got:?}").into()),
  435. Ok(_) => Err("orphan marker token was accepted".into()),
  436. }
  437. }
  438. /// `Debug` on the signer and on every key shape carries no key material: the
  439. /// raw 32-byte scalars appear nowhere; only `kid`/`alg`/public coordinates.
  440. #[test]
  441. fn jwt_debug_redacts_key_material() -> TestResult {
  442. let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
  443. let previous = signing_key("v1", "P", JwtSigningAlg::Es256)?;
  444. let ed = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
  445. let signer = JwtAccessSigner::new(
  446. TEST_ISSUER.to_owned(),
  447. current.clone(),
  448. vec![previous.clone()],
  449. );
  450. for raw in ["C".repeat(32), "P".repeat(32), "E".repeat(32)] {
  451. for rendered in [
  452. format!("{signer:?}"),
  453. format!("{current:?}"),
  454. format!("{previous:?}"),
  455. format!("{ed:?}"),
  456. ] {
  457. if rendered.contains(&raw) {
  458. return Err("key material leaked into Debug".into());
  459. }
  460. }
  461. }
  462. let rendered = format!("{signer:?}");
  463. for marker in ["v2", "v1", "Es256"] {
  464. if !rendered.contains(marker) {
  465. return Err(format!("signer Debug lost {marker}").into());
  466. }
  467. }
  468. if !rendered.contains(current.public_jwk().x.as_str()) {
  469. return Err("signer Debug lost the public coordinates".into());
  470. }
  471. Ok(())
  472. }
  473. /// `JwtClaims` Debug never prints the PII `sub` or the raw `jti` (the
  474. /// allowlist marker key material); public claim members stay visible.
  475. #[test]
  476. fn jwt_claims_debug_redacts_sub_and_jti() {
  477. let claims = JwtClaims {
  478. iss: "https://auth.example.test".to_owned(),
  479. sub: "user@example.test".to_owned(),
  480. aud: vec!["https://api.example.test".to_owned()],
  481. scope: "read".to_owned(),
  482. exp: 1_700_000_000,
  483. iat: 1_699_999_700,
  484. jti: "raw-jti-marker".to_owned(),
  485. client_id: "client-abc".to_owned(),
  486. cnf: None,
  487. act: None,
  488. };
  489. let rendered = format!("{claims:?}");
  490. assert!(!rendered.contains("user@example.test"));
  491. assert!(!rendered.contains("raw-jti-marker"));
  492. assert!(rendered.contains("[REDACTED]"));
  493. assert!(rendered.contains("client-abc"));
  494. }