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
//! M72 — id_token per-scope PII allowlist (OIDC Core §5.4 wire-side narrowing).
//!
//! The structural twin of `check_domain.rs::ALLOWED_CLAIMS` (M45 access-profile
//! allowlist), but **profile-aware**: the permitted set is derived from the
//! `S: ScopeSet` type witness via `S::names()`, not hardcoded. A token
//! verified at `Claims<Openid>` gets the base set; a token verified at
//! `Claims<Email>` admits `email` / `email_verified` additionally; etc.
//!
//! ── Why a runtime gate when accessor narrowing is already structural ────
//!
//! `claims.rs::pub(crate)` field privacy + scope-bounded `impl<S: HasEmail>
//! Claims<S>` accessor blocks already make `Claims<Openid>::email()` a
//! compile error. M72 closes the *deserialization* loop: without this gate,
//! a wire-borne `email` claim still populates the `pub(crate) email` field
//! on `Claims<Openid>`, where it sits silently — reachable via Debug,
//! serde-roundtrip, or any future accessor that forgets the bound. M72
//! refuses such tokens at parse time so the field is structurally
//! guaranteed to be `None` for an `S` that doesn't permit it.
//!
//! ── Strict-refuse semantics (β1) ────────────────────────────────────────
//!
//! Any payload key NOT in `S::names()` triggers `AuthError::UnknownClaim(key)`.
//! No silent strip, no leniency flag, no allowlist union with M45's access
//! set — the two profiles are claim-disjoint (M45 admits `client_id`, `cat`,
//! `active_ppnum` etc.; M72 forbids them on id_tokens). Mirroring M45's
//! shape gives audit logs a uniform `UnknownClaim(name)` signal across both
//! profiles; the carrying enum (id_token vs access_token AuthError) tells
//! the reader which profile rejected.
//!
//! ── Order in `verify` ───────────────────────────────────────────────────
//!
//! Fires AFTER `check_acr` (last typed gate) and BEFORE `deserialize_claims`.
//! Rationale: structural rejection of unknown claims happens before the
//! engine commits the wire shape into a typed `Claims<S>`. Specific-variant
//! rejects (M66-M71) get the precise audit signal first; only after every
//! typed check passes does the catch-net allowlist run.

use crate::id_token::AuthError;

/// Refuse any payload key not in `expected`. Returns the FIRST offending
/// claim name in the error variant so audit logs see exactly which key
/// tripped the gate.
///
/// `expected` is conventionally `S::names()` from the verify entry-point
/// — passing an empty slice is a misuse (would refuse every token) but
/// not a soundness issue, so it is not specially-cased.
///
/// Non-object payloads silently no-op: by the time this gate fires,
/// every prior submodule (`check_nonce`, `check_at_hash`, `check_c_hash`,
/// `check_azp`, `check_auth_time`, `check_acr`) has already read claims
/// from the payload as an object — a non-object would have surfaced as
/// the relevant `*Missing` variant earlier. Same defensive shape as
/// `check_domain::run` line 165.
pub(crate) fn run(
    payload: &serde_json::Value,
    expected: &[&str],
) -> Result<(), AuthError> {
    if let Some(object) = payload.as_object() {
        for key in object.keys() {
            if !expected.iter().any(|allowed| *allowed == key.as_str()) {
                return Err(AuthError::UnknownClaim(key.clone()));
            }
        }
    }
    Ok(())
}