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}