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
//! Per-issuance id_token issuance config — mirror of `VerifyConfig`.
//!
//! Holds the deployment-stable fields (`issuer`, `audiences`, `typ`, `kid`,
//! `cat`) plus the **RP-knowable bindings** that pin a specific id_token
//! to a specific session (`nonce`) or a specific paired artifact
//! (`for_access_token` for at_hash, `for_authorization_code` for c_hash).
//!
//! ── Why bindings live on `IssueConfig` and not `IssueRequest<S>` ────────
//!
//! The conceptual split between `IssueConfig` and `IssueRequest<S>` is:
//!
//! * `IssueConfig` — fields the **RP** (verify side) holds an
//!   independent copy of. The RP minted the nonce in its session, the
//!   RP just received the access_token at the redirect_uri, the RP just
//!   received the authorization_code. The id_token's job is to *prove*
//!   the IdP's emission matches the RP's records. These fields land on
//!   `IssueConfig` so that issuance configuration sits structurally
//!   symmetric to `VerifyConfig` (same field, same surface, mirror
//!   names).
//! * `IssueRequest<S>` — fields **only the IdP** asserts. `sub` is
//!   IdP-assigned; `auth_time`/`acr`/`amr` describe the authentication
//!   ceremony only the IdP witnessed; PII is sourced from the IdP's
//!   account record. The RP cannot independently verify these against
//!   its own state — it can only check well-formedness.
//!
//! This split is deeper than "per-deployment vs per-issuance": both
//! structs hold per-issuance state, but each owns one *side* of the
//! verifier-issuer agreement. Symmetric VerifyConfig and IssueConfig
//! both hold the RP's view; the IssueRequest is the IdP's testimony.
//!
//! ── No agility on `typ` and `cat` ───────────────────────────────────────
//!
//! `typ="JWT"` and `cat="id"` are pinned constructor-side. `&'static str`
//! literals prevent runtime mutation; the verify side enforces both
//! (`typ` via shared header check `M07-M16a`, `cat` via the M29-mirror
//! `engine::check_id_token_cat` landed in 10.10.A). Mirrors PASETO v4's
//! no-cryptographic-agility stance: zero negotiation surface.

use std::fmt;

use crate::id_token::Nonce;

/// Issuance configuration for an OIDC id_token.
///
/// Constructed via [`IssueConfig::id_token`] which pins `typ="JWT"`,
/// `cat="id"`, single-audience array, and the RP-supplied
/// [`Nonce`]. Multi-audience tokens replace the audience list via
/// [`with_audiences`](Self::with_audiences); hybrid + implicit flows add
/// the at_hash / c_hash binding inputs via
/// [`with_access_token_for_at_hash`](Self::with_access_token_for_at_hash)
/// and
/// [`with_authorization_code_for_c_hash`](Self::with_authorization_code_for_c_hash).
#[derive(Clone)]
pub struct IssueConfig {
    pub(crate) issuer: String,
    pub(crate) audiences: Vec<String>,
    pub(crate) typ: &'static str,
    pub(crate) kid: String,
    pub(crate) cat: &'static str,

    /// RP-minted nonce stored in the RP's session — the IdP echoes it
    /// back on the wire so the verify-side `M66 check_nonce` can prove
    /// the id_token belongs to the session that initiated the auth
    /// request. Required at construction (mirror of
    /// `VerifyConfig::expected_nonce`); the [`Nonce`] newtype enforces
    /// non-empty input.
    pub(crate) nonce: Nonce,

    /// Optional access_token to bind via M67 `at_hash` emission. Set
    /// per-issuance when the response carries both an id_token and an
    /// access_token (hybrid `code id_token token`, implicit
    /// `id_token token`); pure code flow (RCW/CTW today) leaves this
    /// unset.
    ///
    /// γ1 (NEXT_PROMPT 2026-05-10): lives on IssueConfig, not
    /// IssueRequest, so the issue side is structurally symmetric to
    /// VerifyConfig::expected_access_token.
    pub(crate) for_access_token: Option<String>,

    /// Optional authorization_code to bind via M68 `c_hash` emission.
    /// Set per-issuance for hybrid flow responses
    /// (`code id_token`, `code id_token token`); pure implicit-flow
    /// consumers leave this unset.
    ///
    /// γ1 (NEXT_PROMPT 2026-05-10): lives on IssueConfig, not
    /// IssueRequest, mirror of VerifyConfig::expected_authorization_code.
    pub(crate) for_authorization_code: Option<String>,
}

impl IssueConfig {
    /// Build the canonical id_token config: `JWT` typ (the OIDC Core
    /// canonical id-token type, distinct from access tokens'
    /// RFC 9068 `at+jwt`), `id` cat (M29-mirror profile-routing
    /// claim — see `engine::check_id_token_cat`), single-audience
    /// array, and the RP-supplied `Nonce`. Multi-aud tokens add
    /// audiences via [`with_audiences`](Self::with_audiences).
    pub fn id_token(
        issuer: impl Into<String>,
        audience: impl Into<String>,
        kid: impl Into<String>,
        nonce: Nonce,
    ) -> Self {
        Self {
            issuer: issuer.into(),
            audiences: vec![audience.into()],
            typ: "JWT",
            kid: kid.into(),
            cat: "id",
            nonce,
            for_access_token: None,
            for_authorization_code: None,
        }
    }

    /// Replace the audience list. Engine emits the array form when
    /// `audiences.len() > 1`, single string when length is 1 (RFC 9068
    /// §3 — also valid for OIDC Core which silently delegates to JWT
    /// rules). Empty audience list is a logic error — the engine refuses
    /// to emit such a token at issuance time.
    #[must_use]
    pub fn with_audiences(mut self, audiences: Vec<String>) -> Self {
        self.audiences = audiences;
        self
    }

    /// Bind the issued id_token to a specific access_token via M67
    /// `at_hash` emission. The engine will compute
    /// `BASE64URL(SHA-256(access_token)[..16])` and embed it as the
    /// `at_hash` claim. Required when the same response carries both the
    /// id_token and the access_token; not called for pure code flow.
    #[must_use]
    pub fn with_access_token_for_at_hash(mut self, access_token: impl Into<String>) -> Self {
        self.for_access_token = Some(access_token.into());
        self
    }

    /// Bind the issued id_token to a specific authorization_code via M68
    /// `c_hash` emission. The engine will compute
    /// `BASE64URL(SHA-256(code)[..16])` and embed it as the `c_hash`
    /// claim. Required for hybrid flow responses; not called for
    /// implicit-only.
    #[must_use]
    pub fn with_authorization_code_for_c_hash(mut self, code: impl Into<String>) -> Self {
        self.for_authorization_code = Some(code.into());
        self
    }
}

// Manual Debug — derive would expose `pub(crate)` field syntax in the
// output, which leaks naming. We render the same fields in a stable
// order, redacting the at_hash / c_hash binding inputs (an access_token
// is itself a bearer credential — never log raw). The Nonce is a public
// correlator with no secrecy contract (`id_token::nonce` doc-comment),
// so its derived Debug surfaces normally.
impl fmt::Debug for IssueConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("IssueConfig")
            .field("issuer", &self.issuer)
            .field("audiences", &self.audiences)
            .field("typ", &self.typ)
            .field("kid", &self.kid)
            .field("cat", &self.cat)
            .field("nonce", &self.nonce)
            .field(
                "for_access_token",
                &self.for_access_token.as_ref().map(|_| "<redacted bearer>"),
            )
            .field(
                "for_authorization_code",
                &self.for_authorization_code.as_ref().map(|_| "<redacted code>"),
            )
            .finish()
    }
}