#![cfg(feature = "rotation")]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
use jsonwebtoken::{EncodingKey, Header, encode};
use serde::{Deserialize, Serialize};
use tokenkit::service::{JwtAlgorithm, JwtConfig, JwtService, RotationKey};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct NumericClaims {
sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
exp: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
aud: Option<String>,
}
fn claims() -> NumericClaims {
NumericClaims {
sub: Some("user-1".to_string()),
exp: Some(chrono::Utc::now().timestamp() as u64 + 3600),
iss: Some("issuer".to_string()),
aud: None,
}
}
fn token_with_kid(secret: &str, kid: Option<&str>) -> String {
let mut header = Header::new(JwtAlgorithm::HS256.into());
header.kid = kid.map(str::to_string);
encode(
&header,
&claims(),
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}
fn rotated_service() -> JwtService {
JwtService::new(JwtConfig::with_rotation_keys(
JwtAlgorithm::HS256,
vec![
RotationKey::new("new-secret", Some("new-kid".to_string())),
RotationKey::new("old-secret", Some("old-kid".to_string())),
],
))
}
#[test]
fn encode_stamps_primary_key_id() {
let token = rotated_service().encode(&claims()).unwrap();
let header = jsonwebtoken::decode_header(&token).unwrap();
assert_eq!(header.kid.as_deref(), Some("new-kid"));
}
#[test]
fn kid_matching_rotation_key_verifies() {
let service = rotated_service();
let token = token_with_kid("old-secret", Some("old-kid"));
let decoded: NumericClaims = service.decode(&token).unwrap();
assert_eq!(decoded.sub.as_deref(), Some("user-1"));
}
#[test]
fn kid_pin_prevents_key_confusion() {
let service = rotated_service();
let forged = token_with_kid("new-secret", Some("old-kid"));
let decoded = service.decode::<NumericClaims>(&forged);
assert!(decoded.is_err());
let forged_other_way = token_with_kid("old-secret", Some("new-kid"));
assert!(service.decode::<NumericClaims>(&forged_other_way).is_err());
}
#[test]
fn unknown_kid_rejected_without_try_all() {
let service = rotated_service();
let token = token_with_kid("old-secret", Some("attacker-kid"));
let err = service
.decode::<NumericClaims>(&token)
.expect_err("unknown kid must not verify");
assert!(
matches!(&err, tokenkit::error::JwtError::DecodingFailed(msg) if msg.contains("attacker-kid")),
"{err:?}"
);
}
#[test]
fn no_kid_token_falls_back_to_try_all() {
let service = rotated_service();
let legacy = token_with_kid("old-secret", None);
let decoded: NumericClaims = service.decode(&legacy).unwrap();
assert_eq!(decoded.sub.as_deref(), Some("user-1"));
let primary = token_with_kid("new-secret", None);
let decoded: NumericClaims = service.decode(&primary).unwrap();
assert_eq!(decoded.sub.as_deref(), Some("user-1"));
let bad = token_with_kid("wrong-secret", None);
assert!(service.decode::<NumericClaims>(&bad).is_err());
}
#[test]
fn legacy_rotation_secrets_constructor_still_works() {
let service = JwtService::new(JwtConfig::with_rotation_secrets(
JwtAlgorithm::HS256,
vec!["first".to_string(), "second".to_string()],
));
let token = service.encode(&claims()).unwrap();
assert!(service.decode::<NumericClaims>(&token).is_ok());
let old = token_with_kid("second", None);
assert!(service.decode::<NumericClaims>(&old).is_ok());
let header = jsonwebtoken::decode_header(&token).unwrap();
assert_eq!(header.kid, None);
}
#[test]
fn rotation_key_debug_redacts_secret() {
let key = RotationKey::new("rotation-hush-hush-secret", Some("kid-1".to_string()));
let rendered = format!("{key:?}");
assert!(
rendered.contains("kid-1"),
"key_id is not secret: {rendered}"
);
assert!(
!rendered.contains("rotation-hush-hush-secret"),
"Debug must redact the signing secret: {rendered}"
);
}