use super::*;
use axess_factors::jwt::verifier::JwtVerifier;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct CustomClaims {}
#[tokio::test]
async fn historical_key_still_verifies_under_combined_jwks() {
let now = Utc::now();
let k_old = LocalIdpSigningKey::generate_rsa().with_key_id("k-old");
let k_new = LocalIdpSigningKey::generate_rsa().with_key_id("k-new");
let old_idp = LocalIdpFixture::with_signing_key("https://idp", k_old.clone());
let token_under_old = old_idp.mint(
&MintClaims::new("alice", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
let rotated_idp =
LocalIdpFixture::with_signing_key("https://idp", k_new).with_historical_signing_key(k_old);
let token_under_new = rotated_idp.mint(
&MintClaims::new("alice", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
assert_eq!(
jsonwebtoken::decode_header(&token_under_old).unwrap().kid,
Some("k-old".to_string()),
);
assert_eq!(
jsonwebtoken::decode_header(&token_under_new).unwrap().kid,
Some("k-new".to_string()),
);
assert_eq!(rotated_idp.jwks().keys.len(), 2);
let verifier = JwtVerifier::new(rotated_idp.jwks_handle()).with_audience("https://api");
verifier
.verify::<CustomClaims>(&token_under_old)
.await
.expect("historical-kid token must verify against multi-key JWKS");
verifier
.verify::<CustomClaims>(&token_under_new)
.await
.expect("current-kid token must verify against multi-key JWKS");
}
#[tokio::test]
async fn rotate_signing_key_moves_previous_current_to_historical() {
let now = Utc::now();
let k1 = LocalIdpSigningKey::generate_rsa().with_key_id("k1");
let k2 = LocalIdpSigningKey::generate_rsa().with_key_id("k2");
let idp_v1 = LocalIdpFixture::with_signing_key("https://idp", k1);
let token_v1 = idp_v1.mint(
&MintClaims::new("alice", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
let idp_v2 = idp_v1.rotate_signing_key(k2);
assert_eq!(idp_v2.key_id(), "k2");
assert_eq!(idp_v2.jwks().keys.len(), 2);
let token_v2 = idp_v2.mint(
&MintClaims::new("alice", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
assert_eq!(
jsonwebtoken::decode_header(&token_v2).unwrap().kid,
Some("k2".to_string()),
);
let verifier = JwtVerifier::new(idp_v2.jwks_handle()).with_audience("https://api");
verifier
.verify::<CustomClaims>(&token_v1)
.await
.expect("token from before rotation must still verify");
verifier
.verify::<CustomClaims>(&token_v2)
.await
.expect("token from after rotation must verify");
}
#[tokio::test]
async fn multiple_rotations_accumulate_historical_keys() {
let now = Utc::now();
let k1 = LocalIdpSigningKey::generate_rsa().with_key_id("k1");
let k2 = LocalIdpSigningKey::generate_rsa().with_key_id("k2");
let k3 = LocalIdpSigningKey::generate_rsa().with_key_id("k3");
let idp = LocalIdpFixture::with_signing_key("https://idp", k1);
let t1 = idp.mint(
&MintClaims::new("alice", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
let idp = idp.rotate_signing_key(k2);
let t2 = idp.mint(
&MintClaims::new("alice", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
let idp = idp.rotate_signing_key(k3);
let t3 = idp.mint(
&MintClaims::new("alice", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
assert_eq!(
idp.jwks().keys.len(),
3,
"three keys: k3 current, k1+k2 historical"
);
let verifier = JwtVerifier::new(idp.jwks_handle()).with_audience("https://api");
for (label, token) in [("t1", &t1), ("t2", &t2), ("t3", &t3)] {
verifier
.verify::<CustomClaims>(token)
.await
.unwrap_or_else(|e| panic!("{label} must verify after two rotations: {e:?}"));
}
}
#[tokio::test]
async fn extra_public_jwk_appears_in_jwks_but_not_used_for_signing() {
let now = Utc::now();
let foreign_key = LocalIdpSigningKey::generate_rsa().with_key_id("foreign");
let foreign_jwk = foreign_key.jwk().clone();
let foreign_idp = LocalIdpFixture::with_signing_key("https://foreign-idp", foreign_key);
let foreign_token = foreign_idp.mint(
&MintClaims::new("bob", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
let our_idp = LocalIdpFixture::new("https://our-idp").with_extra_public_jwk(foreign_jwk);
assert_eq!(our_idp.jwks().keys.len(), 2);
let our_token = our_idp.mint(
&MintClaims::new("alice", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
let verifier = JwtVerifier::new(our_idp.jwks_handle()).with_audience("https://api");
verifier
.verify::<CustomClaims>(&our_token)
.await
.expect("our token must verify");
verifier
.verify::<CustomClaims>(&foreign_token)
.await
.expect("foreign-IdP token must verify against extra JWK");
}
#[tokio::test]
async fn with_key_id_preserves_historical_keys_and_extra_jwks() {
let now = Utc::now();
let k_old = LocalIdpSigningKey::generate_rsa().with_key_id("old");
let foreign_jwk = LocalIdpSigningKey::generate_rsa()
.with_key_id("foreign")
.jwk()
.clone();
let idp = LocalIdpFixture::new("https://idp")
.with_historical_signing_key(k_old)
.with_extra_public_jwk(foreign_jwk)
.with_key_id("renamed-current");
assert_eq!(idp.key_id(), "renamed-current");
assert_eq!(idp.jwks().keys.len(), 3, "current + historical + extra");
let kids: Vec<&str> = idp
.jwks()
.keys
.iter()
.filter_map(|k| k.common.key_id.as_deref())
.collect();
assert!(kids.contains(&"renamed-current"));
assert!(kids.contains(&"old"));
assert!(kids.contains(&"foreign"));
let token = idp.mint(
&MintClaims::new("alice", now + Duration::hours(1))
.with_audience("https://api")
.with_issued_at(now),
);
assert_eq!(
jsonwebtoken::decode_header(&token).unwrap().kid,
Some("renamed-current".to_string()),
);
}