Skip to main content

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}