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
//! Per-issuance id_token principal-assertion payload — phantom-typed by scope.
//!
//! Field-for-field mirror of `Claims<S>` on the issuance side. `IssueRequest<S>`
//! carries everything **only the IdP asserts** (the principal's identity,
//! when/how they authenticated, profile PII), while the RP-knowable
//! bindings (`nonce`, at_hash inputs, c_hash inputs) live on `IssueConfig`
//! per the conceptual split documented there.
//!
//! ── The structural invariant (D2 emission half) ─────────────────────────
//!
//! PII fields (`email`, `name`, `phone_number`, `address`, …) are
//! `pub(crate)` — same shape as `Claims<S>::pub(crate)` on the verify
//! side. The only way to *populate* them is through the scope-bounded
//! `impl<S: HasEmail> IssueRequest<S>` builder blocks below; calling
//! `.with_email(...)` on an `IssueRequest<Openid>` is a *compile error*.
//! That's the type-system half of D2 (project_phase7_module_naming —
//! "phantom-typed `IssueRequest<S>` whose `with_*` builders are gated on
//! `S: HasEmail`/`HasProfile`/`HasPhone`/`HasAddress`").
//!
//! The runtime half is the M72-symmetric allowlist guard inside
//! `engine::encode_id_token::IssuePayload::build`: even if intra-crate
//! code bypasses the builders via struct-literal access to the
//! `pub(crate)` fields, the engine refuses to emit any populated key
//! outside `S::names()` (β1 defense in depth — see
//! `IssueError::EmissionDisallowed`).
//!
//! ── Why field-for-field with `Claims<S>` ────────────────────────────────
//!
//! Symmetry watch (per NEXT_PROMPT 2026-05-10 architecture-health note):
//! every field on `Claims<S>` MUST have a corresponding builder on
//! `IssueRequest<S>` (or be engine-managed from `IssueConfig` + clock).
//! Forgetting one means the issuance side cannot emit a claim the
//! verify side can read — silent narrowing. The engine-managed set is
//! `iss` / `exp` / `iat` (from `cfg.issuer` + clock) and `aud` (from
//! `cfg.audiences`) plus `nonce` / `at_hash` / `c_hash` (from
//! IssueConfig per γ1). Everything else lives on this struct.
//!
//! Construction goes through [`IssueRequest::new`] which sets all
//! optional fields to their absent defaults (`None`, `Vec::new()`).
//! Builders are chainable (`#[must_use]`) and composable; the type
//! parameter `S` is fixed at `new` time via turbofish:
//! `IssueRequest::<EmailProfile>::new(...)`.

use std::marker::PhantomData;
use std::time::Duration;

use super::claims::AddressClaim;
use super::scopes::{HasAddress, HasEmail, HasPhone, HasProfile, ScopeSet};

/// OIDC id_token issuance payload, phantom-typed by `S: ScopeSet`.
///
/// The `S` parameter witnesses the OAuth scope the issuer is honoring.
/// PII builders (`with_email`, `with_name`, …) are gated by the matching
/// marker traits (`HasEmail`, `HasProfile`, `HasPhone`, `HasAddress`),
/// making "wrong scope, wrong field" a compile error.
///
/// ── compile_fail evidence (D2 emission half) ────────────────────────────
///
/// The standing acceptance fixture is the doc-test below; `cargo test
/// --doc -p ppoppo-token` runs it and asserts the snippet fails to
/// compile (E0599 — method not found).
///
/// ```compile_fail,E0599
/// use std::time::Duration;
/// use ppoppo_token::id_token::{IssueRequest, scopes::Openid};
///
/// fn _compile_fail() {
///     let _ = IssueRequest::<Openid>::new(
///         "01HSAB00000000000000000000",
///         Duration::from_secs(600),
///     )
///     .with_email("u@example.com"); // ERROR: with_email requires S: HasEmail
/// }
/// ```
///
/// Granting the `email` scope at issuance time satisfies the bound:
///
/// ```ignore
/// use std::time::Duration;
/// use ppoppo_token::id_token::{IssueRequest, scopes::Email};
///
/// fn _compiles() {
///     let _ = IssueRequest::<Email>::new(
///         "01HSAB00000000000000000000",
///         Duration::from_secs(600),
///     )
///     .with_email("u@example.com");
/// }
/// ```
#[derive(Debug, Clone)]
pub struct IssueRequest<S: ScopeSet> {
    // ── Core principal data (always present) ──────────────────────────────
    /// `sub` — the principal the id_token is about (RFC 7519 §4.1.2,
    /// OIDC Core §2). PAS-issued tokens carry `ppnum_id` (ULID); never
    /// empty.
    pub sub: String,

    /// Time-to-live from now. The engine computes `exp = iat + ttl` and
    /// emits both. Per-profile cap is per-deployment; the engine may
    /// enforce upper bounds in a future row (analogous to access-token
    /// M19).
    pub ttl: Duration,

    // Note: no `jti` field. OIDC Core §2 lists jti as neither required
    // nor recommended for id_tokens (replay defense is the nonce path),
    // and `Claims<S>` on the verify side carries no `jti` accessor —
    // adding one to `IssueRequest<S>` and emitting it on the wire would
    // be an asymmetry the verifier never reads, and would force "jti"
    // into `BASE_CLAIMS` for no semantic gain. Access-token's
    // `IssueRequest::jti` exists because RFC 9068 §2.2.2 requires it on
    // the access-token wire; this profile diverges deliberately.

    // ── IdP-asserted claims (Phase 10.10) ────────────────────────────────
    /// `auth_time` — when the End-User authentication occurred (Unix
    /// seconds). The verify-side M70 gate (Phase 10.6) compares this
    /// against `now - max_age`; the issuer-side just emits what the IdP
    /// witnessed. Required when the RP requested `max_age` in the auth
    /// request — but that contract is between RP and IdP at the
    /// app-protocol level, not the engine; emitting whenever the IdP
    /// has a value is the safe default.
    pub auth_time: Option<i64>,

    /// `acr` — Authentication Context Class Reference (OIDC §2). The
    /// verify-side M71 gate (Phase 10.7) refuses tokens whose acr is
    /// not in `cfg.acr_values`. Emit a value when the IdP can attest to
    /// a specific authentication context; absence collapses to "RP has
    /// no acr policy or IdP cannot assert one".
    pub acr: Option<String>,

    /// `amr` — Authentication Methods References (e.g. `["pwd", "mfa"]`,
    /// OIDC §2). Surfaced as data on the verify side; no gate. Emit
    /// whenever the IdP knows the methods; absence is admitted.
    pub amr: Option<Vec<String>>,

    /// `azp` — Authorized Party (OIDC §2). The verify-side M69 gate
    /// (Phase 10.5) requires `azp == client_id` whenever it's present
    /// AND requires presence on multi-aud tokens. Issue side: set on
    /// every multi-aud token; optional on single-aud (the §2 guidance
    /// is silent on single-aud).
    pub azp: Option<String>,

    // ── PII — gated by scope-bounded builder blocks below ─────────────────
    pub(crate) email: Option<String>,
    pub(crate) email_verified: Option<bool>,

    pub(crate) name: Option<String>,
    pub(crate) given_name: Option<String>,
    pub(crate) family_name: Option<String>,
    pub(crate) middle_name: Option<String>,
    pub(crate) nickname: Option<String>,
    pub(crate) preferred_username: Option<String>,
    pub(crate) profile: Option<String>,
    pub(crate) picture: Option<String>,
    pub(crate) website: Option<String>,
    pub(crate) gender: Option<String>,
    pub(crate) birthdate: Option<String>,
    pub(crate) zoneinfo: Option<String>,
    pub(crate) locale: Option<String>,
    pub(crate) updated_at: Option<i64>,

    pub(crate) phone_number: Option<String>,
    pub(crate) phone_number_verified: Option<bool>,

    pub(crate) address: Option<AddressClaim>,

    pub(crate) _scope: PhantomData<S>,
}

impl<S: ScopeSet> IssueRequest<S> {
    /// Construct a new request with the required core fields. All
    /// optional fields default to absent; every emission is opt-in via a
    /// `with_*` builder, so a caller who forgets to set a value cannot
    /// accidentally emit a populated claim.
    ///
    /// The scope parameter is fixed at construction via turbofish:
    /// `IssueRequest::<Email>::new("01H...", Duration::from_secs(600))`.
    pub fn new(sub: impl Into<String>, ttl: Duration) -> Self {
        Self {
            sub: sub.into(),
            ttl,
            auth_time: None,
            acr: None,
            amr: None,
            azp: None,
            email: None,
            email_verified: None,
            name: None,
            given_name: None,
            family_name: None,
            middle_name: None,
            nickname: None,
            preferred_username: None,
            profile: None,
            picture: None,
            website: None,
            gender: None,
            birthdate: None,
            zoneinfo: None,
            locale: None,
            updated_at: None,
            phone_number: None,
            phone_number_verified: None,
            address: None,
            _scope: PhantomData,
        }
    }

    /// Set `auth_time` (Unix seconds) — when the End-User authentication
    /// occurred. Always available regardless of `S` (auth_time is in
    /// `BASE_CLAIMS`).
    #[must_use]
    pub fn with_auth_time(mut self, auth_time: i64) -> Self {
        self.auth_time = Some(auth_time);
        self
    }

    /// Set the Authentication Context Class Reference.
    #[must_use]
    pub fn with_acr(mut self, acr: impl Into<String>) -> Self {
        self.acr = Some(acr.into());
        self
    }

    /// Set the Authentication Methods References.
    #[must_use]
    pub fn with_amr(mut self, amr: Vec<String>) -> Self {
        self.amr = Some(amr);
        self
    }

    /// Set the Authorized Party. Required for multi-aud tokens (M69
    /// verify-side gate); optional on single-aud.
    #[must_use]
    pub fn with_azp(mut self, azp: impl Into<String>) -> Self {
        self.azp = Some(azp.into());
        self
    }
}

// ── Scope-bounded PII builder blocks ────────────────────────────────────
//
// Reading these top-down: each `impl<S: HasX>` block exposes exactly
// the builder set OIDC §5.4 binds to scope `X`. A new claim inside an
// existing scope is one builder addition here plus one accessor in
// `claims.rs`; a new scope is a struct + trait impl in `scopes.rs` plus
// one block here AND in `claims.rs`.
//
// Naming convention: `with_<wire_name>`. Wire name == method name suffix
// so the audit reader greps `with_email` and finds both the issuance
// builder and the wire-name string in `EMAIL_CLAIMS` without juggling.

/// `email` scope — OIDC §5.4.
impl<S: HasEmail> IssueRequest<S> {
    #[must_use]
    pub fn with_email(mut self, email: impl Into<String>) -> Self {
        self.email = Some(email.into());
        self
    }

    #[must_use]
    pub fn with_email_verified(mut self, verified: bool) -> Self {
        self.email_verified = Some(verified);
        self
    }
}

/// `profile` scope — OIDC §5.4 (name family + locale + updated_at).
impl<S: HasProfile> IssueRequest<S> {
    #[must_use]
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.name = Some(name.into());
        self
    }

    #[must_use]
    pub fn with_given_name(mut self, given_name: impl Into<String>) -> Self {
        self.given_name = Some(given_name.into());
        self
    }

    #[must_use]
    pub fn with_family_name(mut self, family_name: impl Into<String>) -> Self {
        self.family_name = Some(family_name.into());
        self
    }

    #[must_use]
    pub fn with_middle_name(mut self, middle_name: impl Into<String>) -> Self {
        self.middle_name = Some(middle_name.into());
        self
    }

    #[must_use]
    pub fn with_nickname(mut self, nickname: impl Into<String>) -> Self {
        self.nickname = Some(nickname.into());
        self
    }

    #[must_use]
    pub fn with_preferred_username(mut self, preferred_username: impl Into<String>) -> Self {
        self.preferred_username = Some(preferred_username.into());
        self
    }

    #[must_use]
    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
        self.profile = Some(profile.into());
        self
    }

    #[must_use]
    pub fn with_picture(mut self, picture: impl Into<String>) -> Self {
        self.picture = Some(picture.into());
        self
    }

    #[must_use]
    pub fn with_website(mut self, website: impl Into<String>) -> Self {
        self.website = Some(website.into());
        self
    }

    #[must_use]
    pub fn with_gender(mut self, gender: impl Into<String>) -> Self {
        self.gender = Some(gender.into());
        self
    }

    #[must_use]
    pub fn with_birthdate(mut self, birthdate: impl Into<String>) -> Self {
        self.birthdate = Some(birthdate.into());
        self
    }

    #[must_use]
    pub fn with_zoneinfo(mut self, zoneinfo: impl Into<String>) -> Self {
        self.zoneinfo = Some(zoneinfo.into());
        self
    }

    #[must_use]
    pub fn with_locale(mut self, locale: impl Into<String>) -> Self {
        self.locale = Some(locale.into());
        self
    }

    /// `updated_at` is Unix seconds (OIDC §5.1).
    #[must_use]
    pub fn with_updated_at(mut self, updated_at: i64) -> Self {
        self.updated_at = Some(updated_at);
        self
    }
}

/// `phone` scope — OIDC §5.4.
impl<S: HasPhone> IssueRequest<S> {
    #[must_use]
    pub fn with_phone_number(mut self, phone_number: impl Into<String>) -> Self {
        self.phone_number = Some(phone_number.into());
        self
    }

    #[must_use]
    pub fn with_phone_number_verified(mut self, verified: bool) -> Self {
        self.phone_number_verified = Some(verified);
        self
    }
}

/// `address` scope — OIDC §5.4 (single structured claim).
impl<S: HasAddress> IssueRequest<S> {
    #[must_use]
    pub fn with_address(mut self, address: AddressClaim) -> Self {
        self.address = Some(address);
        self
    }
}