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
//! OIDC Core 1.0 id_token payload — phantom-typed by scope.
//!
//! ── The structural invariant ────────────────────────────────────────────
//!
//! PII fields (`email`, `name`, `phone_number`, `address`, …) are
//! `pub(crate)`. The only way to *read* them is through the
//! scope-bounded `impl<S: HasEmail> Claims<S>` accessor blocks below.
//! That makes the type system the M72 enforcer: a verifier that
//! returns `Claims<scopes::Openid>` carries no syntactic path to
//! `.email()` — the call doesn't compile.
//!
//! ── Why `pub(crate)` over private + accessors-only ──────────────────────
//!
//! The engine itself (specifically `id_token::verify`) needs to
//! deserialize *into* these fields, populating them unconditionally
//! from the wire (the IdP may have included `email` even on a token
//! the RP requested only `openid` for — defensive read, then narrow).
//! Engine code is intra-crate; `pub(crate)` lets the deserializer
//! write while keeping the read path gated.
//!
//! ── Construction ────────────────────────────────────────────────────────
//!
//! `Claims<S>` has no public constructor. The `verify` entry-point
//! mints them; consumers only ever *receive* a `Claims<S>` from a
//! successful verification. This prevents a caller from fabricating
//! `Claims<EmailProfile>` to bypass the deserialization narrowing
//! (which is a TODO until M72 lands — Phase 10.8).

use std::marker::PhantomData;

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

/// OIDC Core 1.0 §5.1.1 — `address` is a structured claim, not a flat
/// string. All fields optional; an issuer may emit any subset.
///
/// Serializable in both directions: `Deserialize` is used by
/// `id_token::verify`'s `deserialize_claims`; `Serialize` is used by
/// `engine::encode_id_token::IssuePayload` (Phase 10.10) when the
/// issuer-side `IssueRequest::with_address` populates it. Matching
/// derive sets keep the round-trip identity-preserving.
///
/// Per OIDC Core §5.1.1, an issuer may emit any subset of fields; the
/// `serde` deserializer fills missing keys as `None`. Symmetric
/// `serde(skip_serializing_if = "Option::is_none")` on the
/// fields keeps the wire shape minimal — an issuer emitting only
/// `country` produces `{"country": "KR"}` rather than a fully-fielded
/// object with `null`s.
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct AddressClaim {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub formatted: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub street_address: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub locality: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub region: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub postal_code: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub country: Option<String>,
}

/// Verified id_token payload. `S: ScopeSet` is the *type-level scope
/// witness* — the engine sets it to the scope struct matching the
/// requested OAuth `scope` parameter, and the resulting value's PII
/// accessors are bounded by `S`'s implemented marker traits.
///
/// ── M72 acceptance evidence (RFC §6.11.1 D2) ────────────────────────────
///
/// Calling `.email()` on a `Claims<Openid>` is a *compile error*, not a
/// runtime check. The doc-test below is the standing acceptance fixture
/// for the type-level enforcement invariant — `cargo test --doc -p
/// ppoppo-token` runs it and asserts the snippet fails to compile.
///
/// ```compile_fail,E0599
/// use ppoppo_token::id_token::Claims;
/// use ppoppo_token::id_token::scopes::Openid;
///
/// fn _compile_fail(c: &Claims<Openid>) -> &str {
///     c.email() // ERROR: method `email` not in scope (requires HasEmail)
/// }
/// ```
///
/// Granting the `email` scope at issuance time satisfies the bound:
///
/// ```ignore
/// use ppoppo_token::id_token::Claims;
/// use ppoppo_token::id_token::scopes::Email;
///
/// fn _compiles(c: &Claims<Email>) -> &str { c.email() }
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Claims<S: ScopeSet> {
    // ── Core (always present, per OIDC §2) ────────────────────────────────
    pub iss: String,
    pub sub: String,
    pub aud: Vec<String>,
    pub exp: i64,
    pub iat: i64,
    /// Conditionally required: present iff the RP sent `nonce` in the
    /// auth request. Engine-side: `VerifyConfig::id_token` requires an
    /// `expected_nonce`, so reaching the engine implies nonce is
    /// expected; M66 fires when this field is empty after parsing.
    pub nonce: String,

    /// `azp` (authorized party) — OIDC §2. Set when the audience is
    /// multi-valued; the M69 gate (Phase 10.5) verifies it equals the
    /// RP's `client_id` when `aud.len() > 1`.
    pub azp: Option<String>,

    /// `auth_time` — when the End-User authentication occurred. Required
    /// when the `max_age` request parameter or `auth_time` essential
    /// claim was sent; surfaced unconditionally so the M70 gate (Phase
    /// 10.6) can read it.
    pub auth_time: Option<i64>,

    /// `acr` — Authentication Context Class Reference. OIDC §2.
    pub acr: Option<String>,

    /// `amr` — Authentication Methods References (e.g. `["pwd",
    /// "mfa"]`).
    pub amr: Option<Vec<String>>,

    // ── PII — gated by scope-bounded accessor methods 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>,
}

// ── Scope-bounded accessor blocks ───────────────────────────────────────
//
// Reading these top-down: each `impl<S: HasX>` block exposes exactly
// the field set OIDC §5.4 binds to scope `X`. Adding a new claim
// inside a scope is one accessor here; adding a new scope is a struct
// + trait impl in `scopes.rs` plus one block here.

/// `email` scope — OIDC §5.4.
impl<S: HasEmail> Claims<S> {
    /// `email` is REQUIRED if the issuer emits the email scope at all
    /// (OIDC §5.4). Engine deserialization populates `Some(_)` when
    /// the wire contains the claim; the accessor unwraps via
    /// `expect()` because reaching this method bound (`S: HasEmail`)
    /// already proves the IdP honored the scope. A missing email on a
    /// `HasEmail` token is an issuer drift, surfaced as a panic so the
    /// regression is loud — *if* this path is reachable in production.
    /// Phase 10.8 (M72) will replace `expect` with a verify-time
    /// rejection so the panic becomes structurally unreachable.
    #[must_use]
    pub fn email(&self) -> &str {
        self.email
            .as_deref()
            .expect("HasEmail bound implies email Some — IdP drift if absent")
    }

    #[must_use]
    pub fn email_verified(&self) -> Option<bool> {
        self.email_verified
    }
}

/// `profile` scope — OIDC §5.4 (name / locale / updated_at family).
impl<S: HasProfile> Claims<S> {
    #[must_use]
    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    #[must_use]
    pub fn given_name(&self) -> Option<&str> {
        self.given_name.as_deref()
    }

    #[must_use]
    pub fn family_name(&self) -> Option<&str> {
        self.family_name.as_deref()
    }

    #[must_use]
    pub fn middle_name(&self) -> Option<&str> {
        self.middle_name.as_deref()
    }

    #[must_use]
    pub fn nickname(&self) -> Option<&str> {
        self.nickname.as_deref()
    }

    #[must_use]
    pub fn preferred_username(&self) -> Option<&str> {
        self.preferred_username.as_deref()
    }

    #[must_use]
    pub fn profile(&self) -> Option<&str> {
        self.profile.as_deref()
    }

    #[must_use]
    pub fn picture(&self) -> Option<&str> {
        self.picture.as_deref()
    }

    #[must_use]
    pub fn website(&self) -> Option<&str> {
        self.website.as_deref()
    }

    #[must_use]
    pub fn gender(&self) -> Option<&str> {
        self.gender.as_deref()
    }

    #[must_use]
    pub fn birthdate(&self) -> Option<&str> {
        self.birthdate.as_deref()
    }

    #[must_use]
    pub fn zoneinfo(&self) -> Option<&str> {
        self.zoneinfo.as_deref()
    }

    #[must_use]
    pub fn locale(&self) -> Option<&str> {
        self.locale.as_deref()
    }

    #[must_use]
    pub fn updated_at(&self) -> Option<i64> {
        self.updated_at
    }
}

/// `phone` scope — OIDC §5.4.
impl<S: HasPhone> Claims<S> {
    #[must_use]
    pub fn phone_number(&self) -> Option<&str> {
        self.phone_number.as_deref()
    }

    #[must_use]
    pub fn phone_number_verified(&self) -> Option<bool> {
        self.phone_number_verified
    }
}

/// `address` scope — OIDC §5.4 (single structured claim).
impl<S: HasAddress> Claims<S> {
    #[must_use]
    pub fn address(&self) -> Option<&AddressClaim> {
        self.address.as_ref()
    }
}