| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526 |
- //! `test(jwt_)` suite: mint/verify round-trips, header discipline, rotation,
- //! allowlist markers, and disabled-subject rejection for AS-minted JWT
- //! access tokens (RFC 9068 profile subset).
- use std::sync::atomic::{AtomicU64, Ordering};
- use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
- use jsonwebtoken::{Algorithm, Header, decode_header, encode};
- use secrecy::SecretString;
- use time::OffsetDateTime;
- use super::{
- ActClaim, JwtAccessSigner, JwtClaims, JwtError, VerifiedJwt, jti_hash, marker_live,
- subject_active,
- };
- use crate::{
- config::{DurabilityMode, JwtSigningAlg, JwtSigningKey},
- crypto::secret::{Argon2Params, hash_secret},
- db::{
- test_support::migrated_pool,
- user_repo::{NewUser, create_user, disable_user},
- },
- keys::{KeyId, KeyRing},
- protocol::token::Cnf,
- state::RedisConn,
- store::Store,
- };
- /// Test body result (no `unwrap`/`expect` under the deny lints).
- type TestError = Box<dyn std::error::Error + Send + Sync>;
- type TestResult = Result<(), TestError>;
- /// Issuer every test signer asserts.
- const TEST_ISSUER: &str = "https://as.example.test";
- /// Access-token TTL stand-in: the round-trip pins `exp == iat + TTL`.
- const TEST_TTL: i64 = 3_600;
- static JTI_NONCE: AtomicU64 = AtomicU64::new(1);
- /// A `jti` no other test uses, so allowlist markers never collide.
- fn unique_jti() -> String {
- let n = JTI_NONCE.fetch_add(1, Ordering::Relaxed);
- format!("jwt-test-jti-{n}")
- }
- /// Current time as seconds since the Unix epoch.
- fn now_unix() -> i64 {
- OffsetDateTime::now_utc().unix_timestamp()
- }
- /// A validated signing key from a fixed 32-byte printable scalar.
- fn signing_key(kid: &str, seed: &str, alg: JwtSigningAlg) -> Result<JwtSigningKey, TestError> {
- let raw = SecretString::from(seed.repeat(32));
- Ok(JwtSigningKey::from_raw(
- KeyId::new(kid),
- alg,
- &raw,
- "jwt_signing_key",
- )?)
- }
- /// An ES256 signer with no previous keys.
- fn test_signer() -> Result<(JwtAccessSigner, JwtSigningKey), TestError> {
- let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
- let signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), current.clone(), Vec::new());
- Ok((signer, current))
- }
- /// A well-formed claim set; tests override fields via struct-update syntax.
- fn valid_claims(now: i64) -> JwtClaims {
- JwtClaims {
- iss: TEST_ISSUER.to_owned(),
- sub: "subject@example.test".to_owned(),
- aud: vec!["https://rs.example.test".to_owned()],
- scope: "orders:read orders:write".to_owned(),
- exp: now + TEST_TTL,
- iat: now,
- jti: unique_jti(),
- client_id: "test-client".to_owned(),
- cnf: None,
- act: None,
- }
- }
- /// Fresh connection plus store handle against the shared compose Redis.
- async fn test_redis() -> Result<(RedisConn, Store), TestError> {
- let manager = crate::test_support::redis_manager().await?;
- let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
- let store = Store::from_parts(state_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
- Ok((RedisConn::Single(manager), store))
- }
- /// Test PII key ring for the disabled-subject re-check.
- fn test_keys() -> Result<KeyRing, TestError> {
- Ok(KeyRing::from_parts(
- vec![(KeyId::new("v1"), SecretString::from("jwt-test-pepper"))],
- vec![(KeyId::new("v1"), SecretString::from("jwt-test-totp"))],
- vec![(KeyId::new("v1"), SecretString::from("jwt-test-pii"))],
- )?)
- }
- /// Write the `jwt:<jti-hash>` allowlist marker hash for `jti`.
- async fn write_marker(
- conn: &mut RedisConn,
- store: &Store,
- jti: &str,
- subject: &str,
- ) -> Result<(), TestError> {
- let key = store.jwt_marker_key(&jti_hash(jti));
- let hash = jti_hash(jti);
- redis::cmd("HSET")
- .arg(&key)
- .arg("grant_id")
- .arg("grant-1")
- .arg("client_id")
- .arg("test-client")
- .arg("subject")
- .arg(subject)
- .query_async::<()>(conn)
- .await?;
- redis::cmd("SADD")
- .arg(store.grant_tokens_key("grant-1"))
- .arg(format!("jwt:{hash}"))
- .query_async::<()>(conn)
- .await?;
- Ok(())
- }
- /// Assert a verification outcome is exactly the expected `JwtError`.
- fn assert_jwt_error(result: Result<VerifiedJwt, JwtError>, want: JwtError) -> TestResult {
- match result {
- Ok(_) => Err(format!("expected {want:?}, token was accepted").into()),
- Err(got) => {
- if got == want {
- Ok(())
- } else {
- Err(format!("expected {want:?}, got {got:?}").into())
- }
- }
- }
- }
- /// Sign with the configured key but a forged `typ: "JWT"` header: the
- /// signature is valid, yet verification must reject the type.
- #[test]
- fn jwt_verify_rejects_wrong_typ() -> TestResult {
- let (signer, key) = test_signer()?;
- let claims = valid_claims(now_unix());
- let mut header = Header::new(Algorithm::ES256);
- header.kid = Some(key.kid().as_str().to_owned());
- header.typ = Some("JWT".to_owned());
- let forged = encode(&header, &claims, key.encoding_key())?;
- assert_jwt_error(signer.verify(&forged, None), JwtError::WrongTyp)
- }
- /// A token whose `aud` lacks a required value is rejected, even when the
- /// signature and every other claim verify.
- #[test]
- fn jwt_verify_rejects_wrong_aud() -> TestResult {
- let (signer, _) = test_signer()?;
- let claims = valid_claims(now_unix());
- let token = signer.sign(&claims)?;
- assert_jwt_error(
- signer.verify(&token, Some(&["https://other.example.test".to_owned()])),
- JwtError::WrongAudience,
- )
- }
- /// Signature and claims valid, but no marker hash in Redis: the token is
- /// unusable (revoked, expired, or purged).
- #[tokio::test]
- async fn jwt_verify_rejects_unknown_marker() -> TestResult {
- let (signer, _) = test_signer()?;
- let (mut conn, store) = test_redis().await?;
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- let claims = JwtClaims {
- sub: String::new(),
- ..valid_claims(now_unix())
- };
- let token = signer.sign(&claims)?;
- if marker_live(&mut conn, &store, &jti_hash(&claims.jti)).await? {
- return Err("marker exists for a fresh jti".into());
- }
- match signer
- .verify_live(&mut conn, &store, &pool, &keys, &token, None)
- .await
- {
- Err(JwtError::MarkerAbsent) => Ok(()),
- Err(got) => Err(format!("expected MarkerAbsent, got {got:?}").into()),
- Ok(_) => Err("markerless token was accepted".into()),
- }
- }
- /// A token with no `cnf` (unbound bearer, e.g. client-credentials) verifies,
- /// and `cnf` round-trips to `None` on the wire.
- #[test]
- fn jwt_verify_accepts_absent_cnf() -> TestResult {
- let (signer, _) = test_signer()?;
- let claims = valid_claims(now_unix());
- let token = signer.sign(&claims)?;
- let verified = signer.verify(&token, None)?;
- if verified.claims.cnf.is_some() {
- return Err("absent cnf did not round-trip to None".into());
- }
- let segments: Vec<&str> = token.split('.').collect();
- if segments.len() != 3 {
- return Err("minted token is not three segments".into());
- }
- let payload: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[1])?)?;
- if payload.get("cnf").is_some() {
- return Err("absent cnf was serialized onto the wire".into());
- }
- let bound = JwtClaims {
- cnf: Some(Cnf {
- jkt: Some("test-thumbprint".to_owned()),
- x5t_s256: None,
- }),
- ..valid_claims(now_unix())
- };
- let bound_verified = signer.verify(&signer.sign(&bound)?, None)?;
- if bound_verified.claims.cnf != bound.cnf {
- return Err("present cnf did not survive the round-trip".into());
- }
- Ok(())
- }
- /// Mint-to-verify round-trip: header `typ`/`kid`/`alg`, `client_id`, `scope`,
- /// `aud`, `jti`, `act`, and `iat`/`exp` pinned to now plus the access TTL.
- #[test]
- fn jwt_sign_verify_round_trip() -> TestResult {
- let (signer, key) = test_signer()?;
- let now = now_unix();
- let claims = JwtClaims {
- act: Some(ActClaim {
- sub: "subject-client".to_owned(),
- }),
- ..valid_claims(now)
- };
- let token = signer.sign(&claims)?;
- let header = decode_header(&token)?;
- if header.typ.as_deref() != Some("at+jwt") {
- return Err("header typ is not at+jwt".into());
- }
- if header.kid.as_deref() != Some(key.kid().as_str()) {
- return Err("header kid is not the current key".into());
- }
- if header.alg != Algorithm::ES256 {
- return Err("header alg is not the configured algorithm".into());
- }
- let verified = signer.verify(
- &token,
- Some(&[
- "https://rs.example.test".to_owned(),
- "https://rs.example.test".to_owned(),
- ]),
- )?;
- if verified.claims != claims {
- return Err("claims did not survive the round-trip".into());
- }
- if verified.claims.iat != now || verified.claims.exp != now + TEST_TTL {
- return Err("iat/exp are not now plus the access TTL".into());
- }
- if verified.claims.client_id != "test-client" {
- return Err("client_id did not survive the round-trip".into());
- }
- Ok(())
- }
- /// Rotation: a token minted under the previous kid verifies once that kid is
- /// retained verify-only; the current kid still signs.
- #[test]
- fn jwt_verify_rotation_previous_kid_accepted() -> TestResult {
- let old = signing_key("v1", "P", JwtSigningAlg::Es256)?;
- let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
- let old_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), old.clone(), Vec::new());
- let rotated = JwtAccessSigner::new(TEST_ISSUER.to_owned(), current.clone(), vec![old.clone()]);
- let token = old_signer.sign(&valid_claims(now_unix()))?;
- let verified = rotated.verify(&token, None)?;
- if decode_header(&token)?.kid.as_deref() != Some("v1") {
- return Err("rotated token does not carry the previous kid".into());
- }
- if verified.claims.jti.is_empty() {
- return Err("rotated token claims are empty".into());
- }
- assert_jwt_error(
- JwtAccessSigner::new(TEST_ISSUER.to_owned(), current, Vec::new()).verify(&token, None),
- JwtError::UnknownKid,
- )
- }
- /// A single flipped signature character rejects with `Signature`.
- #[test]
- fn jwt_verify_rejects_tampered_signature() -> TestResult {
- let (signer, _) = test_signer()?;
- let token = signer.sign(&valid_claims(now_unix()))?;
- assert_jwt_error(
- signer.verify(&tamper_signature(&token)?, None),
- JwtError::Signature,
- )
- }
- /// An expired token is rejected with zero leeway.
- #[test]
- fn jwt_verify_rejects_expired() -> TestResult {
- let (signer, _) = test_signer()?;
- let now = now_unix();
- let claims = JwtClaims {
- iat: now - TEST_TTL - 20,
- exp: now - 10,
- ..valid_claims(now)
- };
- assert_jwt_error(
- signer.verify(&signer.sign(&claims)?, None),
- JwtError::Expired,
- )
- }
- /// Flip the first signature character: every base64url symbol there carries
- /// full data bits, so the token still parses and only the signature breaks.
- fn tamper_signature(token: &str) -> Result<String, TestError> {
- let segments: Vec<&str> = token.split('.').collect();
- if segments.len() != 3 {
- return Err("minted token is not three segments".into());
- }
- let mut sig = segments[2].to_owned();
- let first = sig.remove(0);
- sig.insert(0, if first == 'a' { 'b' } else { 'a' });
- Ok(format!("{}.{}.{}", segments[0], segments[1], sig))
- }
- /// A token minted for another issuer is rejected.
- #[test]
- fn jwt_verify_rejects_wrong_issuer() -> TestResult {
- let (signer, _) = test_signer()?;
- let claims = JwtClaims {
- iss: "https://other.example.test".to_owned(),
- ..valid_claims(now_unix())
- };
- assert_jwt_error(
- signer.verify(&signer.sign(&claims)?, None),
- JwtError::WrongIssuer,
- )
- }
- /// A header `kid` naming no current or previous key fails with no fallback.
- #[test]
- fn jwt_verify_rejects_unknown_kid() -> TestResult {
- let (signer, _) = test_signer()?;
- let stranger = signing_key("v9", "S", JwtSigningAlg::Es256)?;
- let stranger_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), stranger, Vec::new());
- let token = stranger_signer.sign(&valid_claims(now_unix()))?;
- assert_jwt_error(signer.verify(&token, None), JwtError::UnknownKid)
- }
- /// A token signed under another algorithm is rejected even with a valid
- /// signature for that algorithm: the header never picks the algorithm.
- #[test]
- fn jwt_verify_rejects_wrong_alg() -> TestResult {
- let (signer, _) = test_signer()?;
- let ed_key = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
- let ed_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), ed_key, Vec::new());
- let token = ed_signer.sign(&valid_claims(now_unix()))?;
- assert_jwt_error(signer.verify(&token, None), JwtError::WrongAlg)
- }
- /// EdDSA keys mint and verify under the same header discipline.
- #[test]
- fn jwt_eddsa_sign_verify_round_trip() -> TestResult {
- let key = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
- let signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), key.clone(), Vec::new());
- let token = signer.sign(&valid_claims(now_unix()))?;
- let header = decode_header(&token)?;
- if header.alg != Algorithm::EdDSA || header.typ.as_deref() != Some("at+jwt") {
- return Err("eddsa header does not carry alg EdDSA and typ at+jwt".into());
- }
- signer.verify(&token, None)?;
- Ok(())
- }
- /// Marker present, subject enabled: live. After `disable_user`: the same
- /// token reports `SubjectDisabled`. Empty and unmapped subjects stay live.
- #[tokio::test]
- async fn jwt_verify_live_rejects_disabled_subject() -> TestResult {
- let (signer, _) = test_signer()?;
- let (mut conn, store) = test_redis().await?;
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- let params = Argon2Params::new(8, 1, 1, 32)?;
- let email = format!("jwt-subject-{}@example.test", now_unix());
- let record = create_user(
- &pool,
- &keys,
- false,
- &NewUser {
- email: email.clone(),
- password_hash: hash_secret("correct-horse-01", ¶ms)?,
- is_admin: false,
- must_change_password: false,
- },
- )
- .await?;
- let claims = JwtClaims {
- sub: email.clone(),
- ..valid_claims(now_unix())
- };
- let token = signer.sign(&claims)?;
- write_marker(&mut conn, &store, &claims.jti, &email).await?;
- signer
- .verify_live(&mut conn, &store, &pool, &keys, &token, None)
- .await?;
- if !subject_active(&pool, &keys, "").await? {
- return Err("empty subject must stay live".into());
- }
- if !subject_active(&pool, &keys, "synthetic-subject").await? {
- return Err("unmapped subject must stay live".into());
- }
- if !disable_user(&pool, record.id).await? {
- return Err("disable_user reported no change".into());
- }
- match signer
- .verify_live(&mut conn, &store, &pool, &keys, &token, None)
- .await
- {
- Err(JwtError::SubjectDisabled) => Ok(()),
- Err(got) => Err(format!("expected SubjectDisabled, got {got:?}").into()),
- Ok(_) => Err("disabled subject token was accepted".into()),
- }
- }
- /// Strict liveness: a marker hash without grant membership is dead
- /// (`MarkerAbsent`), even though the marker-only check reads live.
- #[tokio::test]
- async fn jwt_verify_live_rejects_orphan_marker() -> TestResult {
- let (signer, _) = test_signer()?;
- let (mut conn, store) = test_redis().await?;
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- let claims = JwtClaims {
- sub: String::new(),
- ..valid_claims(now_unix())
- };
- let token = signer.sign(&claims)?;
- let hash = jti_hash(&claims.jti);
- let key = store.jwt_marker_key(&hash);
- redis::cmd("HSET")
- .arg(&key)
- .arg("grant_id")
- .arg("orphan-grant")
- .arg("client_id")
- .arg("test-client")
- .arg("subject")
- .arg("")
- .query_async::<()>(&mut conn)
- .await?;
- if !marker_live(&mut conn, &store, &hash).await? {
- return Err("orphan marker must read live on the marker-only check".into());
- }
- match signer
- .verify_live(&mut conn, &store, &pool, &keys, &token, None)
- .await
- {
- Err(JwtError::MarkerAbsent) => Ok(()),
- Err(got) => Err(format!("expected MarkerAbsent, got {got:?}").into()),
- Ok(_) => Err("orphan marker token was accepted".into()),
- }
- }
- /// `Debug` on the signer and on every key shape carries no key material: the
- /// raw 32-byte scalars appear nowhere; only `kid`/`alg`/public coordinates.
- #[test]
- fn jwt_debug_redacts_key_material() -> TestResult {
- let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
- let previous = signing_key("v1", "P", JwtSigningAlg::Es256)?;
- let ed = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
- let signer = JwtAccessSigner::new(
- TEST_ISSUER.to_owned(),
- current.clone(),
- vec![previous.clone()],
- );
- for raw in ["C".repeat(32), "P".repeat(32), "E".repeat(32)] {
- for rendered in [
- format!("{signer:?}"),
- format!("{current:?}"),
- format!("{previous:?}"),
- format!("{ed:?}"),
- ] {
- if rendered.contains(&raw) {
- return Err("key material leaked into Debug".into());
- }
- }
- }
- let rendered = format!("{signer:?}");
- for marker in ["v2", "v1", "Es256"] {
- if !rendered.contains(marker) {
- return Err(format!("signer Debug lost {marker}").into());
- }
- }
- if !rendered.contains(current.public_jwk().x.as_str()) {
- return Err("signer Debug lost the public coordinates".into());
- }
- Ok(())
- }
- /// `JwtClaims` Debug never prints the PII `sub` or the raw `jti` (the
- /// allowlist marker key material); public claim members stay visible.
- #[test]
- fn jwt_claims_debug_redacts_sub_and_jti() {
- let claims = JwtClaims {
- iss: "https://auth.example.test".to_owned(),
- sub: "user@example.test".to_owned(),
- aud: vec!["https://api.example.test".to_owned()],
- scope: "read".to_owned(),
- exp: 1_700_000_000,
- iat: 1_699_999_700,
- jti: "raw-jti-marker".to_owned(),
- client_id: "client-abc".to_owned(),
- cnf: None,
- act: None,
- };
- let rendered = format!("{claims:?}");
- assert!(!rendered.contains("user@example.test"));
- assert!(!rendered.contains("raw-jti-marker"));
- assert!(rendered.contains("[REDACTED]"));
- assert!(rendered.contains("client-abc"));
- }
|