ppoppo-token 0.38.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
//! OIDC id_token verification entry-point.
//!
//! Mirrors `access_token::verify` but narrows the result type to
//! `Claims<S>` per scope marker.
//!
//! ── Pipeline ────────────────────────────────────────────────────────────
//!
//! JOSE wire format (M01-M16a, M31-M34, M15, structural) → M75 signature →
//! M74 `cat` → registered claims (M17-M18, M20-M25, M28 —
//! `check_id_token_claims`) →
//! M66-M71 bindings → M72 per-scope allowlist. PII fields populate from the
//! wire once M72 has admitted them; the type system narrows visibility at
//! the *accessor* layer (`Claims<scopes::Openid>::email()` does not
//! compile).

use std::marker::PhantomData;

use super::scopes::ScopeSet;
use super::{AddressClaim, AuthError, Claims, VerifyConfig};
use crate::KeySet;
use crate::engine::check_id_token_claims::{self, RegisteredClaims};
use crate::engine::shared_error::SharedAuthError;
use crate::engine::{
    check_acr, check_algorithm, check_at_hash, check_auth_time, check_azp, check_c_hash,
    check_header, check_id_token_cat, check_id_token_pii, check_nonce, check_signature, raw,
};

/// Verify a JWS Compact-serialized id_token against the configured policy.
///
/// `S: ScopeSet` is the type-level scope witness. The engine constructs
/// `Claims<S>` regardless of the token's actual `scope` claim — narrowing
/// landed in M72 (Phase 10.8) will refuse a `Claims<EmailProfile>`
/// construction when the token's scope is just `openid email`. For 10.1
/// the type bound is the sole guard, enforced syntactically by the
/// scope-bounded accessor `impl` blocks in `claims.rs`.
pub async fn verify<S: ScopeSet>(
    token: &str,
    cfg: &VerifyConfig,
    key_set: &KeySet,
    now: i64,
) -> Result<Claims<S>, AuthError> {
    // M34: total token size cap. Mirrors `access_token::verify`.
    if token.len() > cfg.shared.max_token_size {
        return Err(AuthError::Jose(SharedAuthError::OversizedToken));
    }

    // M31: reject JWS JSON serialization.
    if token.starts_with('{') {
        return Err(AuthError::Jose(SharedAuthError::JwsJsonRejected));
    }

    // M15: structural — JWE compact has 5 segments, JWS has 3.
    if token.split('.').count() == 5 {
        return Err(AuthError::Jose(SharedAuthError::JwePayload));
    }

    // M01-M06 algorithm + M07-M16a header (typ pinned to `JWT` via
    // SharedVerifyConfig). The shared engine pipeline reads only
    // `cfg.shared` — the id-token-specific axes (`expected_nonce`,
    // `max_age`, `acr_values`) never reach the wire-format layer.
    check_algorithm::run(token, &cfg.shared)?;
    let key = check_header::run(token, &cfg.shared, key_set)?;
    // M75: the signature is verified before any claim is read.
    check_signature::run(token, key)?;

    // M32 + M33 fire inside `parse_payload_json`. From here on, claim
    // semantics are id_token-specific.
    let payload = raw::parse_payload_json(token)?;

    // M29-mirror (Phase 10.10) — id_token `cat` profile-routing assertion.
    // Fires before any typed gate so a wrong-profile token short-circuits
    // here rather than spending cycles on M66/M67/etc. Symmetric to
    // access-token's M29 in `engine::check_claims::run` lines 137-140.
    // See `engine::check_id_token_cat` for the full rationale.
    check_id_token_cat::run(&payload)?;

    // M17-M18, M20-M25, M28 — registered claims (OIDC Core §3.1.3.7 steps 2, 3, 9,
    // 10), before any id_token-specific gate: M69 below reasons over `aud`
    // cardinality, so `aud` must already be known to name this RP.
    let registered = check_id_token_claims::run(&payload, cfg, now)?;

    // M66 — nonce binding (OIDC Core §3.1.3.7 step 11).
    check_nonce::run(&payload, &cfg.expected_nonce)?;

    // M67 — at_hash binding (OIDC Core §3.1.3.8). Opt-in: skipped when
    // `cfg.expected_access_token` is None. Hybrid + implicit flows
    // populate this; pure code flow leaves it unset.
    check_at_hash::run(&payload, cfg)?;

    // M68 — c_hash binding (OIDC Core §3.3.2.11). Opt-in: skipped when
    // `cfg.expected_authorization_code` is None. Hybrid flow populates
    // this. Order: at_hash before c_hash matches the OIDC Core §3.3.2.11
    // narrative (token-then-code) and gives audit logs a stable
    // first-failure axis when both happen to be wrong.
    check_c_hash::run(&payload, cfg)?;

    // M69 — azp (authorized party) binding (OIDC Core §2). Always-on:
    // multi-aud requires azp; azp present must equal client_id
    // regardless of aud cardinality. Sources expected client_id from
    // `cfg.shared.audience` (no separate field — single-tenant model).
    check_azp::run(&payload, cfg)?;

    // M70 — auth_time freshness (OIDC Core §3.1.3.7). Opt-in: skipped
    // when `cfg.max_age` is None. Once opted in, missing `auth_time`
    // is a hard refusal (no implicit fallback to `now`).
    check_auth_time::run(&payload, cfg, now)?;

    // M71 — acr step-up assertion (OIDC Core §3.1.3.7, §5.5.1.1).
    // Opt-in: skipped when `cfg.acr_values` is None. Case-sensitive `==`
    // (URN values are case-sensitive; case-folding would silently admit
    // step-up downgrades).
    check_acr::run(&payload, cfg)?;

    // M72 — per-scope PII allowlist (OIDC Core §5.4 wire-side narrowing).
    // Always-on: the entire `Claims<S>` shape exists to enforce per-scope
    // narrowing, so unlike M70/M71 there is no "skip-if-unset" path.
    // Fires AFTER every typed gate so specific-variant rejects (M66-M71)
    // get the precise audit signal first; the allowlist is the catch-net.
    check_id_token_pii::run(&payload, S::names())?;

    Ok(deserialize_claims::<S>(
        &payload,
        registered,
        cfg.expected_nonce.as_str(),
    ))
}

/// Build `Claims<S>` from a payload every gate has passed. Required fields
/// come from the gates that validated them — the registered claims from
/// `check_id_token_claims`, `nonce` from M66's equality with the RP's copy —
/// so none has a fallback. Optional claims are read as present. PII fields
/// populate from the wire once M72 has admitted them — visibility narrowing
/// is at the accessor layer (`Claims<S>::email()` requires `S: HasEmail`).
fn deserialize_claims<S: ScopeSet>(
    payload: &serde_json::Value,
    registered: RegisteredClaims,
    nonce: &str,
) -> Claims<S> {
    let s = |key: &str| payload.get(key).and_then(|v| v.as_str()).map(str::to_owned);
    let i = |key: &str| payload.get(key).and_then(serde_json::Value::as_i64);
    let b = |key: &str| payload.get(key).and_then(serde_json::Value::as_bool);

    let RegisteredClaims {
        iss,
        sub,
        aud,
        exp,
        iat,
    } = registered;

    let amr = payload.get("amr").and_then(|v| v.as_array()).map(|a| {
        a.iter()
            .filter_map(|v| v.as_str().map(str::to_owned))
            .collect()
    });

    let address = payload
        .get("address")
        .and_then(|v| serde_json::from_value::<AddressClaim>(v.clone()).ok());

    Claims {
        iss,
        sub,
        aud,
        exp,
        iat,
        nonce: nonce.to_owned(),
        azp: s("azp"),
        auth_time: i("auth_time"),
        acr: s("acr"),
        amr,
        email: s("email"),
        email_verified: b("email_verified"),
        name: s("name"),
        given_name: s("given_name"),
        family_name: s("family_name"),
        middle_name: s("middle_name"),
        nickname: s("nickname"),
        preferred_username: s("preferred_username"),
        profile: s("profile"),
        picture: s("picture"),
        website: s("website"),
        gender: s("gender"),
        birthdate: s("birthdate"),
        zoneinfo: s("zoneinfo"),
        locale: s("locale"),
        updated_at: i("updated_at"),
        phone_number: s("phone_number"),
        phone_number_verified: b("phone_number_verified"),
        address,
        _scope: PhantomData,
    }
}