#![allow(clippy::unwrap_used, clippy::expect_used)]
use base64::Engine as _;
use jsonwebtoken::{DecodingKey, EncodingKey, Header};
use ppoppo_token::id_token::{
verify,
scopes::{Email, EmailProfile, EmailProfilePhone, EmailProfilePhoneAddress, Openid, Profile},
AuthError, Nonce, VerifyConfig,
};
use ppoppo_token::KeySet;
use serde_json::json;
use sha2::{Digest, Sha256};
const TEST_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIG+00IvEd4uv6IWtGFVUEBVdqnXiuI/ESQHu6rmcDvAs
-----END PRIVATE KEY-----
";
const TEST_PUBLIC_KEY_PEM: &[u8] = b"-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=
-----END PUBLIC KEY-----
";
const TEST_KID: &str = "k4.test.0";
const ISSUER: &str = "https://accounts.ppoppo.com";
const AUDIENCE: &str = "rp-client-1234";
const TEST_SUB_ULID: &str = "01HSAB00000000000000000000";
fn test_keyset() -> KeySet {
let mut ks = KeySet::new();
let dec = DecodingKey::from_ed_pem(TEST_PUBLIC_KEY_PEM).expect("public PEM");
ks.insert(TEST_KID, dec);
ks
}
fn forge_id_token(payload: &serde_json::Value) -> String {
let mut header = Header::new(jsonwebtoken::Algorithm::EdDSA);
header.kid = Some(TEST_KID.to_string());
header.typ = Some("JWT".to_string());
let enc = EncodingKey::from_ed_pem(TEST_PRIVATE_KEY_PEM).expect("private PEM");
jsonwebtoken::encode(&header, payload, &enc).expect("encode")
}
fn cfg(expected_nonce: &str) -> VerifyConfig {
VerifyConfig::id_token(
ISSUER,
AUDIENCE,
Nonce::new(expected_nonce).expect("non-empty test nonce"),
)
}
fn base_payload() -> serde_json::Value {
let now = time::OffsetDateTime::now_utc().unix_timestamp();
json!({
"iss": ISSUER,
"sub": TEST_SUB_ULID,
"aud": AUDIENCE,
"exp": now + 600,
"iat": now,
"cat": "id",
})
}
#[tokio::test]
async fn cat_id_passes_via_base_payload() {
const NONCE: &str = "happy-path-nonce";
let mut payload = base_payload();
payload
.as_object_mut()
.unwrap()
.insert("nonce".into(), serde_json::Value::String(NONCE.into()));
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert!(result.is_ok(), "M29-mirror: cat=\"id\" must round-trip");
}
#[tokio::test]
async fn cat_access_returns_cat_mismatch_substitution_signal() {
const NONCE: &str = "substitution-test-nonce";
let mut payload = base_payload();
payload
.as_object_mut()
.unwrap()
.insert("nonce".into(), serde_json::Value::String(NONCE.into()));
payload
.as_object_mut()
.unwrap()
.insert("cat".into(), serde_json::Value::String("access".into()));
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::CatMismatch("access".into())));
}
#[tokio::test]
async fn cat_absent_returns_cat_mismatch_with_empty_audit() {
const NONCE: &str = "absent-cat-test-nonce";
let mut payload = base_payload();
payload
.as_object_mut()
.unwrap()
.insert("nonce".into(), serde_json::Value::String(NONCE.into()));
payload.as_object_mut().unwrap().remove("cat");
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::CatMismatch(String::new())));
}
#[tokio::test]
async fn cat_check_fires_before_nonce_check() {
let mut payload = base_payload();
payload.as_object_mut().unwrap().remove("cat");
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg("nonce-irrelevant"), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(
result,
Err(AuthError::CatMismatch(String::new())),
"M29-mirror MUST fire before M66 nonce so the audit signal points at \
the profile-routing failure first"
);
}
#[tokio::test]
async fn nonce_missing_returns_nonce_missing() {
let mut payload = base_payload();
payload.as_object_mut().unwrap().remove("nonce");
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg("expected-nonce-abc"), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::NonceMissing));
}
#[tokio::test]
async fn nonce_mismatch_returns_nonce_mismatch() {
let mut payload = base_payload();
payload.as_object_mut().unwrap().insert(
"nonce".into(),
serde_json::Value::String("token-side-nonce".into()),
);
let token = forge_id_token(&payload);
let result =
verify::<Openid>(&token, &cfg("rp-stored-nonce-different"), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::NonceMismatch));
}
#[tokio::test]
async fn nonce_present_and_matches_passes() {
const NONCE: &str = "shared-nonce-value-xyz";
let mut payload = base_payload();
payload
.as_object_mut()
.unwrap()
.insert("nonce".into(), serde_json::Value::String(NONCE.into()));
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
let claims = result.expect("M66 happy-path must accept");
assert_eq!(claims.iss, ISSUER);
assert_eq!(claims.sub, TEST_SUB_ULID);
assert_eq!(claims.nonce, NONCE);
}
#[test]
fn empty_nonce_construction_returns_nonce_config_empty() {
let result = Nonce::new("");
assert_eq!(result.unwrap_err(), AuthError::NonceConfigEmpty);
}
const NONCE: &str = "binding-test-nonce";
const ACCESS_TOKEN: &str = "fake.access.token.three.dots";
const AUTH_CODE: &str = "oauth2-authorization-code-test";
fn compute_hash_binding(subject: &[u8]) -> String {
let digest = Sha256::digest(subject);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&digest[..16])
}
fn payload_with_nonce() -> serde_json::Value {
let mut p = base_payload();
p.as_object_mut()
.unwrap()
.insert("nonce".into(), serde_json::Value::String(NONCE.into()));
p
}
#[tokio::test]
async fn at_hash_missing_returns_at_hash_missing() {
let token = forge_id_token(&payload_with_nonce());
let cfg = cfg(NONCE).with_access_token_binding(ACCESS_TOKEN);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::AtHashMissing));
}
#[tokio::test]
async fn at_hash_mismatch_returns_at_hash_mismatch() {
let mut payload = payload_with_nonce();
payload.as_object_mut().unwrap().insert(
"at_hash".into(),
serde_json::Value::String(compute_hash_binding(b"some-other-access-token")),
);
let token = forge_id_token(&payload);
let cfg = cfg(NONCE).with_access_token_binding(ACCESS_TOKEN);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::AtHashMismatch));
}
#[tokio::test]
async fn at_hash_present_and_matches_passes() {
let mut payload = payload_with_nonce();
payload.as_object_mut().unwrap().insert(
"at_hash".into(),
serde_json::Value::String(compute_hash_binding(ACCESS_TOKEN.as_bytes())),
);
let token = forge_id_token(&payload);
let cfg = cfg(NONCE).with_access_token_binding(ACCESS_TOKEN);
let claims = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp())
.await
.expect("M67 happy path must accept");
assert_eq!(claims.iss, ISSUER);
assert_eq!(claims.nonce, NONCE);
}
#[tokio::test]
async fn at_hash_ignored_when_binding_unset_even_if_bogus_in_payload() {
let mut payload = payload_with_nonce();
payload.as_object_mut().unwrap().insert(
"at_hash".into(),
serde_json::Value::String("totally-bogus-not-a-real-hash".into()),
);
let token = forge_id_token(&payload);
let cfg = cfg(NONCE);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert!(result.is_ok(), "M67 must skip when binding is unset");
}
#[tokio::test]
async fn c_hash_missing_returns_c_hash_missing() {
let token = forge_id_token(&payload_with_nonce());
let cfg = cfg(NONCE).with_authorization_code_binding(AUTH_CODE);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::CHashMissing));
}
#[tokio::test]
async fn c_hash_mismatch_returns_c_hash_mismatch() {
let mut payload = payload_with_nonce();
payload.as_object_mut().unwrap().insert(
"c_hash".into(),
serde_json::Value::String(compute_hash_binding(b"a-different-code")),
);
let token = forge_id_token(&payload);
let cfg = cfg(NONCE).with_authorization_code_binding(AUTH_CODE);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::CHashMismatch));
}
#[tokio::test]
async fn c_hash_present_and_matches_passes() {
let mut payload = payload_with_nonce();
payload.as_object_mut().unwrap().insert(
"c_hash".into(),
serde_json::Value::String(compute_hash_binding(AUTH_CODE.as_bytes())),
);
let token = forge_id_token(&payload);
let cfg = cfg(NONCE).with_authorization_code_binding(AUTH_CODE);
let claims = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp())
.await
.expect("M68 happy path must accept");
assert_eq!(claims.nonce, NONCE);
}
#[tokio::test]
async fn azp_missing_when_aud_multi_returns_azp_missing() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("aud".into(), json!([AUDIENCE, "sibling-client-9999"]));
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::AzpMissing));
}
#[tokio::test]
async fn azp_mismatch_when_aud_multi_returns_azp_mismatch() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("aud".into(), json!([AUDIENCE, "sibling-client-9999"]));
payload
.as_object_mut()
.unwrap()
.insert("azp".into(), json!("not-the-rp-client-id"));
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::AzpMismatch));
}
#[tokio::test]
async fn azp_present_and_matches_client_id_passes() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("aud".into(), json!([AUDIENCE, "sibling-client-9999"]));
payload
.as_object_mut()
.unwrap()
.insert("azp".into(), json!(AUDIENCE));
let token = forge_id_token(&payload);
let claims = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp())
.await
.expect("M69 happy path must accept");
assert_eq!(claims.azp.as_deref(), Some(AUDIENCE));
}
#[tokio::test]
async fn azp_absent_when_aud_single_passes() {
let token = forge_id_token(&payload_with_nonce());
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert!(
result.is_ok(),
"M69 must permit single-aud token without azp (§2 silent)"
);
}
#[tokio::test]
async fn azp_mismatch_when_aud_single_returns_azp_mismatch() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("azp".into(), json!("sibling-client-9999"));
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::AzpMismatch));
}
#[tokio::test]
async fn auth_time_stale_returns_auth_time_stale() {
let mut payload = payload_with_nonce();
let now = time::OffsetDateTime::now_utc().unix_timestamp();
payload
.as_object_mut()
.unwrap()
.insert("auth_time".into(), json!(now - 600));
let token = forge_id_token(&payload);
let cfg = cfg(NONCE).with_max_age(300);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::AuthTimeStale));
}
#[tokio::test]
async fn auth_time_missing_when_max_age_set_returns_auth_time_missing() {
let token = forge_id_token(&payload_with_nonce());
let cfg = cfg(NONCE).with_max_age(300);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::AuthTimeMissing));
}
#[tokio::test]
async fn auth_time_within_window_passes() {
let mut payload = payload_with_nonce();
let now = time::OffsetDateTime::now_utc().unix_timestamp();
payload
.as_object_mut()
.unwrap()
.insert("auth_time".into(), json!(now - 60));
let token = forge_id_token(&payload);
let cfg = cfg(NONCE).with_max_age(300);
let claims = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp())
.await
.expect("M70 happy path must accept fresh auth_time");
assert_eq!(claims.auth_time, Some(now - 60));
}
#[tokio::test]
async fn auth_time_ignored_when_max_age_unset() {
let token = forge_id_token(&payload_with_nonce());
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert!(
result.is_ok(),
"M70 must skip freshness check when max_age is unset"
);
}
const ACR_SILVER: &str = "urn:mace:incommon:iap:silver";
const ACR_BRONZE: &str = "urn:mace:incommon:iap:bronze";
#[tokio::test]
async fn acr_not_in_values_returns_acr_not_allowed() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("acr".into(), json!(ACR_BRONZE));
let token = forge_id_token(&payload);
let cfg = cfg(NONCE).with_acr_values(vec![ACR_SILVER.to_string()]);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::AcrNotAllowed));
}
#[tokio::test]
async fn acr_missing_when_values_set_returns_acr_missing() {
let token = forge_id_token(&payload_with_nonce());
let cfg = cfg(NONCE).with_acr_values(vec![ACR_SILVER.to_string()]);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::AcrMissing));
}
#[tokio::test]
async fn acr_in_values_passes() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("acr".into(), json!(ACR_SILVER));
let token = forge_id_token(&payload);
let cfg = cfg(NONCE).with_acr_values(vec![
ACR_SILVER.to_string(),
ACR_BRONZE.to_string(),
]);
let claims = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp())
.await
.expect("M71 happy path must accept allowed acr");
assert_eq!(claims.acr.as_deref(), Some(ACR_SILVER));
}
#[tokio::test]
async fn acr_ignored_when_values_unset_even_if_payload_carries_one() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("acr".into(), json!("some-arbitrary-acr-value"));
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert!(
result.is_ok(),
"M71 must skip step-up check when acr_values is unset"
);
}
#[tokio::test]
async fn acr_case_sensitive_uppercase_rejected_against_lowercase_allowlist() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("acr".into(), json!("URN:MACE:INCOMMON:IAP:SILVER"));
let token = forge_id_token(&payload);
let cfg = cfg(NONCE).with_acr_values(vec![ACR_SILVER.to_string()]);
let result = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(
result,
Err(AuthError::AcrNotAllowed),
"case-folding would silently admit a downgrade — must compare with =="
);
}
#[tokio::test]
async fn unknown_claim_at_openid_scope_returns_unknown_claim() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("email".into(), json!("victim@example.com"));
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(result, Err(AuthError::UnknownClaim("email".into())));
}
#[tokio::test]
async fn email_claim_at_email_scope_passes() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("email".into(), json!("user@example.com"));
payload
.as_object_mut()
.unwrap()
.insert("email_verified".into(), json!(true));
let token = forge_id_token(&payload);
let claims = verify::<Email>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp())
.await
.expect("Email scope must admit email + email_verified");
assert_eq!(claims.email(), "user@example.com");
assert_eq!(claims.email_verified(), Some(true));
}
#[tokio::test]
async fn unknown_synthetic_claim_returns_unknown_claim() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("attacker_injected_field".into(), json!("payload"));
let token = forge_id_token(&payload);
let result = verify::<Openid>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
assert_eq!(
result,
Err(AuthError::UnknownClaim("attacker_injected_field".into())),
);
}
#[tokio::test]
async fn fabricated_backdoor_claim_rejected_at_every_scope() {
let mut payload = payload_with_nonce();
payload
.as_object_mut()
.unwrap()
.insert("backdoor".into(), json!("attacker-controlled"));
let token = forge_id_token(&payload);
let cfg = cfg(NONCE);
let expected = AuthError::UnknownClaim("backdoor".into());
assert_eq!(
verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await,
Err(expected.clone()),
"Openid scope must refuse backdoor",
);
assert_eq!(
verify::<Email>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await,
Err(expected.clone()),
"Email scope must refuse backdoor",
);
assert_eq!(
verify::<Profile>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await,
Err(expected.clone()),
"Profile scope must refuse backdoor",
);
assert_eq!(
verify::<EmailProfile>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await,
Err(expected.clone()),
"EmailProfile scope must refuse backdoor",
);
assert_eq!(
verify::<EmailProfilePhone>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await,
Err(expected.clone()),
"EmailProfilePhone scope must refuse backdoor",
);
assert_eq!(
verify::<EmailProfilePhoneAddress>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await,
Err(expected),
"EmailProfilePhoneAddress scope must refuse backdoor",
);
}
#[tokio::test]
async fn maximal_scope_with_all_pii_passes() {
let mut payload = payload_with_nonce();
let obj = payload.as_object_mut().unwrap();
obj.insert("email".into(), json!("u@example.com"));
obj.insert("email_verified".into(), json!(true));
obj.insert("name".into(), json!("Test User"));
obj.insert("given_name".into(), json!("Test"));
obj.insert("family_name".into(), json!("User"));
obj.insert("phone_number".into(), json!("+15555555555"));
obj.insert("phone_number_verified".into(), json!(false));
obj.insert(
"address".into(),
json!({"locality": "Seoul", "country": "KR"}),
);
let token = forge_id_token(&payload);
let claims = verify::<EmailProfilePhoneAddress>(&token, &cfg(NONCE), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp())
.await
.expect("maximal scope must admit full PII bundle");
assert_eq!(claims.email(), "u@example.com");
assert_eq!(claims.name(), Some("Test User"));
assert_eq!(claims.phone_number(), Some("+15555555555"));
assert_eq!(
claims.address().and_then(|a| a.country.as_deref()),
Some("KR"),
);
}
#[tokio::test]
async fn hybrid_flow_at_hash_and_c_hash_both_pass() {
let mut payload = payload_with_nonce();
payload.as_object_mut().unwrap().insert(
"at_hash".into(),
serde_json::Value::String(compute_hash_binding(ACCESS_TOKEN.as_bytes())),
);
payload.as_object_mut().unwrap().insert(
"c_hash".into(),
serde_json::Value::String(compute_hash_binding(AUTH_CODE.as_bytes())),
);
let token = forge_id_token(&payload);
let cfg = cfg(NONCE)
.with_access_token_binding(ACCESS_TOKEN)
.with_authorization_code_binding(AUTH_CODE);
let claims = verify::<Openid>(&token, &cfg, &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp())
.await
.expect("hybrid flow must accept when both hashes match");
assert_eq!(claims.nonce, NONCE);
}