tests.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  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. redis::cmd("HSET")
  97. .arg(&key)
  98. .arg("grant_id")
  99. .arg("grant-1")
  100. .arg("client_id")
  101. .arg("test-client")
  102. .arg("subject")
  103. .arg(subject)
  104. .query_async::<()>(conn)
  105. .await?;
  106. Ok(())
  107. }
  108. /// Assert a verification outcome is exactly the expected `JwtError`.
  109. fn assert_jwt_error(result: Result<VerifiedJwt, JwtError>, want: JwtError) -> TestResult {
  110. match result {
  111. Ok(_) => Err(format!("expected {want:?}, token was accepted").into()),
  112. Err(got) => {
  113. if got == want {
  114. Ok(())
  115. } else {
  116. Err(format!("expected {want:?}, got {got:?}").into())
  117. }
  118. }
  119. }
  120. }
  121. /// Sign with the configured key but a forged `typ: "JWT"` header: the
  122. /// signature is valid, yet verification must reject the type.
  123. #[test]
  124. fn jwt_verify_rejects_wrong_typ() -> TestResult {
  125. let (signer, key) = test_signer()?;
  126. let claims = valid_claims(now_unix());
  127. let mut header = Header::new(Algorithm::ES256);
  128. header.kid = Some(key.kid().as_str().to_owned());
  129. header.typ = Some("JWT".to_owned());
  130. let forged = encode(&header, &claims, key.encoding_key())?;
  131. assert_jwt_error(signer.verify(&forged, None), JwtError::WrongTyp)
  132. }
  133. /// A token whose `aud` lacks a required value is rejected, even when the
  134. /// signature and every other claim verify.
  135. #[test]
  136. fn jwt_verify_rejects_wrong_aud() -> TestResult {
  137. let (signer, _) = test_signer()?;
  138. let claims = valid_claims(now_unix());
  139. let token = signer.sign(&claims)?;
  140. assert_jwt_error(
  141. signer.verify(&token, Some(&["https://other.example.test".to_owned()])),
  142. JwtError::WrongAudience,
  143. )
  144. }
  145. /// Signature and claims valid, but no marker hash in Redis: the token is
  146. /// unusable (revoked, expired, or purged).
  147. #[tokio::test]
  148. async fn jwt_verify_rejects_unknown_marker() -> TestResult {
  149. let (signer, _) = test_signer()?;
  150. let (mut conn, store) = test_redis().await?;
  151. let (_db, pool) = migrated_pool().await?;
  152. let keys = test_keys()?;
  153. let claims = JwtClaims {
  154. sub: String::new(),
  155. ..valid_claims(now_unix())
  156. };
  157. let token = signer.sign(&claims)?;
  158. if marker_live(&mut conn, &store, &jti_hash(&claims.jti)).await? {
  159. return Err("marker exists for a fresh jti".into());
  160. }
  161. match signer
  162. .verify_live(&mut conn, &store, &pool, &keys, &token, None)
  163. .await
  164. {
  165. Err(JwtError::MarkerAbsent) => Ok(()),
  166. Err(got) => Err(format!("expected MarkerAbsent, got {got:?}").into()),
  167. Ok(_) => Err("markerless token was accepted".into()),
  168. }
  169. }
  170. /// A token with no `cnf` (unbound bearer, e.g. client-credentials) verifies,
  171. /// and `cnf` round-trips to `None` on the wire.
  172. #[test]
  173. fn jwt_verify_accepts_absent_cnf() -> TestResult {
  174. let (signer, _) = test_signer()?;
  175. let claims = valid_claims(now_unix());
  176. let token = signer.sign(&claims)?;
  177. let verified = signer.verify(&token, None)?;
  178. if verified.claims.cnf.is_some() {
  179. return Err("absent cnf did not round-trip to None".into());
  180. }
  181. let segments: Vec<&str> = token.split('.').collect();
  182. if segments.len() != 3 {
  183. return Err("minted token is not three segments".into());
  184. }
  185. let payload: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[1])?)?;
  186. if payload.get("cnf").is_some() {
  187. return Err("absent cnf was serialized onto the wire".into());
  188. }
  189. let bound = JwtClaims {
  190. cnf: Some(Cnf {
  191. jkt: Some("test-thumbprint".to_owned()),
  192. x5t_s256: None,
  193. }),
  194. ..valid_claims(now_unix())
  195. };
  196. let bound_verified = signer.verify(&signer.sign(&bound)?, None)?;
  197. if bound_verified.claims.cnf != bound.cnf {
  198. return Err("present cnf did not survive the round-trip".into());
  199. }
  200. Ok(())
  201. }
  202. /// Mint-to-verify round-trip: header `typ`/`kid`/`alg`, `client_id`, `scope`,
  203. /// `aud`, `jti`, `act`, and `iat`/`exp` pinned to now plus the access TTL.
  204. #[test]
  205. fn jwt_sign_verify_round_trip() -> TestResult {
  206. let (signer, key) = test_signer()?;
  207. let now = now_unix();
  208. let claims = JwtClaims {
  209. act: Some(ActClaim {
  210. sub: "subject-client".to_owned(),
  211. }),
  212. ..valid_claims(now)
  213. };
  214. let token = signer.sign(&claims)?;
  215. let header = decode_header(&token)?;
  216. if header.typ.as_deref() != Some("at+jwt") {
  217. return Err("header typ is not at+jwt".into());
  218. }
  219. if header.kid.as_deref() != Some(key.kid().as_str()) {
  220. return Err("header kid is not the current key".into());
  221. }
  222. if header.alg != Algorithm::ES256 {
  223. return Err("header alg is not the configured algorithm".into());
  224. }
  225. let verified = signer.verify(
  226. &token,
  227. Some(&[
  228. "https://rs.example.test".to_owned(),
  229. "https://rs.example.test".to_owned(),
  230. ]),
  231. )?;
  232. if verified.claims != claims {
  233. return Err("claims did not survive the round-trip".into());
  234. }
  235. if verified.claims.iat != now || verified.claims.exp != now + TEST_TTL {
  236. return Err("iat/exp are not now plus the access TTL".into());
  237. }
  238. if verified.claims.client_id != "test-client" {
  239. return Err("client_id did not survive the round-trip".into());
  240. }
  241. Ok(())
  242. }
  243. /// Rotation: a token minted under the previous kid verifies once that kid is
  244. /// retained verify-only; the current kid still signs.
  245. #[test]
  246. fn jwt_verify_rotation_previous_kid_accepted() -> TestResult {
  247. let old = signing_key("v1", "P", JwtSigningAlg::Es256)?;
  248. let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
  249. let old_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), old.clone(), Vec::new());
  250. let rotated = JwtAccessSigner::new(TEST_ISSUER.to_owned(), current.clone(), vec![old.clone()]);
  251. let token = old_signer.sign(&valid_claims(now_unix()))?;
  252. let verified = rotated.verify(&token, None)?;
  253. if decode_header(&token)?.kid.as_deref() != Some("v1") {
  254. return Err("rotated token does not carry the previous kid".into());
  255. }
  256. if verified.claims.jti.is_empty() {
  257. return Err("rotated token claims are empty".into());
  258. }
  259. assert_jwt_error(
  260. JwtAccessSigner::new(TEST_ISSUER.to_owned(), current, Vec::new()).verify(&token, None),
  261. JwtError::UnknownKid,
  262. )
  263. }
  264. /// A single flipped signature character rejects with `Signature`.
  265. #[test]
  266. fn jwt_verify_rejects_tampered_signature() -> TestResult {
  267. let (signer, _) = test_signer()?;
  268. let token = signer.sign(&valid_claims(now_unix()))?;
  269. assert_jwt_error(
  270. signer.verify(&tamper_signature(&token)?, None),
  271. JwtError::Signature,
  272. )
  273. }
  274. /// An expired token is rejected with zero leeway.
  275. #[test]
  276. fn jwt_verify_rejects_expired() -> TestResult {
  277. let (signer, _) = test_signer()?;
  278. let now = now_unix();
  279. let claims = JwtClaims {
  280. iat: now - TEST_TTL - 20,
  281. exp: now - 10,
  282. ..valid_claims(now)
  283. };
  284. assert_jwt_error(
  285. signer.verify(&signer.sign(&claims)?, None),
  286. JwtError::Expired,
  287. )
  288. }
  289. /// Flip the first signature character: every base64url symbol there carries
  290. /// full data bits, so the token still parses and only the signature breaks.
  291. fn tamper_signature(token: &str) -> Result<String, TestError> {
  292. let segments: Vec<&str> = token.split('.').collect();
  293. if segments.len() != 3 {
  294. return Err("minted token is not three segments".into());
  295. }
  296. let mut sig = segments[2].to_owned();
  297. let first = sig.remove(0);
  298. sig.insert(0, if first == 'a' { 'b' } else { 'a' });
  299. Ok(format!("{}.{}.{}", segments[0], segments[1], sig))
  300. }
  301. /// A token minted for another issuer is rejected.
  302. #[test]
  303. fn jwt_verify_rejects_wrong_issuer() -> TestResult {
  304. let (signer, _) = test_signer()?;
  305. let claims = JwtClaims {
  306. iss: "https://other.example.test".to_owned(),
  307. ..valid_claims(now_unix())
  308. };
  309. assert_jwt_error(
  310. signer.verify(&signer.sign(&claims)?, None),
  311. JwtError::WrongIssuer,
  312. )
  313. }
  314. /// A header `kid` naming no current or previous key fails with no fallback.
  315. #[test]
  316. fn jwt_verify_rejects_unknown_kid() -> TestResult {
  317. let (signer, _) = test_signer()?;
  318. let stranger = signing_key("v9", "S", JwtSigningAlg::Es256)?;
  319. let stranger_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), stranger, Vec::new());
  320. let token = stranger_signer.sign(&valid_claims(now_unix()))?;
  321. assert_jwt_error(signer.verify(&token, None), JwtError::UnknownKid)
  322. }
  323. /// A token signed under another algorithm is rejected even with a valid
  324. /// signature for that algorithm: the header never picks the algorithm.
  325. #[test]
  326. fn jwt_verify_rejects_wrong_alg() -> TestResult {
  327. let (signer, _) = test_signer()?;
  328. let ed_key = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
  329. let ed_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), ed_key, Vec::new());
  330. let token = ed_signer.sign(&valid_claims(now_unix()))?;
  331. assert_jwt_error(signer.verify(&token, None), JwtError::WrongAlg)
  332. }
  333. /// EdDSA keys mint and verify under the same header discipline.
  334. #[test]
  335. fn jwt_eddsa_sign_verify_round_trip() -> TestResult {
  336. let key = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
  337. let signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), key.clone(), Vec::new());
  338. let token = signer.sign(&valid_claims(now_unix()))?;
  339. let header = decode_header(&token)?;
  340. if header.alg != Algorithm::EdDSA || header.typ.as_deref() != Some("at+jwt") {
  341. return Err("eddsa header does not carry alg EdDSA and typ at+jwt".into());
  342. }
  343. signer.verify(&token, None)?;
  344. Ok(())
  345. }
  346. /// Marker present, subject enabled: live. After `disable_user`: the same
  347. /// token reports `SubjectDisabled`. Empty and unmapped subjects stay live.
  348. #[tokio::test]
  349. async fn jwt_verify_live_rejects_disabled_subject() -> TestResult {
  350. let (signer, _) = test_signer()?;
  351. let (mut conn, store) = test_redis().await?;
  352. let (_db, pool) = migrated_pool().await?;
  353. let keys = test_keys()?;
  354. let params = Argon2Params::new(8, 1, 1, 32)?;
  355. let email = format!("jwt-subject-{}@example.test", now_unix());
  356. let record = create_user(
  357. &pool,
  358. &keys,
  359. false,
  360. &NewUser {
  361. email: email.clone(),
  362. password_hash: hash_secret("correct-horse-01", &params)?,
  363. is_admin: false,
  364. must_change_password: false,
  365. },
  366. )
  367. .await?;
  368. let claims = JwtClaims {
  369. sub: email.clone(),
  370. ..valid_claims(now_unix())
  371. };
  372. let token = signer.sign(&claims)?;
  373. write_marker(&mut conn, &store, &claims.jti, &email).await?;
  374. signer
  375. .verify_live(&mut conn, &store, &pool, &keys, &token, None)
  376. .await?;
  377. if !subject_active(&pool, &keys, "").await? {
  378. return Err("empty subject must stay live".into());
  379. }
  380. if !subject_active(&pool, &keys, "synthetic-subject").await? {
  381. return Err("unmapped subject must stay live".into());
  382. }
  383. if !disable_user(&pool, record.id).await? {
  384. return Err("disable_user reported no change".into());
  385. }
  386. match signer
  387. .verify_live(&mut conn, &store, &pool, &keys, &token, None)
  388. .await
  389. {
  390. Err(JwtError::SubjectDisabled) => Ok(()),
  391. Err(got) => Err(format!("expected SubjectDisabled, got {got:?}").into()),
  392. Ok(_) => Err("disabled subject token was accepted".into()),
  393. }
  394. }
  395. /// `Debug` on the signer and on every key shape carries no key material: the
  396. /// raw 32-byte scalars appear nowhere; only `kid`/`alg`/public coordinates.
  397. #[test]
  398. fn jwt_debug_redacts_key_material() -> TestResult {
  399. let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
  400. let previous = signing_key("v1", "P", JwtSigningAlg::Es256)?;
  401. let ed = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
  402. let signer = JwtAccessSigner::new(
  403. TEST_ISSUER.to_owned(),
  404. current.clone(),
  405. vec![previous.clone()],
  406. );
  407. for raw in ["C".repeat(32), "P".repeat(32), "E".repeat(32)] {
  408. for rendered in [
  409. format!("{signer:?}"),
  410. format!("{current:?}"),
  411. format!("{previous:?}"),
  412. format!("{ed:?}"),
  413. ] {
  414. if rendered.contains(&raw) {
  415. return Err("key material leaked into Debug".into());
  416. }
  417. }
  418. }
  419. let rendered = format!("{signer:?}");
  420. for marker in ["v2", "v1", "Es256"] {
  421. if !rendered.contains(marker) {
  422. return Err(format!("signer Debug lost {marker}").into());
  423. }
  424. }
  425. if !rendered.contains(current.public_jwk().x.as_str()) {
  426. return Err("signer Debug lost the public coordinates".into());
  427. }
  428. Ok(())
  429. }