//! OIDC ID token validation.
//!
//! Validates JWT structure, signature, and the standard OIDC claims
//! (issuer, audience, expiry, nonce). Two signature paths share the same
//! claim validation:
//!
//! * [`validate`](IdTokenValidator::validate) — HMAC (HS256 / HS512), for
//! providers that sign ID tokens with a shared client secret.
//! * [`validate_jwks`](IdTokenValidator::validate_jwks) — asymmetric
//! (`RS256` / `RS512` / `ES256` / `EdDSA`) against a provider's published
//! JWKS, the path used by Google, Microsoft, Okta, and most public identity
//! providers (requires the `asym-jwt` feature, enabled transitively by
//! `oidc`).
use core::fmt;
use crate::crypto::constant_time::constant_time_eq;
use crate::jwt::{JwtAlgorithm, JwtSignatureError, verify_jwt};
use crate::util::log::{debug, info, warn};
use crate::util::timestamp::Timestamp;
use super::claims::IdTokenClaims;
// ---------------------------------------------------------------------------
// Validator
// ---------------------------------------------------------------------------
/// OIDC ID token validator.
///
/// Validates an ID token JWT string by verifying its signature (HMAC via
/// [`validate`](Self::validate) or asymmetric/JWKS via
/// [`validate_jwks`](Self::validate_jwks)) and checking the standard OIDC
/// claims (issuer, audience, expiry, and optionally nonce).
///
/// # Scope
///
/// This validates ID tokens obtained through the **authorization-code flow**,
/// where the token is fetched directly from the token endpoint over TLS. It
/// does not verify the `at_hash` / `c_hash` claims, which bind an ID token to
/// an access token or code delivered through the front channel and are only
/// required for the hybrid and implicit flows (OIDC Core §3.2.2.11 /
/// §3.3.2.11). Do not use this validator for front-channel-delivered tokens.
///
/// # Example
///
/// ```no_run
/// use entropy_auth::oidc::IdTokenValidator;
/// use entropy_auth::jwt::JwkSet;
///
/// // Public IdP (e.g. Google): verify the RS256 signature against the
/// // provider's JWKS, then the standard claims.
/// # fn run(jwks_json: &str, id_token: &str) -> Result<(), Box<dyn std::error::Error>> {
/// let validator = IdTokenValidator::new("https://accounts.google.com", "my-client-id");
/// let jwks = JwkSet::parse(jwks_json)?;
/// let claims = validator.validate_jwks(id_token, &jwks, Some("nonce-value"))?;
/// # let _ = claims;
/// # Ok(())
/// # }
/// ```
#[doc(alias = "id_token_validator")]
#[must_use]
pub struct IdTokenValidator {
issuer: String,
audience: String,
clock_skew_secs: u64,
/// Allowlist of acceptable header `alg` values. Empty = accept any
/// algorithm the verification key supports.
allowed_algs: Vec<JwtAlgorithm>,
}
impl IdTokenValidator {
/// Creates a new validator for the given issuer and audience (client ID).
///
/// # Security
///
/// `issuer` MUST be the operator's trusted, exact issuer identifier; the
/// token's `iss` claim is compared against it byte-for-byte. Pass the
/// value from a validated discovery document
/// ([`OidcDiscovery::issuer`](crate::oidc::OidcDiscovery::issuer), which is
/// HTTPS- and syntax-checked) or a hard-coded constant — never an
/// unvalidated string from the token or an untrusted channel. This
/// constructor does not itself re-validate the issuer's scheme/syntax.
pub fn new(issuer: &str, audience: &str) -> Self {
Self {
issuer: issuer.to_string(),
audience: audience.to_string(),
clock_skew_secs: 60,
allowed_algs: Vec::new(),
}
}
/// Restricts the accepted signature algorithms to `algs` (OIDC Core
/// §3.1.3.7 step 7).
///
/// By default the validator accepts whatever algorithm the matched key
/// supports. The key's type already prevents cross-family confusion (an
/// RSA key cannot verify an `ES256` token), but a provider that publishes
/// keys usable for more than one algorithm leaves the choice to the
/// attacker-supplied header. Pinning the allowlist (e.g. `&[RS256]`) closes
/// that within-family gap. An empty allowlist (the default) accepts any
/// algorithm the key supports.
///
/// Applies to both [`validate`](Self::validate) (HMAC) and
/// [`validate_jwks`](Self::validate_jwks) (asymmetric).
pub fn with_allowed_algs(mut self, algs: &[JwtAlgorithm]) -> Self {
self.allowed_algs = algs.to_vec();
self
}
/// Rejects a token whose header `alg` is not in the configured allowlist.
/// A no-op when the allowlist is empty (accept any).
fn check_alg(&self, header: &crate::jwt::JwtHeader) -> Result<(), IdTokenError> {
if self.allowed_algs.is_empty() || self.allowed_algs.contains(&header.alg()) {
Ok(())
} else {
warn!("oidc: ID token validation failed (algorithm not allowed)");
Err(IdTokenError {
kind: IdTokenErrorKind::DisallowedAlgorithm,
})
}
}
/// Sets the clock skew tolerance in seconds (default: 60).
///
/// Tokens whose `exp` claim is within this many seconds in the past
/// are still considered valid, to account for clock drift between the
/// identity provider and the relying party.
pub fn with_clock_skew(mut self, seconds: u64) -> Self {
self.clock_skew_secs = seconds;
self
}
/// Validates an ID token JWT string with an HMAC key.
///
/// Performs the following checks in order:
/// 1. JWT signature verification (HMAC-SHA256 or HMAC-SHA512).
/// 2. Issuer (`iss`) matches the expected issuer.
/// 3. Audience (`aud`) contains the expected client ID.
/// 4. Authorized party (`azp`): if present it must equal the client
/// ID, and it must be present when `aud` lists multiple audiences
/// (OIDC Core §3.1.3.7 steps 5-6).
/// 5. Token has not expired (`exp`) and is not used before its
/// not-before (`nbf`), each accounting for clock skew.
/// 6. Nonce matches (if `expected_nonce` is provided).
///
/// # Errors
///
/// Returns [`IdTokenError`] if any validation step fails.
pub fn validate(
&self,
token: &str,
key: &[u8],
expected_nonce: Option<&str>,
) -> Result<IdTokenClaims, IdTokenError> {
// SECURITY: Never log the token string, key, or raw claim values.
debug!(
issuer = %self.issuer,
audience = %self.audience,
"oidc: validating ID token"
);
let (header, jwt_claims) = Self::verify_signature(token, key)?;
self.check_alg(&header)?;
let claims = IdTokenClaims::from_jwt_claims(&jwt_claims).map_err(|e| {
let claim_name = e.claim_name().to_string();
IdTokenError {
kind: IdTokenErrorKind::MissingClaim(claim_name),
}
})?;
self.validate_claims(&claims, &jwt_claims, expected_nonce)?;
// SECURITY: do not log `sub` (or any claim value) — it is a stable
// user identifier (PII).
info!("oidc: ID token validated");
Ok(claims)
}
/// Validates an ID token against a provider's JWKS (asymmetric
/// signatures: `RS256`, `RS512`, `ES256`, `EdDSA`), selecting the
/// verification key by the token's `kid` header.
///
/// This is the path for ID tokens from public OIDC providers (Google,
/// Microsoft, Okta) which sign with asymmetric keys published at a JWKS
/// endpoint. The signature is verified against `jwks`; then the same
/// standard OIDC claims as [`validate`](Self::validate) — issuer,
/// audience, authorized party (`azp`), expiry/not-before, and
/// (optionally) nonce — are checked.
///
/// # Security
///
/// The signing algorithm is bound by the JWKS key the `kid` selects (a
/// key carries its algorithm family), so cross-family algorithm confusion
/// is not possible. To additionally pin the token's `alg` to a subset of a
/// provider's advertised
/// [`id_token_signing_alg_values_supported`](crate::oidc::OidcDiscovery::id_token_signing_alg_values_supported)
/// (OIDC Core §3.1.3.7 step 7) — closing the within-family case where a
/// key is usable for more than one algorithm — configure
/// [`with_allowed_algs`](Self::with_allowed_algs).
///
/// # Errors
///
/// Returns [`IdTokenError`] if no key in `jwks` matches the token's
/// `kid`, the signature is invalid, or any claim check fails.
#[cfg(feature = "asym-jwt")]
pub fn validate_jwks(
&self,
token: &str,
jwks: &crate::jwt::JwkSet,
expected_nonce: Option<&str>,
) -> Result<IdTokenClaims, IdTokenError> {
// SECURITY: Never log the token string or raw claim values.
debug!(
issuer = %self.issuer,
audience = %self.audience,
"oidc: validating ID token via JWKS"
);
let (header, jwt_claims) = jwks
.verify(token)
.map_err(|e| Self::map_signature_error(&e))?;
self.check_alg(&header)?;
let claims = IdTokenClaims::from_jwt_claims(&jwt_claims).map_err(|e| IdTokenError {
kind: IdTokenErrorKind::MissingClaim(e.claim_name().to_string()),
})?;
self.validate_claims(&claims, &jwt_claims, expected_nonce)?;
// SECURITY: do not log `sub` (or any claim value) — PII.
info!("oidc: ID token validated (JWKS)");
Ok(claims)
}
/// Verifies the JWT signature (HMAC) and maps errors to [`IdTokenError`].
fn verify_signature(
token: &str,
key: &[u8],
) -> Result<(crate::jwt::JwtHeader, crate::jwt::JwtClaims), IdTokenError> {
verify_jwt(token, key).map_err(|e| Self::map_signature_error(&e))
}
/// Maps a [`JwtSignatureError`] to the vague public [`IdTokenError`].
fn map_signature_error(e: &JwtSignatureError) -> IdTokenError {
// NOTE: Use structured query methods instead of matching on Display
// output — the error message text is not part of the public API
// contract and may change between releases.
if e.is_invalid_signature() || e.is_invalid_signature_encoding() {
warn!("oidc: ID token validation failed (invalid signature)");
IdTokenError {
kind: IdTokenErrorKind::InvalidSignature,
}
} else {
warn!("oidc: ID token validation failed (invalid JWT)");
IdTokenError {
kind: IdTokenErrorKind::InvalidJwt,
}
}
}
/// Validates issuer, audience, expiry, not-before, and nonce claims.
fn validate_claims(
&self,
claims: &IdTokenClaims,
jwt_claims: &crate::jwt::JwtClaims,
expected_nonce: Option<&str>,
) -> Result<(), IdTokenError> {
// Validate issuer.
if claims.iss() != self.issuer {
warn!("oidc: ID token validation failed (issuer mismatch)");
return Err(IdTokenError {
kind: IdTokenErrorKind::InvalidIssuer,
});
}
// Validate audience.
if !claims.aud().iter().any(|a| a == &self.audience) {
warn!("oidc: ID token validation failed (audience mismatch)");
return Err(IdTokenError {
kind: IdTokenErrorKind::InvalidAudience,
});
}
// OIDC Core §3.1.3.7 steps 5-6 (authorized party):
// * step 6 — if an `azp` claim is present, it MUST identify this
// relying party, *regardless of how many audiences are listed*.
// A token minted for a different client (with that client named
// in `azp`) that merely also lists this client in `aud` must be
// rejected (cross-RP token reuse).
// * step 5 — when more than one audience is listed, `azp` MUST be
// present at all.
match claims.azp() {
Some(azp) if azp != self.audience => {
warn!("oidc: ID token validation failed (azp mismatch)");
return Err(IdTokenError {
kind: IdTokenErrorKind::InvalidAuthorizedParty,
});
}
None if claims.aud().len() > 1 => {
warn!("oidc: ID token validation failed (azp required for multi-audience token)");
return Err(IdTokenError {
kind: IdTokenErrorKind::InvalidAuthorizedParty,
});
}
_ => {}
}
// Validate expiry and not-before with clock skew.
let now = Timestamp::now().unix_epoch_secs();
if !jwt_claims.validate_exp(now, self.clock_skew_secs) {
warn!("oidc: ID token validation failed (expired)");
return Err(IdTokenError {
kind: IdTokenErrorKind::ExpiredToken,
});
}
// OIDC Core §3.1.3.7 step 9: reject a token whose `nbf` is still in
// the future (absent `nbf` validates as true).
if !jwt_claims.validate_nbf(now, self.clock_skew_secs) {
warn!("oidc: ID token validation failed (not yet valid)");
return Err(IdTokenError {
kind: IdTokenErrorKind::NotYetValid,
});
}
// SECURITY: Use constant-time comparison to prevent timing
// side-channel attacks on the nonce value.
if let Some(expected) = expected_nonce {
match claims.nonce() {
Some(nonce) if constant_time_eq(nonce.as_bytes(), expected.as_bytes()) => {}
Some(_) => {
warn!("oidc: ID token validation failed (nonce mismatch)");
return Err(IdTokenError {
kind: IdTokenErrorKind::InvalidNonce,
});
}
None => {
warn!("oidc: ID token validation failed (missing nonce)");
return Err(IdTokenError {
kind: IdTokenErrorKind::MissingNonce,
});
}
}
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/// The category of ID token validation failure.
#[derive(Debug, Clone, PartialEq, Eq)]
enum IdTokenErrorKind {
/// The JWT structure is malformed or unparseable.
InvalidJwt,
/// The JWT signature does not match.
InvalidSignature,
/// The token has expired (accounting for clock skew).
ExpiredToken,
/// The `nbf` claim is in the future (accounting for clock skew).
NotYetValid,
/// The `iss` claim does not match the expected issuer.
InvalidIssuer,
/// The `aud` claim does not contain the expected audience.
InvalidAudience,
/// The token has multiple audiences but its `azp` claim is missing or
/// does not identify this relying party.
InvalidAuthorizedParty,
/// A nonce was expected but the token does not contain one.
MissingNonce,
/// The nonce in the token does not match the expected value.
InvalidNonce,
/// A required OIDC claim is missing from the token.
MissingClaim(String),
/// The header `alg` is not in the configured allowlist
/// (OIDC Core §3.1.3.7 step 7).
DisallowedAlgorithm,
}
/// Error returned when OIDC ID token validation fails.
///
/// Error messages are deliberately vague to avoid leaking information
/// about which validation step failed to a potential attacker.
#[doc(alias = "id_token_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdTokenError {
kind: IdTokenErrorKind,
}
impl fmt::Display for IdTokenError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
IdTokenErrorKind::InvalidJwt => {
write!(f, "oidc id token: invalid JWT")
}
IdTokenErrorKind::InvalidSignature => {
write!(f, "oidc id token: signature verification failed")
}
IdTokenErrorKind::ExpiredToken => {
write!(f, "oidc id token: token has expired")
}
IdTokenErrorKind::NotYetValid => {
write!(f, "oidc id token: token not yet valid")
}
IdTokenErrorKind::InvalidIssuer => {
write!(f, "oidc id token: issuer mismatch")
}
IdTokenErrorKind::InvalidAudience => {
write!(f, "oidc id token: audience mismatch")
}
IdTokenErrorKind::InvalidAuthorizedParty => {
write!(f, "oidc id token: authorized party (azp) mismatch")
}
IdTokenErrorKind::MissingNonce => {
write!(f, "oidc id token: missing nonce")
}
IdTokenErrorKind::InvalidNonce => {
write!(f, "oidc id token: nonce mismatch")
}
IdTokenErrorKind::MissingClaim(claim) => {
write!(f, "oidc id token: missing required claim '{claim}'")
}
IdTokenErrorKind::DisallowedAlgorithm => {
write!(f, "oidc id token: signature algorithm not allowed")
}
}
}
}
impl std::error::Error for IdTokenError {}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{HmacSha256, HmacSha512};
use crate::encoding::base64url_encode;
const TEST_KEY: &[u8] = b"super-secret-key-for-oidc-testing";
const ISSUER: &str = "https://accounts.example.com";
const AUDIENCE: &str = "my-client-id";
/// Helper: creates a signed JWT token string.
fn make_jwt(header_json: &str, claims_json: &str, key: &[u8], alg: &str) -> String {
let header_b64 = base64url_encode(header_json.as_bytes());
let payload_b64 = base64url_encode(claims_json.as_bytes());
let signing_input = format!("{header_b64}.{payload_b64}");
let sig = match alg {
"HS256" => {
let mac = HmacSha256::mac(key, signing_input.as_bytes());
base64url_encode(&mac)
}
"HS512" => {
let mac = HmacSha512::mac(key, signing_input.as_bytes());
base64url_encode(&mac)
}
_ => String::new(),
};
format!("{header_b64}.{payload_b64}.{sig}")
}
/// Helper: creates a valid ID token with standard claims.
fn make_valid_id_token(nonce: Option<&str>) -> String {
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
let nonce_field = match nonce {
Some(n) => format!(r#", "nonce": "{n}""#),
None => String::new(),
};
let claims = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-123",
"aud": "{AUDIENCE}",
"exp": 9999999999,
"iat": 1699999000,
"email": "user@example.com",
"email_verified": true,
"name": "Test User"{nonce_field}
}}"#,
);
make_jwt(header, &claims, TEST_KEY, "HS256")
}
#[test]
fn validate_valid_token() {
let token = make_valid_id_token(None);
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let claims = validator.validate(&token, TEST_KEY, None).unwrap();
assert_eq!(claims.iss(), ISSUER);
assert_eq!(claims.sub(), "user-123");
assert_eq!(claims.aud(), [AUDIENCE]);
assert_eq!(claims.email(), Some("user@example.com"));
assert_eq!(claims.email_verified(), Some(true));
assert_eq!(claims.name(), Some("Test User"));
}
#[test]
fn allowed_algs_accepts_listed_algorithm() {
let token = make_valid_id_token(None);
let validator =
IdTokenValidator::new(ISSUER, AUDIENCE).with_allowed_algs(&[JwtAlgorithm::HS256]);
validator.validate(&token, TEST_KEY, None).unwrap();
}
#[test]
fn allowed_algs_rejects_unlisted_algorithm() {
// The token is HS256, but the validator only permits HS512.
let token = make_valid_id_token(None);
let validator =
IdTokenValidator::new(ISSUER, AUDIENCE).with_allowed_algs(&[JwtAlgorithm::HS512]);
let err = validator.validate(&token, TEST_KEY, None).unwrap_err();
assert!(
err.to_string().contains("algorithm not allowed"),
"got: {err}"
);
}
#[test]
fn validate_valid_token_with_nonce() {
let token = make_valid_id_token(Some("my-nonce-value"));
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let claims = validator
.validate(&token, TEST_KEY, Some("my-nonce-value"))
.unwrap();
assert_eq!(claims.nonce(), Some("my-nonce-value"));
}
#[test]
fn validate_valid_token_hs512() {
let header = r#"{"alg":"HS512","typ":"JWT"}"#;
let claims_json = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-456",
"aud": "{AUDIENCE}",
"exp": 9999999999,
"iat": 1699999000
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS512");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let claims = validator.validate(&token, TEST_KEY, None).unwrap();
assert_eq!(claims.sub(), "user-456");
}
#[test]
fn reject_expired_token() {
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
// exp = 1000 (far in the past).
let claims_json = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-1",
"aud": "{AUDIENCE}",
"exp": 1000,
"iat": 500
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE).with_clock_skew(0);
let err = validator.validate(&token, TEST_KEY, None).unwrap_err();
assert!(err.to_string().contains("expired"), "got: {err}");
}
#[test]
fn reject_not_yet_valid_token() {
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
// exp far in the future, but nbf is also far in the future so the
// token is not yet valid.
let claims_json = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-1",
"aud": "{AUDIENCE}",
"exp": 9999999999,
"iat": 9999999000,
"nbf": 9999999000
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE).with_clock_skew(0);
let err = validator.validate(&token, TEST_KEY, None).unwrap_err();
assert!(err.to_string().contains("not yet valid"), "got: {err}");
}
#[test]
fn reject_wrong_issuer() {
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
let claims_json = format!(
r#"{{
"iss": "https://evil.example.com",
"sub": "user-1",
"aud": "{AUDIENCE}",
"exp": 9999999999,
"iat": 1000
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator.validate(&token, TEST_KEY, None).unwrap_err();
assert!(err.to_string().contains("issuer"), "got: {err}");
}
#[test]
fn reject_wrong_audience() {
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
let claims_json = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-1",
"aud": "wrong-client-id",
"exp": 9999999999,
"iat": 1000
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator.validate(&token, TEST_KEY, None).unwrap_err();
assert!(err.to_string().contains("audience"), "got: {err}");
}
#[test]
fn accept_multi_audience_with_valid_azp() {
// OIDC Core §3.1.3.7: multiple audiences are allowed when `azp`
// identifies this relying party.
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
let claims_json = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-1",
"aud": ["{AUDIENCE}", "other-client"],
"azp": "{AUDIENCE}",
"exp": 9999999999,
"iat": 1000
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
assert!(validator.validate(&token, TEST_KEY, None).is_ok());
}
#[test]
fn reject_multi_audience_missing_azp() {
// A token issued for several audiences without `azp` must be rejected
// even though this client appears in `aud` (cross-RP reuse defense).
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
let claims_json = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-1",
"aud": ["{AUDIENCE}", "other-client"],
"exp": 9999999999,
"iat": 1000
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator.validate(&token, TEST_KEY, None).unwrap_err();
assert!(err.to_string().contains("authorized party"), "got: {err}");
}
#[test]
fn reject_multi_audience_wrong_azp() {
// `azp` present but pointing at a different client.
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
let claims_json = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-1",
"aud": ["{AUDIENCE}", "other-client"],
"azp": "other-client",
"exp": 9999999999,
"iat": 1000
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator.validate(&token, TEST_KEY, None).unwrap_err();
assert!(err.to_string().contains("authorized party"), "got: {err}");
}
#[test]
fn accept_single_audience_without_azp() {
// The common case: one audience, no `azp` required.
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
let claims_json = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-1",
"aud": "{AUDIENCE}",
"exp": 9999999999,
"iat": 1000
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
assert!(validator.validate(&token, TEST_KEY, None).is_ok());
}
#[test]
fn reject_single_audience_wrong_azp() {
// OIDC Core §3.1.3.7 step 6: a present `azp` must equal this RP's
// client ID even when only one audience is listed. This guards the
// single-aud replay case where a token minted for another client
// (azp=other) merely also names this RP in `aud`.
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
let claims_json = format!(
r#"{{
"iss": "{ISSUER}",
"sub": "user-1",
"aud": "{AUDIENCE}",
"azp": "other-client",
"exp": 9999999999,
"iat": 1000
}}"#,
);
let token = make_jwt(header, &claims_json, TEST_KEY, "HS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator.validate(&token, TEST_KEY, None).unwrap_err();
assert!(err.to_string().contains("authorized party"), "got: {err}");
}
#[test]
fn reject_asymmetric_alg_on_hmac_path() {
// SECURITY (alg-confusion): an `RS256` token presented to the HMAC
// `validate` entry point must be rejected as an invalid JWT, never
// HMAC-verified with the RSA public key treated as a shared secret.
let header = r#"{"alg":"RS256","typ":"JWT"}"#;
let claims_json = format!(
r#"{{"iss":"{ISSUER}","sub":"u","aud":"{AUDIENCE}","exp":9999999999,"iat":1000}}"#,
);
// Signature bytes are irrelevant — the algorithm check fails first.
let token = make_jwt(header, &claims_json, TEST_KEY, "RS256");
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator.validate(&token, TEST_KEY, None).unwrap_err();
assert!(err.to_string().contains("JWT"), "got: {err}");
}
#[test]
fn reject_missing_nonce() {
let token = make_valid_id_token(None);
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator
.validate(&token, TEST_KEY, Some("expected-nonce"))
.unwrap_err();
assert!(err.to_string().contains("nonce"), "got: {err}");
}
#[test]
fn reject_wrong_nonce() {
let token = make_valid_id_token(Some("actual-nonce"));
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator
.validate(&token, TEST_KEY, Some("expected-nonce"))
.unwrap_err();
assert!(err.to_string().contains("nonce"), "got: {err}");
}
#[test]
fn reject_invalid_signature() {
let token = make_valid_id_token(None);
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator.validate(&token, b"wrong-key", None).unwrap_err();
assert!(err.to_string().contains("signature"), "got: {err}");
}
#[test]
fn reject_malformed_jwt() {
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator.validate("not-a-jwt", TEST_KEY, None).unwrap_err();
assert!(err.to_string().contains("JWT"), "got: {err}");
}
#[test]
fn clock_skew_builder() {
let validator = IdTokenValidator::new(ISSUER, AUDIENCE).with_clock_skew(120);
// Verify the builder returns a validator (compiles and runs).
assert_eq!(validator.clock_skew_secs, 120);
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(IdTokenError {
kind: IdTokenErrorKind::InvalidJwt,
});
let _ = err.to_string();
}
#[test]
fn error_display_all_variants() {
let cases = [
(IdTokenErrorKind::InvalidJwt, "invalid JWT"),
(IdTokenErrorKind::InvalidSignature, "signature"),
(IdTokenErrorKind::ExpiredToken, "expired"),
(IdTokenErrorKind::InvalidIssuer, "issuer"),
(IdTokenErrorKind::InvalidAudience, "audience"),
(IdTokenErrorKind::MissingNonce, "missing nonce"),
(IdTokenErrorKind::InvalidNonce, "nonce mismatch"),
(
IdTokenErrorKind::MissingClaim("test".to_string()),
"missing required claim",
),
];
for (kind, expected_substr) in cases {
let err = IdTokenError { kind };
assert!(
err.to_string().contains(expected_substr),
"expected '{expected_substr}' in '{err}'",
);
}
}
// --- Asymmetric (JWKS) path: RS256, Google-shaped token ---
#[cfg(feature = "asym-jwt")]
mod jwks {
use super::super::IdTokenValidator;
use crate::jwt::JwkSet;
// Same 2048-bit RSA known-answer vector as `jwt::asymmetric`/`jwt::jwks`:
// a JWKS entry plus an RS256 ID token (iss=accounts.google.com,
// aud=konsole-client, exp far in the future) it signed.
const JWKS: &str = r#"{"keys":[{"kty":"RSA","alg":"RS256","use":"sig","kid":"rsa-test-1","e":"AQAB","n":"l12KvkYdWWq2IwpT4kSOh-eC0kIGQzD4AgRAQ2WZY6-RC0m5X3yolmLIwCzH4CJhq1vm7mhG76RgvXoC2VlP7B2nHlz8-wPhk33Re4ia-Z4J6E_aIFn56Y5t01tv2N52rcijgS1Drvkqo2VvO9HWjBdpKi7cpW-lwG0fPYWQ0ibv1IrALZV66Qkp6QMT_wPtgbEYoeocMkSb7URUQFuFqL4BnucW7s8rQ1bFsw7oZ-_uQx_3JN0d3FQgJC2PUe-k2A7U8srQjKI26y3rKkNOe8n7LMUVFEj1rEbSn_OxEiLfUrjIMIJHikWd1QWH8uIpULs8ExeezpOgaeNQOUvdOw"}]}"#;
const TOKEN: &str = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJzYS10ZXN0LTEifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoia29uc29sZS1jbGllbnQiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MTcwMDAwMDAwMCwiZW1haWwiOiJ1c2VyQGVudHJvcHlzb2Z0d29ya3MuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJUZXN0IFVzZXIifQ.GSt8PH2tF-Yd-O9qLZGXgMgXX8NISLN3svmYC245mACNYnHCPm5ottQMsVgXl5ux3BbMrWG1LeX4hLES9Ip7djmDJBuSsmT6XMKGLuOre5GokgFCLCXFsAwz_F3GSSSOKcRRb13-nuBdPnG919SYbS0E0mvD0eSzbmWHdUci5br8QsZQWwJryf9u0RGx3ZdMer2BXT0Qj4bJI1D4s1CK4T7z7jxk1PHeXvZ7w0GOdutJ5VG2jfgPjqfq07RxyBvSRJ_j549t30pmVMite6vnqbvv9673wX_-pN3VLwqR1QXRgaeyZYN3LoUCS1bmBw5kaw6tVCBGPADUz6VRvyUgiA";
const ISSUER: &str = "https://accounts.google.com";
const AUDIENCE: &str = "konsole-client";
#[test]
fn validate_jwks_accepts_valid_rs256_token() {
let jwks = JwkSet::parse(JWKS).unwrap();
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let claims = validator.validate_jwks(TOKEN, &jwks, None).unwrap();
assert_eq!(claims.sub(), "1234567890");
assert_eq!(claims.email(), Some("user@entropysoftworks.com"));
assert_eq!(claims.email_verified(), Some(true));
}
#[test]
fn validate_jwks_rejects_wrong_audience() {
let jwks = JwkSet::parse(JWKS).unwrap();
let validator = IdTokenValidator::new(ISSUER, "different-client");
let err = validator.validate_jwks(TOKEN, &jwks, None).unwrap_err();
assert!(err.to_string().contains("audience"), "got: {err}");
}
#[test]
fn validate_jwks_rejects_wrong_issuer() {
let jwks = JwkSet::parse(JWKS).unwrap();
let validator = IdTokenValidator::new("https://evil.example.com", AUDIENCE);
let err = validator.validate_jwks(TOKEN, &jwks, None).unwrap_err();
assert!(err.to_string().contains("issuer"), "got: {err}");
}
#[test]
fn validate_jwks_rejects_tampered_signature() {
// Flip the final signature character: the RS256 signature no
// longer verifies, so the OIDC layer must surface an error
// (mapped to InvalidSignature) rather than accepting the token.
let jwks = JwkSet::parse(JWKS).unwrap();
let last = TOKEN.chars().last().unwrap();
let flipped = if last == 'A' { 'B' } else { 'A' };
let tampered = format!("{}{flipped}", &TOKEN[..TOKEN.len() - 1]);
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
assert!(validator.validate_jwks(&tampered, &jwks, None).is_err());
}
#[test]
fn validate_jwks_rejects_alg_none() {
// An `alg:none` token (unsigned) must never validate on the
// JWKS path, regardless of matching kid.
use crate::encoding::base64url_encode;
let header = base64url_encode(br#"{"alg":"none","typ":"JWT","kid":"rsa-test-1"}"#);
let claims = base64url_encode(
br#"{"iss":"https://accounts.google.com","sub":"x","aud":"konsole-client","exp":9999999999,"iat":1000}"#,
);
let unsigned = format!("{header}.{claims}.");
let jwks = JwkSet::parse(JWKS).unwrap();
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
assert!(validator.validate_jwks(&unsigned, &jwks, None).is_err());
}
#[test]
fn validate_jwks_allowed_algs_pins_within_family() {
use crate::jwt::JwtAlgorithm;
let jwks = JwkSet::parse(JWKS).unwrap();
// The token is RS256. Permitting only RS512 rejects it even though
// the key would verify it (within-family pinning, §3.1.3.7 step 7).
let validator =
IdTokenValidator::new(ISSUER, AUDIENCE).with_allowed_algs(&[JwtAlgorithm::RS512]);
assert!(validator.validate_jwks(TOKEN, &jwks, None).is_err());
// Permitting RS256 accepts it.
let validator =
IdTokenValidator::new(ISSUER, AUDIENCE).with_allowed_algs(&[JwtAlgorithm::RS256]);
validator.validate_jwks(TOKEN, &jwks, None).unwrap();
}
#[test]
fn validate_jwks_rejects_missing_nonce() {
// The token carries no `nonce`; requiring one must reject on the
// JWKS path too (the nonce check lives in the shared
// `validate_claims`, but pin it here against the asymmetric path).
let jwks = JwkSet::parse(JWKS).unwrap();
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
let err = validator
.validate_jwks(TOKEN, &jwks, Some("expected-nonce"))
.unwrap_err();
assert!(err.to_string().contains("nonce"), "got: {err}");
}
#[test]
fn validate_jwks_rejects_unknown_signing_key() {
// A JWKS that does not contain the token's kid → no matching key.
let other = r#"{"keys":[{"kty":"RSA","alg":"RS256","kid":"nope","e":"AQAB","n":"l12KvkYdWWq2IwpT4kSOh-eC0kIGQzD4AgRAQ2WZY6-RC0m5X3yolmLIwCzH4CJhq1vm7mhG76RgvXoC2VlP7B2nHlz8-wPhk33Re4ia-Z4J6E_aIFn56Y5t01tv2N52rcijgS1Drvkqo2VvO9HWjBdpKi7cpW-lwG0fPYWQ0ibv1IrALZV66Qkp6QMT_wPtgbEYoeocMkSb7URUQFuFqL4BnucW7s8rQ1bFsw7oZ-_uQx_3JN0d3FQgJC2PUe-k2A7U8srQjKI26y3rKkNOe8n7LMUVFEj1rEbSn_OxEiLfUrjIMIJHikWd1QWH8uIpULs8ExeezpOgaeNQOUvdOw"}]}"#;
let jwks = JwkSet::parse(other).unwrap();
let validator = IdTokenValidator::new(ISSUER, AUDIENCE);
assert!(validator.validate_jwks(TOKEN, &jwks, None).is_err());
}
}
}