ppoppo-token 0.2.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
//! M29-mirror — id_token `cat` profile-routing assertion (Phase 10.10).
//!
//! Symmetric to access-token's M29 (`engine::check_claims::run` line 137-140
//! — `cat == "access"` else `TokenTypeMismatch`). Closes the asymmetry where
//! `id_token::verify` had no value-side profile gate and relied on M72's
//! BASE_CLAIMS-omission of `cat` to implicitly forbid it.
//!
//! Phase 10.10 lifts that omission (id_token wire now carries `cat="id"` so
//! self-issued tokens round-trip cleanly via M72) and replaces the implicit
//! "forbid by allowlist absence" with an explicit value gate here.
//!
//! ── Why a separate engine submodule ─────────────────────────────────────
//!
//! Module-per-M-row is the established id_token-engine pattern (`check_nonce`,
//! `check_at_hash`, `check_c_hash`, `check_azp`, `check_auth_time`,
//! `check_acr`, `check_id_token_pii`). Inlining a 4-line value gate inside
//! `id_token::verify::verify` would diverge from that pattern; the audit
//! cost (one less greppable file when reviewing M-row coverage) outweighs
//! the LOC savings.
//!
//! The access-token side leaves M29 inline in `check_claims.rs` because that
//! module already validates the registered-claim cluster (M17-M30 + M32) and
//! M29 sits naturally with its iss/sub/aud/exp/iat siblings. id_token has no
//! analogous registered-claim aggregator yet (Phase 10.5-10.7 will add OIDC
//! exp/iat/iss/aud validation), so the M29-mirror lands as its own module.
//!
//! ── Order in `id_token::verify` ─────────────────────────────────────────
//!
//! Runs immediately AFTER `parse_payload_json` and BEFORE any typed gate
//! (M66 nonce, M67 at_hash, …). Rationale: this is a profile-routing check
//! ("is this even an id_token?"), so a wrong `cat` should short-circuit the
//! id_token-specific pipeline before it spends cycles on nonce/binding
//! comparisons. The position parallels access-token's M29 firing inside
//! `check_claims` BEFORE the domain-layer M45 catch-net.
//!
//! ── Why pinned to the literal `"id"` ────────────────────────────────────
//!
//! Symmetric to access-token's `cat == "access"` literal. PAS issues
//! exactly two token categories today (`access`, `id`); a future third
//! category (e.g. refresh) lands as a new variant + verifier, not as a
//! configurable axis on this check. No-agility mirrors the engine's
//! algorithm pinning (`Algorithm::EdDSA` only) and PASETO v4.public
//! lineage: zero negotiation surface for attackers.

use crate::id_token::AuthError;

/// Refuse any id_token whose `cat` payload claim is not exactly `"id"`.
///
/// Absent `cat` collapses to the empty string, surfacing as
/// `CatMismatch("")` — distinct audit signal from `CatMismatch("access")`
/// (substitution attempt) or `CatMismatch("<other>")` (bespoke forgery /
/// non-ppoppo issuer drift). Treating absence as error rather than admit
/// matches access-token M29 (`unwrap_or("")` then strict equality).
pub(crate) fn run(payload: &serde_json::Value) -> Result<(), AuthError> {
    let cat = payload.get("cat").and_then(|v| v.as_str()).unwrap_or("");
    if cat != "id" {
        return Err(AuthError::CatMismatch(cat.to_string()));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn accepts_cat_id() {
        let payload = json!({ "cat": "id" });
        assert!(run(&payload).is_ok());
    }

    #[test]
    fn rejects_cat_access_with_substitution_signal() {
        // The headline forgery: an attacker presenting an id_token with
        // `cat` rewritten to "access" to pass the access-token verify
        // path. (They can't actually rewrite without re-signing; this
        // test exists so the audit signal is right when they try.)
        let payload = json!({ "cat": "access" });
        assert_eq!(run(&payload), Err(AuthError::CatMismatch("access".into())));
    }

    #[test]
    fn rejects_missing_cat_with_empty_string() {
        // Strict-from-day-1 (β1): an id_token without `cat` is refused.
        // Surfaces as `CatMismatch("")` so audit logs distinguish absence
        // from a mistyped value. Matches access-token M29's
        // `unwrap_or("")` shape verbatim.
        let payload = json!({ "iss": "x" });
        assert_eq!(run(&payload), Err(AuthError::CatMismatch(String::new())));
    }

    #[test]
    fn rejects_non_string_cat_with_empty_string() {
        // Wire-shape forgery: `cat` present but non-string (e.g. integer,
        // object, array). Treated as absence (the wire is malformed)
        // rather than a distinct variant; both signal "this is not a
        // valid id_token shape" and the operator response is identical.
        let payload = json!({ "cat": 42 });
        assert_eq!(run(&payload), Err(AuthError::CatMismatch(String::new())));
    }

    #[test]
    fn rejects_arbitrary_value_with_value_in_audit_signal() {
        let payload = json!({ "cat": "refresh" });
        assert_eq!(
            run(&payload),
            Err(AuthError::CatMismatch("refresh".into())),
        );
    }
}