Skip to main content

ppoppo_token/id_token/
issue_config.rs

1//! Per-issuance id_token issuance config — mirror of `VerifyConfig`.
2//!
3//! Holds the deployment-stable fields (`issuer`, `audiences`, `typ`, `kid`,
4//! `cat`) plus the **RP-knowable bindings** that pin a specific id_token
5//! to a specific session (`nonce`) or a specific paired artifact
6//! (`for_access_token` for at_hash, `for_authorization_code` for c_hash).
7//!
8//! ── Why bindings live on `IssueConfig` and not `IssueRequest<S>` ────────
9//!
10//! The conceptual split between `IssueConfig` and `IssueRequest<S>` is:
11//!
12//! * `IssueConfig` — fields the **RP** (verify side) holds an
13//!   independent copy of. The RP minted the nonce in its session, the
14//!   RP just received the access_token at the redirect_uri, the RP just
15//!   received the authorization_code. The id_token's job is to *prove*
16//!   the IdP's emission matches the RP's records. These fields land on
17//!   `IssueConfig` so that issuance configuration sits structurally
18//!   symmetric to `VerifyConfig` (same field, same surface, mirror
19//!   names).
20//! * `IssueRequest<S>` — fields **only the IdP** asserts. `sub` is
21//!   IdP-assigned; `auth_time`/`acr`/`amr` describe the authentication
22//!   ceremony only the IdP witnessed; PII is sourced from the IdP's
23//!   account record. The RP cannot independently verify these against
24//!   its own state — it can only check well-formedness.
25//!
26//! This split is deeper than "per-deployment vs per-issuance": both
27//! structs hold per-issuance state, but each owns one *side* of the
28//! verifier-issuer agreement. Symmetric VerifyConfig and IssueConfig
29//! both hold the RP's view; the IssueRequest is the IdP's testimony.
30//!
31//! ── No agility on `typ` and `cat` ───────────────────────────────────────
32//!
33//! `typ="JWT"` and `cat="id"` are pinned constructor-side. `&'static str`
34//! literals prevent runtime mutation; the verify side enforces both
35//! (`typ` via shared header check `M07-M16a`, `cat` via the M29-mirror
36//! `engine::check_id_token_cat` landed in 10.10.A). Mirrors PASETO v4's
37//! no-cryptographic-agility stance: zero negotiation surface.
38
39use std::fmt;
40
41use crate::id_token::Nonce;
42
43/// Issuance configuration for an OIDC id_token.
44///
45/// Constructed via [`IssueConfig::id_token`] which pins `typ="JWT"`,
46/// `cat="id"`, single-audience array, and the RP-supplied
47/// [`Nonce`]. Multi-audience tokens replace the audience list via
48/// [`with_audiences`](Self::with_audiences); hybrid + implicit flows add
49/// the at_hash / c_hash binding inputs via
50/// [`with_access_token_for_at_hash`](Self::with_access_token_for_at_hash)
51/// and
52/// [`with_authorization_code_for_c_hash`](Self::with_authorization_code_for_c_hash).
53#[derive(Clone)]
54pub struct IssueConfig {
55    pub(crate) issuer: String,
56    pub(crate) audiences: Vec<String>,
57    pub(crate) typ: &'static str,
58    pub(crate) kid: String,
59    pub(crate) cat: &'static str,
60
61    /// RP-minted nonce stored in the RP's session — the IdP echoes it
62    /// back on the wire so the verify-side `M66 check_nonce` can prove
63    /// the id_token belongs to the session that initiated the auth
64    /// request. Required at construction (mirror of
65    /// `VerifyConfig::expected_nonce`); the [`Nonce`] newtype enforces
66    /// non-empty input.
67    pub(crate) nonce: Nonce,
68
69    /// Optional access_token to bind via M67 `at_hash` emission. Set
70    /// per-issuance when the response carries both an id_token and an
71    /// access_token (hybrid `code id_token token`, implicit
72    /// `id_token token`); pure code flow (RCW/CTW today) leaves this
73    /// unset.
74    ///
75    /// γ1 (NEXT_PROMPT 2026-05-10): lives on IssueConfig, not
76    /// IssueRequest, so the issue side is structurally symmetric to
77    /// VerifyConfig::expected_access_token.
78    pub(crate) for_access_token: Option<String>,
79
80    /// Optional authorization_code to bind via M68 `c_hash` emission.
81    /// Set per-issuance for hybrid flow responses
82    /// (`code id_token`, `code id_token token`); pure implicit-flow
83    /// consumers leave this unset.
84    ///
85    /// γ1 (NEXT_PROMPT 2026-05-10): lives on IssueConfig, not
86    /// IssueRequest, mirror of VerifyConfig::expected_authorization_code.
87    pub(crate) for_authorization_code: Option<String>,
88}
89
90impl IssueConfig {
91    /// Build the canonical id_token config: `JWT` typ (the OIDC Core
92    /// canonical id-token type, distinct from access tokens'
93    /// RFC 9068 `at+jwt`), `id` cat (M29-mirror profile-routing
94    /// claim — see `engine::check_id_token_cat`), single-audience
95    /// array, and the RP-supplied `Nonce`. Multi-aud tokens add
96    /// audiences via [`with_audiences`](Self::with_audiences).
97    pub fn id_token(
98        issuer: impl Into<String>,
99        audience: impl Into<String>,
100        kid: impl Into<String>,
101        nonce: Nonce,
102    ) -> Self {
103        Self {
104            issuer: issuer.into(),
105            audiences: vec![audience.into()],
106            typ: "JWT",
107            kid: kid.into(),
108            cat: "id",
109            nonce,
110            for_access_token: None,
111            for_authorization_code: None,
112        }
113    }
114
115    /// Replace the audience list. Engine emits the array form when
116    /// `audiences.len() > 1`, single string when length is 1 (RFC 9068
117    /// §3 — also valid for OIDC Core which silently delegates to JWT
118    /// rules). Empty audience list is a logic error — the engine refuses
119    /// to emit such a token at issuance time.
120    #[must_use]
121    pub fn with_audiences(mut self, audiences: Vec<String>) -> Self {
122        self.audiences = audiences;
123        self
124    }
125
126    /// Bind the issued id_token to a specific access_token via M67
127    /// `at_hash` emission. The engine will compute
128    /// `BASE64URL(SHA-256(access_token)[..16])` and embed it as the
129    /// `at_hash` claim. Required when the same response carries both the
130    /// id_token and the access_token; not called for pure code flow.
131    #[must_use]
132    pub fn with_access_token_for_at_hash(mut self, access_token: impl Into<String>) -> Self {
133        self.for_access_token = Some(access_token.into());
134        self
135    }
136
137    /// Bind the issued id_token to a specific authorization_code via M68
138    /// `c_hash` emission. The engine will compute
139    /// `BASE64URL(SHA-256(code)[..16])` and embed it as the `c_hash`
140    /// claim. Required for hybrid flow responses; not called for
141    /// implicit-only.
142    #[must_use]
143    pub fn with_authorization_code_for_c_hash(mut self, code: impl Into<String>) -> Self {
144        self.for_authorization_code = Some(code.into());
145        self
146    }
147}
148
149// Manual Debug — derive would expose `pub(crate)` field syntax in the
150// output, which leaks naming. We render the same fields in a stable
151// order, redacting the at_hash / c_hash binding inputs (an access_token
152// is itself a bearer credential — never log raw). The Nonce is a public
153// correlator with no secrecy contract (`id_token::nonce` doc-comment),
154// so its derived Debug surfaces normally.
155impl fmt::Debug for IssueConfig {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        f.debug_struct("IssueConfig")
158            .field("issuer", &self.issuer)
159            .field("audiences", &self.audiences)
160            .field("typ", &self.typ)
161            .field("kid", &self.kid)
162            .field("cat", &self.cat)
163            .field("nonce", &self.nonce)
164            .field(
165                "for_access_token",
166                &self.for_access_token.as_ref().map(|_| "<redacted bearer>"),
167            )
168            .field(
169                "for_authorization_code",
170                &self.for_authorization_code.as_ref().map(|_| "<redacted code>"),
171            )
172            .finish()
173    }
174}