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
//! JWT issuance — wire payload assembly + signing.
//!
//! Engine-internal mirror of `check_claims`. The verifier parses a wire
//! payload into the public `Claims` (which hides `aud`/`cat`); this
//! module builds the inverse — a wire payload that carries every
//! registered claim Phase 2's verifier validates, but never escapes the
//! engine as a struct.
//!
//! `IssuePayload` is `pub(crate)`. Callers go through `issue()` (commit
//! 3.3) which returns the signed Compact JWS string. There is no public
//! escape hatch that hands callers the unsigned payload — that would
//! re-introduce the surface area Phase 2 deliberately removed.
//!
//! Phase 3 emits 9 fields: iss / sub / aud / exp / iat / nbf / jti /
//! client_id / cat. Phase 4 appended domain claims (cid / sv / scopes /
//! account_type / delegator / dlg_depth / admin / active_ppnum) — the
//! additive shape preserves wire compatibility for already-issued tokens.
//! (`mlt` was removed entirely with the magic-link μ1 retirement; see
//! `0context/STATUS_JWT_ADOPTION.md` and the magic-link slice 1/2 commits.)
//!
//! ── `aud` emission shape ─────────────────────────────────────────────
//!
//! Phase 3 picks RFC 9068 §3 norm: **string when `audiences.len() == 1`,
//! array otherwise**. The verify side accepts both (M21+M22 in Phase 2),
//! so this is purely an issuer-side choice. Reasoning:
//!
//! 1. Matches RFC 9068 example tokens — interop with third-party
//!    auditors and parsers that expect the string form.
//! 2. Smaller wire for the common case (1 audience covers PAS-self,
//!    PCS, every OAuth token; multi-aud is rare).
//! 3. The "uniform wire" counterargument (always-array) only matters
//!    for downstream consumers that can't handle the union type — our
//!    verifier already does.
//!
//! Reversal cost is low (this file only); if a future consumer
//! demands always-array, flip `serialize_aud_choice` and update tests.

use serde::Serialize;

use crate::access_token::{IssueConfig, IssueRequest};

/// Wire shape for the `aud` claim. Mirrors `check_claims::AudOnWire` on
/// the verify side; serializer chooses between variants based on
/// `IssueConfig.audiences.len()`.
#[derive(Serialize)]
#[serde(untagged)]
enum AudOnWire<'a> {
    One(&'a str),
    Many(&'a [String]),
}

/// Engine-internal wire payload — the typed shape `jsonwebtoken::encode`
/// serializes to the JWS body. Lifetime-borrowed from `IssueConfig` and
/// `IssueRequest` so building the payload is allocation-free apart from
/// the `jti` ULID stringification.
///
/// Domain claims (Phase 4) use `#[serde(skip_serializing_if = "Option::is_none")]`
/// so callers that don't opt in keep emitting the same wire bytes as
/// pre-rollout — adding a `with_*` is opt-in surface, never silent
/// presence.
#[derive(Serialize)]
pub(crate) struct IssuePayload<'a> {
    iss: &'a str,
    sub: &'a str,
    aud: AudOnWire<'a>,
    exp: i64,
    iat: i64,
    nbf: i64,
    jti: String,
    client_id: &'a str,
    cat: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    account_type: Option<&'a str>,
    #[serde(skip_serializing_if = "<[String]>::is_empty")]
    caps: &'a [String],
    #[serde(skip_serializing_if = "<[String]>::is_empty")]
    scopes: &'a [String],
    /// `dlg_depth` skips on `0` — depth-0 (originating principal) is
    /// the implicit default. Any non-zero value means the token sits
    /// in a Token Exchange chain and audit logs need to know.
    #[serde(skip_serializing_if = "u8_is_zero")]
    dlg_depth: u8,
    /// `admin` skips on `false` — non-admin tokens (the common case)
    /// keep the same wire bytes as pre-Phase-4. Only opt-in admin
    /// pipelines emit the flag.
    #[serde(skip_serializing_if = "bool_is_false")]
    admin: bool,
    /// `active_ppnum` skips on `None`. Engine-required when `admin =
    /// true` (M44 band check); optional otherwise.
    #[serde(skip_serializing_if = "Option::is_none")]
    active_ppnum: Option<&'a str>,
    /// `delegator` — Token Exchange chain's delegating principal.
    /// Skips when None — non-delegated tokens (the common case)
    /// keep pre-Phase-4 wire bytes.
    #[serde(skip_serializing_if = "Option::is_none")]
    delegator: Option<&'a str>,
    /// `cid` — passkey credential id. Skips when None — tokens minted
    /// via non-passkey paths emit no `cid`.
    #[serde(skip_serializing_if = "Option::is_none")]
    cid: Option<&'a str>,
    /// `sv` — Human-path session_version. Engine-internal at verify
    /// time (Phase 5 cache compares); surfaces on the wire so the
    /// Phase 5 cache port has the value to compare against.
    #[serde(skip_serializing_if = "Option::is_none")]
    sv: Option<u64>,
    /// `sid` (M36) — session row id. Skips when None — non-session-bound
    /// tokens (AI agent, machine flows) emit no `sid` and the verifier
    /// short-circuits the M36 gate. When present, the verifier consults
    /// `cfg.session_revocation::is_active(sub, sid)` and refuses the
    /// token if the substrate row is gone (= revoked).
    #[serde(skip_serializing_if = "Option::is_none")]
    sid: Option<&'a str>,
}

/// Predicate for `serde(skip_serializing_if)` — `dlg_depth == 0` is the
/// "no delegation" default and must not appear on the wire (mirrors the
/// caps/scopes/cid pattern of "absent equals default").
fn u8_is_zero(value: &u8) -> bool {
    *value == 0
}

/// Predicate for `serde(skip_serializing_if)` — `admin == false` is the
/// non-admin default and must not appear on the wire. Only opt-in admin
/// pipelines emit the flag.
fn bool_is_false(value: &bool) -> bool {
    !*value
}

impl<'a> IssuePayload<'a> {
    /// Build the wire payload from an `IssueRequest` + `IssueConfig` at
    /// the supplied `now` (seconds since UNIX_EPOCH).
    ///
    /// `now` is a parameter rather than a system call so tests can pin
    /// timestamps; production paths in `issue()` pass
    /// `time::OffsetDateTime::now_utc().unix_timestamp()`.
    ///
    /// `nbf == iat` — Phase 3 access tokens are valid from the moment
    /// they're issued. A future profile that needs delayed activation
    /// will introduce its own `IssueConfig::with_nbf_offset`.
    pub(crate) fn build(req: &'a IssueRequest, cfg: &'a IssueConfig, now: i64) -> Self {
        let exp = now + req.ttl.as_secs() as i64;
        let jti = req.jti.unwrap_or_else(ulid::Ulid::new).to_string();
        let aud = serialize_aud_choice(&cfg.audiences);

        Self {
            iss: &cfg.issuer,
            sub: &req.sub,
            aud,
            exp,
            iat: now,
            nbf: now,
            jti,
            client_id: &req.client_id,
            cat: cfg.cat,
            account_type: req.account_type.as_deref(),
            caps: &req.caps,
            scopes: &req.scopes,
            dlg_depth: req.dlg_depth,
            admin: req.admin,
            active_ppnum: req.active_ppnum.as_deref(),
            delegator: req.delegator.as_deref(),
            cid: req.cid.as_deref(),
            sv: req.sv,
            sid: req.sid.as_deref(),
        }
    }
}

fn serialize_aud_choice(audiences: &[String]) -> AudOnWire<'_> {
    if audiences.len() == 1 {
        AudOnWire::One(&audiences[0])
    } else {
        AudOnWire::Many(audiences)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use std::time::Duration;

    fn payload_json(req: &IssueRequest, cfg: &IssueConfig, now: i64) -> serde_json::Value {
        let payload = IssuePayload::build(req, cfg, now);
        serde_json::to_value(&payload).unwrap()
    }

    #[test]
    fn registered_non_aud_claims_round_trip() {
        let req = IssueRequest::new("sub-x", "client-y", Duration::from_secs(3600));
        let cfg = IssueConfig::access_token("https://accounts.ppoppo.com", "ppoppo", "kid-1");
        let now = 1_700_000_000_i64;

        let json = payload_json(&req, &cfg, now);
        assert_eq!(json["iss"], "https://accounts.ppoppo.com");
        assert_eq!(json["sub"], "sub-x");
        assert_eq!(json["exp"], now + 3600);
        assert_eq!(json["iat"], now);
        assert_eq!(json["nbf"], now);
        assert_eq!(json["client_id"], "client-y");
        assert_eq!(json["cat"], "access");
        // jti is engine-generated when `req.jti` is None — assert shape only.
        let jti = json["jti"].as_str().expect("jti must serialize as string");
        assert_eq!(jti.len(), 26, "jti must be ULID format (26 chars)");
    }

    #[test]
    fn aud_emits_string_when_single_audience() {
        // RFC 9068 §3 norm: single-element aud is a JSON string, not a
        // 1-element array. The verify side accepts both (M21+M22), so
        // this is a shape choice locked in here for wire stability.
        let req = IssueRequest::new("sub", "client", Duration::from_secs(60));
        let cfg = IssueConfig::access_token("iss", "ppoppo", "kid");
        let json = payload_json(&req, &cfg, 1_700_000_000);
        assert_eq!(json["aud"], "ppoppo");
        assert!(json["aud"].is_string(), "single aud must be a string");
    }

    #[test]
    fn aud_emits_array_when_multi_audience() {
        let req = IssueRequest::new("sub", "client", Duration::from_secs(60));
        let cfg = IssueConfig::access_token("iss", "primary", "kid")
            .with_audiences(vec!["primary".to_string(), "secondary".to_string()]);
        let json = payload_json(&req, &cfg, 1_700_000_000);
        let aud = &json["aud"];
        assert!(aud.is_array(), "multi aud must be an array");
        assert_eq!(aud[0], "primary");
        assert_eq!(aud[1], "secondary");
    }

    #[test]
    fn pinned_jti_overrides_engine_generation() {
        let id = ulid::Ulid::from_string("01HABC00000000000000000000").unwrap();
        let req = IssueRequest::new("sub", "client", Duration::from_secs(60)).with_jti(id);
        let cfg = IssueConfig::access_token("iss", "aud", "kid");
        let json = payload_json(&req, &cfg, 1_700_000_000);
        assert_eq!(json["jti"], "01HABC00000000000000000000");
    }
}