ppoppo-token 0.3.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
Documentation
//! M69 — id_token `azp` (authorized party) binding (OIDC Core 1.0 §2).
//!
//! OIDC Core §2: when an ID Token has multiple audiences, the Client
//! SHOULD verify that an `azp` Claim is present; when `azp` is present,
//! it MUST contain the OAuth 2.0 Client ID of the party to which the
//! ID Token was issued.
//!
//! ── Phase 7 strictness — SHOULD elevated to MUST ────────────────────────
//!
//! Two MUSTs are in force here:
//!
//! 1. **Multi-aud REQUIRES azp**. The §2 SHOULD is upgraded to MUST per
//!    Phase 7 strict-by-default: a multi-audience id_token without `azp`
//!    is rejected with `AzpMissing`. Pinning this prevents a future
//!    drift where a multi-aud token slips through because no concrete
//!    client_id was asserted.
//! 2. **azp present ⇒ azp == client_id, regardless of aud cardinality**.
//!    The §2 wording "MUST contain the Client ID" is unconditional —
//!    a single-aud token with a mismatched `azp` is rejected. This
//!    catches the substitution case where an attacker forges azp on a
//!    single-aud token expecting the engine to ignore it. Pinning *strict
//!    when present* matches the Option D no-agility philosophy: the
//!    engine has no negotiation surface.
//!
//! ── Expected client_id source ───────────────────────────────────────────
//!
//! The expected client_id is reused from `cfg.shared.audience` (the
//! `aud` whitelist the RP filters on). Threading a separate
//! `expected_azp_client_id` field would invite drift between the aud
//! whitelist and the azp expectation; PAS issues to one client_id at a
//! time per request, and consumers (RCW/CTW) verify against their own
//! single client_id, so the implicit mapping is sound.
//!
//! ── Order in `id_token::verify` ─────────────────────────────────────────
//!
//! Runs after the hash-binding gates (M67 at_hash, M68 c_hash) and
//! before the freshness gate (M70 auth_time) and step-up gate (M71 acr).
//! Cardinality reasoning belongs with the client-binding axis, not with
//! the temporal axes.

use crate::id_token::{AuthError, VerifyConfig};

pub(crate) fn run(payload: &serde_json::Value, cfg: &VerifyConfig) -> Result<(), AuthError> {
    let aud_count = match payload.get("aud") {
        Some(serde_json::Value::Array(a)) => a.len(),
        Some(serde_json::Value::String(_)) => 1,
        _ => 0,
    };
    let azp = payload.get("azp").and_then(|v| v.as_str());
    let client_id = cfg.shared.audience.as_str();

    match (aud_count, azp) {
        // azp present but doesn't match — refuse regardless of aud cardinality
        // (§2 "MUST contain the Client ID"). Match-arm order matters: this
        // arm fires before the multi-aud-missing arm so that a forged
        // mismatched azp on a single-aud token cannot silently slip past.
        (_, Some(z)) if z != client_id => Err(AuthError::AzpMismatch),
        // multi-aud + azp absent — refuse (§2 SHOULD elevated to MUST).
        (n, None) if n > 1 => Err(AuthError::AzpMissing),
        // single-aud + azp absent: §2 silent on mandate; permit. Or
        // aud-absent: deferred to a future check_claims gate.
        // azp present and matches: accept.
        _ => Ok(()),
    }
}