ppoppo_token/id_token/verify_config.rs
1//! Per-request id_token verification configuration.
2//!
3//! ── Composition ────────────────────────────────────────────────────────
4//!
5//! JOSE-shared fields (`issuer`, `audience`, `expected_typ`,
6//! `max_token_size`, `algorithms`) live in `engine::SharedVerifyConfig`
7//! and are reached via `self.shared`. OIDC-specific axes
8//! (`expected_nonce`, `max_age`, `acr_values`) stay on this struct.
9//! Symmetric to `access_token::VerifyConfig`'s replay/session/epoch
10//! ports — neither profile imports the other's policy axes.
11//!
12//! ── Required vs Phase-deferred fields ───────────────────────────────────
13//!
14//! * `expected_nonce` — required at construction. M66 binding is *not*
15//! opt-in: the OIDC profile mandates nonce when the RP sent one in
16//! the auth request, and the engine refuses to verify without one to
17//! prevent a footgun where a misconfigured config silently skips the
18//! gate.
19//! * `max_age` / `acr_values` — `Option<...>` placeholders. Phase 10.6
20//! (M70) and 10.7 (M71) wire their consumption; until then they're
21//! inert. Pre-pinning the field shape now means those rows are
22//! commits that *populate*, not commits that *re-shape* the struct.
23
24use super::Nonce;
25use crate::algorithm::Algorithm;
26use crate::engine::shared_config::SharedVerifyConfig;
27
28#[derive(Debug, Clone)]
29#[allow(dead_code)] // max_age/acr_values consumed in Phase 10.6/10.7
30pub struct VerifyConfig {
31 pub(crate) shared: SharedVerifyConfig,
32
33 /// M66 — RP-stored nonce. Required field; construction goes through
34 /// `VerifyConfig::id_token(...)` which takes a `Nonce`.
35 pub(crate) expected_nonce: Nonce,
36
37 /// M67 — when `Some(token)`, the engine compares the payload's
38 /// `at_hash` claim against `SHA-256(token)[..16]` base64url-no-pad.
39 /// Set via `with_access_token_binding`; populated by RPs that
40 /// receive both id_token and access_token in the same response
41 /// (hybrid + implicit flows, OIDC Core §3.1.3.8). When `None`, the
42 /// engine does not inspect `at_hash` — pure code flow consumers
43 /// (RCW/CTW today) leave this unset.
44 pub(crate) expected_access_token: Option<String>,
45
46 /// M68 — when `Some(code)`, the engine compares the payload's
47 /// `c_hash` claim against `SHA-256(code)[..16]` base64url-no-pad.
48 /// Set via `with_authorization_code_binding`; populated by RPs that
49 /// receive both id_token and authorization_code in the same response
50 /// (hybrid flow, OIDC Core §3.3.2.11). When `None`, the engine does
51 /// not inspect `c_hash`.
52 pub(crate) expected_authorization_code: Option<String>,
53
54 /// Phase 10.6 (M70) — when `Some(n)`, refuses tokens whose
55 /// `auth_time` is more than `n` seconds in the past.
56 pub(crate) max_age: Option<i64>,
57
58 /// Phase 10.7 (M71) — when `Some(values)`, refuses tokens whose
59 /// `acr` is not in `values`.
60 pub(crate) acr_values: Option<Vec<String>>,
61}
62
63impl VerifyConfig {
64 /// Canonical id_token config: `JWT` typ (distinct from access
65 /// token's `at+jwt`), EdDSA-only algorithm whitelist, 8 KB token
66 /// size cap, RP-stored `expected_nonce` required.
67 pub fn id_token(
68 issuer: impl Into<String>,
69 audience: impl Into<String>,
70 expected_nonce: Nonce,
71 ) -> Self {
72 Self {
73 shared: SharedVerifyConfig::new(
74 issuer,
75 audience,
76 "JWT",
77 8 * 1024,
78 vec![Algorithm::EdDSA],
79 ),
80 expected_nonce,
81 expected_access_token: None,
82 expected_authorization_code: None,
83 max_age: None,
84 acr_values: None,
85 }
86 }
87
88 /// Override the algorithm whitelist. Test-only escape hatch —
89 /// production callers MUST go through `id_token` so EdDSA is the
90 /// default, not an override.
91 #[must_use]
92 pub fn with_algorithms(mut self, algorithms: Vec<Algorithm>) -> Self {
93 self.shared.algorithms = algorithms;
94 self
95 }
96
97 /// M67 — bind verification to the access_token the RP just received.
98 /// On verify, the engine asserts
99 /// `payload.at_hash == base64url(SHA-256(access_token)[..16])`.
100 /// Required when the response carries both id_token and access_token
101 /// (hybrid flow `code id_token token`, implicit `id_token token`).
102 /// Pure code flow consumers do not call this.
103 #[must_use]
104 pub fn with_access_token_binding(mut self, access_token: impl Into<String>) -> Self {
105 self.expected_access_token = Some(access_token.into());
106 self
107 }
108
109 /// M68 — bind verification to the authorization code the RP just
110 /// received at the redirect_uri. On verify, the engine asserts
111 /// `payload.c_hash == base64url(SHA-256(code)[..16])`. Required when
112 /// the hybrid response carries both id_token and code
113 /// (`code id_token`, `code id_token token`). Pure implicit-flow
114 /// consumers do not call this.
115 #[must_use]
116 pub fn with_authorization_code_binding(mut self, code: impl Into<String>) -> Self {
117 self.expected_authorization_code = Some(code.into());
118 self
119 }
120
121 /// Phase 10.6 (M70) wire site. Inert in 10.1.C.
122 #[must_use]
123 pub fn with_max_age(mut self, max_age: i64) -> Self {
124 self.max_age = Some(max_age);
125 self
126 }
127
128 /// Phase 10.7 (M71) wire site. Inert in 10.1.C.
129 #[must_use]
130 pub fn with_acr_values(mut self, acr_values: Vec<String>) -> Self {
131 self.acr_values = Some(acr_values);
132 self
133 }
134}