ppoppo_token/id_token/claims.rs
1//! OIDC Core 1.0 id_token payload — phantom-typed by scope.
2//!
3//! ── The structural invariant ────────────────────────────────────────────
4//!
5//! PII fields (`email`, `name`, `phone_number`, `address`, …) are
6//! `pub(crate)`. The only way to *read* them is through the
7//! scope-bounded `impl<S: HasEmail> Claims<S>` accessor blocks below.
8//! That makes the type system the M72 enforcer: a verifier that
9//! returns `Claims<scopes::Openid>` carries no syntactic path to
10//! `.email()` — the call doesn't compile.
11//!
12//! ── Why `pub(crate)` over private + accessors-only ──────────────────────
13//!
14//! The engine itself (specifically `id_token::verify`) needs to
15//! deserialize *into* these fields, populating them unconditionally
16//! from the wire (the IdP may have included `email` even on a token
17//! the RP requested only `openid` for — defensive read, then narrow).
18//! Engine code is intra-crate; `pub(crate)` lets the deserializer
19//! write while keeping the read path gated.
20//!
21//! ── Construction ────────────────────────────────────────────────────────
22//!
23//! `Claims<S>` has no public constructor. The `verify` entry-point
24//! mints them; consumers only ever *receive* a `Claims<S>` from a
25//! successful verification. This prevents a caller from fabricating
26//! `Claims<EmailProfile>` to bypass the deserialization narrowing
27//! (which is a TODO until M72 lands — Phase 10.8).
28
29use std::marker::PhantomData;
30
31use super::scopes::{HasAddress, HasEmail, HasPhone, HasProfile, ScopeSet};
32
33/// OIDC Core 1.0 §5.1.1 — `address` is a structured claim, not a flat
34/// string. All fields optional; an issuer may emit any subset.
35///
36/// Serializable in both directions: `Deserialize` is used by
37/// `id_token::verify`'s `deserialize_claims`; `Serialize` is used by
38/// `engine::encode_id_token::IssuePayload` (Phase 10.10) when the
39/// issuer-side `IssueRequest::with_address` populates it. Matching
40/// derive sets keep the round-trip identity-preserving.
41///
42/// Per OIDC Core §5.1.1, an issuer may emit any subset of fields; the
43/// `serde` deserializer fills missing keys as `None`. Symmetric
44/// `serde(skip_serializing_if = "Option::is_none")` on the
45/// fields keeps the wire shape minimal — an issuer emitting only
46/// `country` produces `{"country": "KR"}` rather than a fully-fielded
47/// object with `null`s.
48#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
49pub struct AddressClaim {
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub formatted: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub street_address: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub locality: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub region: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub postal_code: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub country: Option<String>,
62}
63
64/// Verified id_token payload. `S: ScopeSet` is the *type-level scope
65/// witness* — the engine sets it to the scope struct matching the
66/// requested OAuth `scope` parameter, and the resulting value's PII
67/// accessors are bounded by `S`'s implemented marker traits.
68///
69/// ── M72 acceptance evidence (RFC §6.11.1 D2) ────────────────────────────
70///
71/// Calling `.email()` on a `Claims<Openid>` is a *compile error*, not a
72/// runtime check. The doc-test below is the standing acceptance fixture
73/// for the type-level enforcement invariant — `cargo test --doc -p
74/// ppoppo-token` runs it and asserts the snippet fails to compile.
75///
76/// ```compile_fail,E0599
77/// use ppoppo_token::id_token::Claims;
78/// use ppoppo_token::id_token::scopes::Openid;
79///
80/// fn _compile_fail(c: &Claims<Openid>) -> &str {
81/// c.email() // ERROR: method `email` not in scope (requires HasEmail)
82/// }
83/// ```
84///
85/// Granting the `email` scope at issuance time satisfies the bound:
86///
87/// ```ignore
88/// use ppoppo_token::id_token::Claims;
89/// use ppoppo_token::id_token::scopes::Email;
90///
91/// fn _compiles(c: &Claims<Email>) -> &str { c.email() }
92/// ```
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Claims<S: ScopeSet> {
95 // ── Core (always present, per OIDC §2) ────────────────────────────────
96 pub iss: String,
97 pub sub: String,
98 pub aud: Vec<String>,
99 pub exp: i64,
100 pub iat: i64,
101 /// Conditionally required: present iff the RP sent `nonce` in the
102 /// auth request. Engine-side: `VerifyConfig::id_token` requires an
103 /// `expected_nonce`, so reaching the engine implies nonce is
104 /// expected; M66 fires when this field is empty after parsing.
105 pub nonce: String,
106
107 /// `azp` (authorized party) — OIDC §2. Set when the audience is
108 /// multi-valued; the M69 gate (Phase 10.5) verifies it equals the
109 /// RP's `client_id` when `aud.len() > 1`.
110 pub azp: Option<String>,
111
112 /// `auth_time` — when the End-User authentication occurred. Required
113 /// when the `max_age` request parameter or `auth_time` essential
114 /// claim was sent; surfaced unconditionally so the M70 gate (Phase
115 /// 10.6) can read it.
116 pub auth_time: Option<i64>,
117
118 /// `acr` — Authentication Context Class Reference. OIDC §2.
119 pub acr: Option<String>,
120
121 /// `amr` — Authentication Methods References (e.g. `["pwd",
122 /// "mfa"]`).
123 pub amr: Option<Vec<String>>,
124
125 // ── PII — gated by scope-bounded accessor methods below ───────────────
126 pub(crate) email: Option<String>,
127 pub(crate) email_verified: Option<bool>,
128
129 pub(crate) name: Option<String>,
130 pub(crate) given_name: Option<String>,
131 pub(crate) family_name: Option<String>,
132 pub(crate) middle_name: Option<String>,
133 pub(crate) nickname: Option<String>,
134 pub(crate) preferred_username: Option<String>,
135 pub(crate) profile: Option<String>,
136 pub(crate) picture: Option<String>,
137 pub(crate) website: Option<String>,
138 pub(crate) gender: Option<String>,
139 pub(crate) birthdate: Option<String>,
140 pub(crate) zoneinfo: Option<String>,
141 pub(crate) locale: Option<String>,
142 pub(crate) updated_at: Option<i64>,
143
144 pub(crate) phone_number: Option<String>,
145 pub(crate) phone_number_verified: Option<bool>,
146
147 pub(crate) address: Option<AddressClaim>,
148
149 pub(crate) _scope: PhantomData<S>,
150}
151
152// ── Scope-bounded accessor blocks ───────────────────────────────────────
153//
154// Reading these top-down: each `impl<S: HasX>` block exposes exactly
155// the field set OIDC §5.4 binds to scope `X`. Adding a new claim
156// inside a scope is one accessor here; adding a new scope is a struct
157// + trait impl in `scopes.rs` plus one block here.
158
159/// `email` scope — OIDC §5.4.
160impl<S: HasEmail> Claims<S> {
161 /// `email` is REQUIRED if the issuer emits the email scope at all
162 /// (OIDC §5.4). Engine deserialization populates `Some(_)` when
163 /// the wire contains the claim; the accessor unwraps via
164 /// `expect()` because reaching this method bound (`S: HasEmail`)
165 /// already proves the IdP honored the scope. A missing email on a
166 /// `HasEmail` token is an issuer drift, surfaced as a panic so the
167 /// regression is loud — *if* this path is reachable in production.
168 /// Phase 10.8 (M72) will replace `expect` with a verify-time
169 /// rejection so the panic becomes structurally unreachable.
170 #[must_use]
171 pub fn email(&self) -> &str {
172 self.email
173 .as_deref()
174 .expect("HasEmail bound implies email Some — IdP drift if absent")
175 }
176
177 #[must_use]
178 pub fn email_verified(&self) -> Option<bool> {
179 self.email_verified
180 }
181}
182
183/// `profile` scope — OIDC §5.4 (name / locale / updated_at family).
184impl<S: HasProfile> Claims<S> {
185 #[must_use]
186 pub fn name(&self) -> Option<&str> {
187 self.name.as_deref()
188 }
189
190 #[must_use]
191 pub fn given_name(&self) -> Option<&str> {
192 self.given_name.as_deref()
193 }
194
195 #[must_use]
196 pub fn family_name(&self) -> Option<&str> {
197 self.family_name.as_deref()
198 }
199
200 #[must_use]
201 pub fn middle_name(&self) -> Option<&str> {
202 self.middle_name.as_deref()
203 }
204
205 #[must_use]
206 pub fn nickname(&self) -> Option<&str> {
207 self.nickname.as_deref()
208 }
209
210 #[must_use]
211 pub fn preferred_username(&self) -> Option<&str> {
212 self.preferred_username.as_deref()
213 }
214
215 #[must_use]
216 pub fn profile(&self) -> Option<&str> {
217 self.profile.as_deref()
218 }
219
220 #[must_use]
221 pub fn picture(&self) -> Option<&str> {
222 self.picture.as_deref()
223 }
224
225 #[must_use]
226 pub fn website(&self) -> Option<&str> {
227 self.website.as_deref()
228 }
229
230 #[must_use]
231 pub fn gender(&self) -> Option<&str> {
232 self.gender.as_deref()
233 }
234
235 #[must_use]
236 pub fn birthdate(&self) -> Option<&str> {
237 self.birthdate.as_deref()
238 }
239
240 #[must_use]
241 pub fn zoneinfo(&self) -> Option<&str> {
242 self.zoneinfo.as_deref()
243 }
244
245 #[must_use]
246 pub fn locale(&self) -> Option<&str> {
247 self.locale.as_deref()
248 }
249
250 #[must_use]
251 pub fn updated_at(&self) -> Option<i64> {
252 self.updated_at
253 }
254}
255
256/// `phone` scope — OIDC §5.4.
257impl<S: HasPhone> Claims<S> {
258 #[must_use]
259 pub fn phone_number(&self) -> Option<&str> {
260 self.phone_number.as_deref()
261 }
262
263 #[must_use]
264 pub fn phone_number_verified(&self) -> Option<bool> {
265 self.phone_number_verified
266 }
267}
268
269/// `address` scope — OIDC §5.4 (single structured claim).
270impl<S: HasAddress> Claims<S> {
271 #[must_use]
272 pub fn address(&self) -> Option<&AddressClaim> {
273 self.address.as_ref()
274 }
275}