Skip to main content

ppoppo_token/id_token/
scopes.rs

1//! OIDC Core 1.0 §5.4 scope vocabulary as a *type-system* construct.
2//!
3//! ── Why types, not strings ──────────────────────────────────────────────
4//!
5//! M45 (`access_token`) is a flat PII allowlist enforced at runtime: the
6//! engine refuses any payload claim outside `ALLOWED_CLAIMS`. M72
7//! (`id_token`) is *per-scope* — `email` is admissible iff the OAuth
8//! request carried `scope=openid email`, etc. Encoding M72 the same way
9//! M45 does (string match) reproduces the runtime cost on every verify
10//! and leaves no compile-time evidence that the engine actually narrows
11//! visibility.
12//!
13//! The phantom-typed approach (this file + `claims.rs`) makes M72
14//! *structural*: a `Claims<Openid>` value has no `.email()` method —
15//! calling it produces `error[E0599]: no method named 'email' found`.
16//! Adding a new scope (`offline_access`) is mechanical (one struct + one
17//! trait impl); adding a new PII *leak path* requires editing the engine
18//! invariant (the `pub(crate)` field privacy), which is the surface the
19//! security review reads.
20//!
21//! ── Vocabulary ──────────────────────────────────────────────────────────
22//!
23//! * [`ScopeSet`] — sealed trait every scope marker implements; the
24//!   `verify<S: ScopeSet>` engine entry-point bounds on it so callers
25//!   cannot fabricate ad-hoc scope structs that bypass deserialization
26//!   narrowing.
27//! * [`HasEmail`] / [`HasProfile`] / [`HasPhone`] / [`HasAddress`] —
28//!   marker traits gating the scope-bounded `impl Claims<S>` accessor
29//!   blocks in `claims.rs`. Composition is via *concrete combination
30//!   structs*, not blanket impls — see "Combination structs" below.
31//!
32//! ── Combination structs ─────────────────────────────────────────────────
33//!
34//! OIDC scope is a request-time bag: `scope=openid email profile phone`
35//! is a single OAuth string. We mint one struct per *useful* combination
36//! the consumer will actually request — the surface is small (RP apps
37//! pick one of {`Openid`, `Email`, `Profile`, `EmailProfile`,
38//! `EmailProfilePhone`, `EmailProfilePhoneAddress`}) and the structs are
39//! not parameterised. Combinatorial blow-up (2^4 = 16) is bounded by
40//! adding combinations *only when a real RP needs them*, not preemptively.
41//!
42//! Why not a generic combinator `(Email, Profile)` tuple? The OIDC scope
43//! request is an unordered set; a tuple imposes order. A type-level set
44//! type would work but adds machinery (`HList`, frunk) for no engineering
45//! win at 16-element scale.
46
47/// Sealed trait. Every scope marker (the 6 structs below) implements it;
48/// nothing outside this module can. Bounds `verify<S>` and `Claims<S>` so
49/// callers cannot smuggle in `Claims<()>` and bypass the Has* gating.
50///
51/// ── `names()` (M72) ─────────────────────────────────────────────────────
52///
53/// The full per-scope claim allowlist — every payload key the engine is
54/// permitted to deserialize for this scope. Returned as a `&'static`
55/// slice so the engine's M72 check (`engine::check_id_token_pii::run`)
56/// iterates without allocation. The slice is the COMPLETE allowlist
57/// (registered base claims `iss`/`sub`/`aud`/`exp`/`iat`/`nonce`/`azp`/
58/// `auth_time`/`acr`/`amr` UNIONED with the scope's PII fields), so
59/// auditing is single-file: every allowlist lives in this module.
60///
61/// Adding a new claim is a 3-step change (in this order):
62/// 1. Append the wire name to the appropriate const slice below
63///    (`BASE_CLAIMS` for registered claims, `EMAIL_CLAIMS` /
64///    `PROFILE_CLAIMS` / `PHONE_CLAIMS` / `ADDRESS_CLAIMS` for PII).
65/// 2. Append the same name to every per-variant `static NAMES_*` array
66///    that should permit it (the union sets are NOT auto-derived —
67///    explicit listing is the audit surface).
68/// 3. Surface the field on `Claims<S>` (or extend the deserializer) per
69///    the scope-bounded accessor pattern in `claims.rs`.
70///
71/// Skipping step 2 leaves `verify::<EmailProfile>` rejecting a token
72/// that legitimately carries the new claim. The unit test
73/// `email_profile_phone_address_is_union_of_components` is the
74/// regression guard for accidental drift in the maximal scope.
75pub trait ScopeSet: sealed::Sealed {
76    /// Complete per-scope claim allowlist (union of base + PII for this
77    /// scope). M72 enforcement iterates this set; any payload key
78    /// outside it is refused with `AuthError::UnknownClaim(name)`.
79    fn names() -> &'static [&'static str];
80}
81
82/// Token grants `openid email` (or any superset including `email`).
83/// Gates `Claims::email()` / `Claims::email_verified()`.
84pub trait HasEmail: ScopeSet {}
85
86/// Token grants `profile` (name fields + locale + updated_at — OIDC §5.4).
87/// Gates `Claims::name()` / `given_name()` / `family_name()`.
88pub trait HasProfile: ScopeSet {}
89
90/// Token grants `phone`. Gates `Claims::phone_number()` /
91/// `phone_number_verified()`.
92pub trait HasPhone: ScopeSet {}
93
94/// Token grants `address`. Gates `Claims::address()`.
95pub trait HasAddress: ScopeSet {}
96
97mod sealed {
98    pub trait Sealed {}
99}
100
101// ── Per-scope claim allowlists (M72 source of truth) ────────────────────
102//
103// `BASE_CLAIMS` is the registered/core OIDC set every scope permits.
104// Each PII slice (`EMAIL_CLAIMS` / `PROFILE_CLAIMS` / `PHONE_CLAIMS` /
105// `ADDRESS_CLAIMS`) lists the names OIDC §5.4 binds to the matching
106// scope. The per-variant `NAMES_*` static arrays below are the
107// hand-listed unions that `ScopeSet::names()` returns — they are NOT
108// auto-derived because the explicit form is the audit surface.
109
110/// Registered + always-permitted core claims.
111///
112/// Members:
113/// * `iss`, `sub`, `aud`, `exp`, `iat` — RFC 7519 baseline.
114/// * `nonce` — M66 (always required: `VerifyConfig::id_token` mandates
115///   `expected_nonce`, so a verified id_token always carries it).
116/// * `at_hash` — M67 (OIDC §3.1.3.8; conditionally required when
117///   `response_type` includes `token`). Surfaces in BASE because
118///   verification reads it via `with_access_token_binding`; the IdP
119///   emits it on hybrid + implicit flows even at base scope.
120/// * `c_hash` — M68 (OIDC §3.3.2.11; conditionally required on hybrid
121///   flow). Same shape as at_hash.
122/// * `azp` — M69 (populated whenever multi-aud or whenever the IdP
123///   asserts authorized party — surfaces unconditionally).
124/// * `auth_time` — M70 (gated opt-in by `cfg.max_age`; engine surfaces
125///   regardless of opt-in).
126/// * `acr`, `amr` — M71 (acr step-up; amr authentication-method list).
127/// * `cat` — profile-routing token-category discriminator. PAS issues
128///   id_tokens with `cat="id"` (mirror of access tokens' `cat="access"`
129///   per RFC 9068 §2.2). Symmetric verify-side gate lives in
130///   `engine::check_id_token_cat` (the M29 mirror — refuses a non-`id`
131///   value with `CatMismatch`); membership here is the M72 allowlist
132///   side of the same coin: M72 admits the *key*, the cat-check binds
133///   the *value*. Carrying `cat` on the id_token wire also strengthens
134///   M73 defense in depth — `pas-external::token::jwt::peek_id_token_shape`
135///   reads it as a second signal beyond the `typ="JWT"` header.
136///
137/// Forgetting any of these silently causes M72 to refuse a legitimately
138/// bound token (a token with valid `nonce` / `at_hash` / `c_hash` /
139/// `azp` / `auth_time` / `acr` / `cat` suddenly fails post-10.8 with
140/// `UnknownClaim`). The unit test
141/// `openid_names_includes_all_binding_claims` is the regression guard.
142///
143/// Audit surface only — the engine reads `S::names()` (the per-variant
144/// `NAMES_*` arrays below). The component slices exist so the union
145/// regression tests can prove no claim was silently dropped from a
146/// scope; `#[allow(dead_code)]` is correct outside test builds.
147#[allow(dead_code)]
148pub(crate) const BASE_CLAIMS: &[&str] = &[
149    "iss",
150    "sub",
151    "aud",
152    "exp",
153    "iat",
154    "nonce",
155    "at_hash",
156    "c_hash",
157    "azp",
158    "auth_time",
159    "acr",
160    "amr",
161    "cat",
162];
163
164/// `email` scope claims (OIDC Core §5.4).
165#[allow(dead_code)]
166pub(crate) const EMAIL_CLAIMS: &[&str] = &["email", "email_verified"];
167
168/// `profile` scope claims (OIDC Core §5.4 — name family + locale +
169/// updated_at). Field set matches the accessors in
170/// `claims.rs::impl<S: HasProfile>` exactly.
171#[allow(dead_code)]
172pub(crate) const PROFILE_CLAIMS: &[&str] = &[
173    "name",
174    "given_name",
175    "family_name",
176    "middle_name",
177    "nickname",
178    "preferred_username",
179    "profile",
180    "picture",
181    "website",
182    "gender",
183    "birthdate",
184    "zoneinfo",
185    "locale",
186    "updated_at",
187];
188
189/// `phone` scope claims (OIDC Core §5.4).
190#[allow(dead_code)]
191pub(crate) const PHONE_CLAIMS: &[&str] = &["phone_number", "phone_number_verified"];
192
193/// `address` scope claim (OIDC Core §5.4 — single structured value).
194#[allow(dead_code)]
195pub(crate) const ADDRESS_CLAIMS: &[&str] = &["address"];
196
197// Per-variant unions. Hand-listed (not auto-concatenated) so each
198// allowlist's exact membership is greppable from this file. The unit
199// tests below assert each `NAMES_*` equals the expected union as a set.
200
201static NAMES_OPENID: &[&str] = &[
202    "iss", "sub", "aud", "exp", "iat", "nonce", "at_hash", "c_hash", "azp", "auth_time", "acr", "amr", "cat",
203];
204
205static NAMES_EMAIL: &[&str] = &[
206    "iss", "sub", "aud", "exp", "iat", "nonce", "at_hash", "c_hash", "azp", "auth_time", "acr", "amr", "cat",
207    "email", "email_verified",
208];
209
210static NAMES_PROFILE: &[&str] = &[
211    "iss", "sub", "aud", "exp", "iat", "nonce", "at_hash", "c_hash", "azp", "auth_time", "acr", "amr", "cat",
212    "name", "given_name", "family_name", "middle_name", "nickname", "preferred_username",
213    "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale",
214    "updated_at",
215];
216
217static NAMES_EMAIL_PROFILE: &[&str] = &[
218    "iss", "sub", "aud", "exp", "iat", "nonce", "at_hash", "c_hash", "azp", "auth_time", "acr", "amr", "cat",
219    "email", "email_verified",
220    "name", "given_name", "family_name", "middle_name", "nickname", "preferred_username",
221    "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale",
222    "updated_at",
223];
224
225static NAMES_EMAIL_PROFILE_PHONE: &[&str] = &[
226    "iss", "sub", "aud", "exp", "iat", "nonce", "at_hash", "c_hash", "azp", "auth_time", "acr", "amr", "cat",
227    "email", "email_verified",
228    "name", "given_name", "family_name", "middle_name", "nickname", "preferred_username",
229    "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale",
230    "updated_at",
231    "phone_number", "phone_number_verified",
232];
233
234static NAMES_EMAIL_PROFILE_PHONE_ADDRESS: &[&str] = &[
235    "iss", "sub", "aud", "exp", "iat", "nonce", "at_hash", "c_hash", "azp", "auth_time", "acr", "amr", "cat",
236    "email", "email_verified",
237    "name", "given_name", "family_name", "middle_name", "nickname", "preferred_username",
238    "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale",
239    "updated_at",
240    "phone_number", "phone_number_verified",
241    "address",
242];
243
244// ── Concrete scope structs ──────────────────────────────────────────────
245//
246// Each is a zero-sized type used solely as the `S` parameter on
247// `Claims<S>` and `verify::<S>`. They carry no runtime state — the
248// invariant they witness is "the issuance pipeline emitted a token whose
249// scope claim is a superset of this struct's name".
250
251/// `scope=openid` — the mandatory baseline. No PII accessors.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub struct Openid;
254impl sealed::Sealed for Openid {}
255impl ScopeSet for Openid {
256    fn names() -> &'static [&'static str] {
257        NAMES_OPENID
258    }
259}
260
261/// `scope=openid email`.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub struct Email;
264impl sealed::Sealed for Email {}
265impl ScopeSet for Email {
266    fn names() -> &'static [&'static str] {
267        NAMES_EMAIL
268    }
269}
270impl HasEmail for Email {}
271
272/// `scope=openid profile`.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct Profile;
275impl sealed::Sealed for Profile {}
276impl ScopeSet for Profile {
277    fn names() -> &'static [&'static str] {
278        NAMES_PROFILE
279    }
280}
281impl HasProfile for Profile {}
282
283/// `scope=openid email profile`.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct EmailProfile;
286impl sealed::Sealed for EmailProfile {}
287impl ScopeSet for EmailProfile {
288    fn names() -> &'static [&'static str] {
289        NAMES_EMAIL_PROFILE
290    }
291}
292impl HasEmail for EmailProfile {}
293impl HasProfile for EmailProfile {}
294
295/// `scope=openid email profile phone`.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub struct EmailProfilePhone;
298impl sealed::Sealed for EmailProfilePhone {}
299impl ScopeSet for EmailProfilePhone {
300    fn names() -> &'static [&'static str] {
301        NAMES_EMAIL_PROFILE_PHONE
302    }
303}
304impl HasEmail for EmailProfilePhone {}
305impl HasProfile for EmailProfilePhone {}
306impl HasPhone for EmailProfilePhone {}
307
308/// `scope=openid email profile phone address` — the maximal request.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub struct EmailProfilePhoneAddress;
311impl sealed::Sealed for EmailProfilePhoneAddress {}
312impl ScopeSet for EmailProfilePhoneAddress {
313    fn names() -> &'static [&'static str] {
314        NAMES_EMAIL_PROFILE_PHONE_ADDRESS
315    }
316}
317impl HasEmail for EmailProfilePhoneAddress {}
318impl HasProfile for EmailProfilePhoneAddress {}
319impl HasPhone for EmailProfilePhoneAddress {}
320impl HasAddress for EmailProfilePhoneAddress {}
321
322#[cfg(test)]
323mod tests {
324    //! M72 acceptance — every per-variant `names()` slice must equal
325    //! the union of `BASE_CLAIMS` and the scope-specific PII slices,
326    //! treated as sets (order-insensitive).
327    //!
328    //! The test method is set-equality, not slice-equality: a future
329    //! refactor that reorders members should not require touching these
330    //! tests. Drift in the union itself (a PII claim added to one slice
331    //! but forgotten in `EmailProfilePhoneAddress`) IS what we catch.
332    use super::*;
333    use std::collections::HashSet;
334
335    fn set(slice: &[&'static str]) -> HashSet<&'static str> {
336        slice.iter().copied().collect()
337    }
338
339    fn union(slices: &[&[&'static str]]) -> HashSet<&'static str> {
340        slices.iter().flat_map(|s| s.iter().copied()).collect()
341    }
342
343    #[test]
344    fn openid_names_includes_all_binding_claims() {
345        // Audit guard: every M66/M69/M70/M71 binding claim plus the M29-mirror
346        // profile-routing claim (`cat`, Phase 10.10) MUST appear at base
347        // scope. Forgetting any one means a verified token with valid
348        // nonce/azp/auth_time/acr/cat suddenly fails M72 with UnknownClaim.
349        let names = set(Openid::names());
350        for required in [
351            "iss", "sub", "aud", "exp", "iat",
352            "nonce", "at_hash", "c_hash", "azp", "auth_time", "acr", "amr", "cat",
353        ] {
354            assert!(
355                names.contains(required),
356                "Openid::names() missing binding claim {required:?} — M72 would refuse a valid token"
357            );
358        }
359        assert_eq!(names, set(BASE_CLAIMS));
360    }
361
362    #[test]
363    fn email_names_is_union_of_base_and_email() {
364        assert_eq!(set(Email::names()), union(&[BASE_CLAIMS, EMAIL_CLAIMS]));
365    }
366
367    #[test]
368    fn profile_names_is_union_of_base_and_profile() {
369        assert_eq!(set(Profile::names()), union(&[BASE_CLAIMS, PROFILE_CLAIMS]));
370    }
371
372    #[test]
373    fn email_profile_names_is_union_of_base_email_profile() {
374        assert_eq!(
375            set(EmailProfile::names()),
376            union(&[BASE_CLAIMS, EMAIL_CLAIMS, PROFILE_CLAIMS]),
377        );
378    }
379
380    #[test]
381    fn email_profile_phone_names_is_union_of_base_email_profile_phone() {
382        assert_eq!(
383            set(EmailProfilePhone::names()),
384            union(&[BASE_CLAIMS, EMAIL_CLAIMS, PROFILE_CLAIMS, PHONE_CLAIMS]),
385        );
386    }
387
388    #[test]
389    fn email_profile_phone_address_is_union_of_components() {
390        // Maximal scope: catches drift where a claim is added to one
391        // component slice (e.g. EMAIL_CLAIMS gains `email_locale`) but the
392        // hand-listed NAMES_EMAIL_PROFILE_PHONE_ADDRESS array isn't
393        // updated to match — silent narrowing of the maximal allowlist.
394        assert_eq!(
395            set(EmailProfilePhoneAddress::names()),
396            union(&[
397                BASE_CLAIMS,
398                EMAIL_CLAIMS,
399                PROFILE_CLAIMS,
400                PHONE_CLAIMS,
401                ADDRESS_CLAIMS,
402            ]),
403        );
404    }
405}