pas_external/oidc/verifier.rs
1//! Production [`IdTokenVerifier`] adapter — verifies PAS-issued OIDC
2//! id_tokens.
3//!
4//! [`PasIdTokenVerifier`] is the only place inside the SDK that knows
5//! the OIDC id_token wire format. It calls
6//! [`ppoppo_token::id_token::verify::<S>`] under a TTL-cached JWKS
7//! ([`crate::JwksCache`], shared type with
8//! [`PasJwtVerifier`](crate::JwtVerifier) per ε1 — separate
9//! instance per verifier, no shared state), maps the engine's
10//! [`Claims<S>`] payload to an SDK-shaped [`IdAssertion<S>`], and
11//! routes any [`AuthError`] to the boundary's [`IdVerifyError`] enum so
12//! consumer logs retain the M-code via Display fallback.
13//!
14//! Single textbook constructor [`Self::from_jwks_url`] — mirror of
15//! [`PasJwtVerifier::from_jwks_url`]. The verifier is generic over
16//! `S: ScopePiiReader`, so a consumer wires
17//! `PasIdTokenVerifier::<scopes::EmailProfile>::from_jwks_url(...)` and
18//! the type system narrows the resulting [`IdAssertion`] accessor
19//! surface to exactly that scope's claims at compile time.
20//!
21//! ## ε1 invariant — separate JwksCache per verifier
22//!
23//! Each verifier ([`PasJwtVerifier`](crate::JwtVerifier),
24//! [`PasIdTokenVerifier<S>`]) constructs its own
25//! [`JwksCache`](crate::JwksCache) via
26//! [`from_jwks_url`]. Two HTTP fetches at startup; two TTL refresh
27//! schedules; isolated state. Cost: one extra HTTP fetch per consumer
28//! at startup (cheap; both endpoints live on `accounts.ppoppo.com`).
29//! Benefit: simpler API surface, no third "JwksProvider" type. ε2
30//! (shared cache) is deferred to a post-launch ops measurement —
31//! see RFC §6.11.1 D-04 and `project_jwt_adoption.md`.
32
33use std::marker::PhantomData;
34use std::str::FromStr;
35use std::sync::Arc;
36
37use async_trait::async_trait;
38use ppoppo_clock::ArcClock;
39use ppoppo_clock::native::WallClock;
40// Engine `VerifyConfig` (id_token profile) is the cryptographic verify
41// config. Aliased to disambiguate from the SDK boundary's
42// `crate::VerifyConfig` (was `Expectations`) — Phase A audit decision G.
43use jiff::Timestamp;
44use ppoppo_token::id_token::{AuthError, Claims, Nonce, VerifyConfig as EngineVerifyConfig};
45use ppoppo_token::SharedAuthError;
46
47use crate::JwksCache;
48use crate::VerifyConfig;
49use crate::types::PpnumId;
50
51use super::port::{IdAssertion, IdTokenVerifier, IdVerifyError, ScopePiiReader};
52
53/// PAS OIDC id_token verifier (RFC 9068 + OIDC Core 1.0, EdDSA).
54///
55/// Constructed once per consumer deployment with the JWKS URL and the
56/// per-deployment [`VerifyConfig`]. Cheap to clone — internal state is
57/// `Arc`-shared, so you can store the verifier behind
58/// `Arc<dyn IdTokenVerifier<S>>` in a request extension or per-route
59/// layer without measurable overhead.
60///
61/// `JwksCache` and `ArcClock` have no useful `Debug` representation,
62/// so this is a manual impl that surfaces only the expectations shape.
63///
64/// As of 0.8.0 the containing `oidc::verifier` module is `pub(crate)`.
65/// Production consumers reach the verify-half through
66/// [`super::RelyingParty<S>::complete`], which constructs and consumes
67/// this verifier internally. SDK boundary tests + downstream consumer
68/// integration tests reach the type via the
69/// [`crate::test_support::PasIdTokenVerifier`] re-export under the
70/// `test-support` feature gate.
71#[derive(Clone)]
72pub struct PasIdTokenVerifier<S: ScopePiiReader> {
73 jwks: JwksCache,
74 expectations: VerifyConfig,
75 clock: ArcClock,
76 _scope: PhantomData<S>,
77}
78
79impl<S: ScopePiiReader> std::fmt::Debug for PasIdTokenVerifier<S> {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("PasIdTokenVerifier")
82 .field("expectations", &self.expectations)
83 .finish_non_exhaustive()
84 }
85}
86
87impl<S: ScopePiiReader> PasIdTokenVerifier<S> {
88 /// Single textbook constructor — mirror of
89 /// [`PasJwtVerifier::from_jwks_url`](crate::JwtVerifier::from_jwks_url).
90 /// Performs an initial JWKS fetch + cache; subsequent verifications
91 /// snapshot the cache, refreshing on TTL expiry.
92 ///
93 /// # Errors
94 ///
95 /// Returns [`IdVerifyError::KeysetUnavailable`] if the initial JWKS
96 /// fetch fails. The verifier cannot serve verifications without at
97 /// least one usable key snapshot.
98 pub async fn from_jwks_url(
99 jwks_url: impl Into<String>,
100 expectations: VerifyConfig,
101 ) -> Result<Self, IdVerifyError> {
102 let jwks = JwksCache::fetch(jwks_url)
103 .await
104 .map_err(|_| IdVerifyError::KeysetUnavailable)?;
105 Ok(Self {
106 jwks,
107 expectations,
108 clock: Arc::new(WallClock),
109 _scope: PhantomData,
110 })
111 }
112
113 /// Test-support ctor — constructs a verifier without performing a
114 /// JWKS fetch. Mirror of
115 /// [`PasJwtVerifier::for_test_skip_fetch`](crate::JwtVerifier::for_test_skip_fetch).
116 ///
117 /// The internal keyset is empty, so any engine verify path rejects
118 /// on `KidUnknown` (mapped to [`IdVerifyError::SignatureInvalid`]
119 /// per [`map_auth_error`]). Adapter-side rejection paths
120 /// ([`IdVerifyError::InvalidFormat`]) are fully exercisable since
121 /// they reject before consulting the keyset.
122 ///
123 /// Used by Phase 10.11.E's boundary tests to drive the verify path
124 /// without a wiremock-shaped JWKS endpoint. NOT for production use
125 /// — use [`Self::from_jwks_url`] instead.
126 #[cfg(any(test, feature = "test-support"))]
127 #[must_use]
128 pub fn for_test_skip_fetch(expectations: VerifyConfig) -> Self {
129 Self {
130 jwks: JwksCache::for_test_empty(),
131 expectations,
132 clock: Arc::new(WallClock),
133 _scope: PhantomData,
134 }
135 }
136}
137
138#[async_trait]
139impl<S: ScopePiiReader> IdTokenVerifier<S> for PasIdTokenVerifier<S> {
140 async fn verify(
141 &self,
142 id_token: &str,
143 expected_nonce: &Nonce,
144 ) -> Result<IdAssertion<S>, IdVerifyError> {
145 // Adapter-side reject before engine entry — JWS Compact has
146 // exactly three segments separated by `.`. Mirror of
147 // `PasJwtVerifier::verify`'s upstream reject; rejecting at the
148 // SDK boundary keeps the rejection signal cleaner ("malformed
149 // at SDK boundary" vs "engine M07/M11/M15").
150 if id_token.is_empty() || !looks_like_jws_compact(id_token) {
151 return Err(IdVerifyError::InvalidFormat);
152 }
153
154 // No M73-equivalent guard here — id_token IS what this verifier
155 // expects. The engine's M29-mirror `cat="id"` check (Phase
156 // 10.10.D) catches access_tokens presented to the id_token
157 // verifier; that refusal surfaces as
158 // `IdVerifyError::CatMismatch(_)` post-engine.
159
160 let cfg = EngineVerifyConfig::id_token(
161 self.expectations.issuer.clone(),
162 self.expectations.audience.clone(),
163 expected_nonce.clone(),
164 );
165 let keyset = self.jwks.snapshot().await;
166 let now = self.clock.now().as_second();
167 let claims = ppoppo_token::id_token::verify::<S>(id_token, &cfg, &keyset, now)
168 .await
169 .map_err(IdVerifyError::from)?;
170
171 claims_to_assertion::<S>(claims)
172 }
173}
174
175fn looks_like_jws_compact(token: &str) -> bool {
176 token.split('.').count() == 3
177}
178
179/// Convert engine [`Claims<S>`] to SDK [`IdAssertion<S>`].
180///
181/// Mirror of `claims_to_auth_session` in `token::jwt`. Parses the
182/// stable subject identifier (ULID), converts Unix-timestamp times to
183/// [`Timestamp`], and lays out base fields. Per-scope PII is
184/// layered in via [`ScopePiiReader::fill_pii`] — the trait's per-scope
185/// impls compose the four `fill_*` helpers in `oidc::port`.
186///
187/// # Errors
188///
189/// - [`IdVerifyError::MissingClaim`] — `sub` is not a parseable ULID,
190/// `exp`/`iat` are out of representable range. The engine's
191/// `Claims<S>` always populates these from the wire, so reaching
192/// this error means an issuer drift (or a verifier-engine version
193/// skew, which `Cargo.toml` workspace pinning prevents).
194fn claims_to_assertion<S: ScopePiiReader>(
195 claims: Claims<S>,
196) -> Result<IdAssertion<S>, IdVerifyError> {
197 let sub = ulid::Ulid::from_str(&claims.sub)
198 .map(PpnumId)
199 .map_err(|_| IdVerifyError::MissingClaim("sub"))?;
200
201 let exp = Timestamp::from_second(claims.exp)
202 .map_err(|_| IdVerifyError::MissingClaim("exp"))?;
203 let iat = Timestamp::from_second(claims.iat)
204 .map_err(|_| IdVerifyError::MissingClaim("iat"))?;
205
206 let auth_time = match claims.auth_time {
207 Some(ts) => Some(
208 Timestamp::from_second(ts)
209 .map_err(|_| IdVerifyError::MissingClaim("auth_time"))?,
210 ),
211 None => None,
212 };
213
214 let mut assertion = IdAssertion::<S>::new_base(
215 claims.iss.clone(),
216 sub,
217 claims.aud.clone(),
218 exp,
219 iat,
220 claims.nonce.clone(),
221 claims.azp.clone(),
222 auth_time,
223 claims.acr.clone(),
224 claims.amr.clone(),
225 );
226
227 S::fill_pii(&claims, &mut assertion);
228 Ok(assertion)
229}
230
231/// Map engine [`AuthError`] to the SDK boundary [`IdVerifyError`].
232///
233/// id_token-specific rows (M66-M73 + M29-mirror `CatMismatch`) map
234/// 1:1 to the dedicated SDK variants. JOSE-layer rejections (carried
235/// via the engine's `Jose(SharedAuthError)` carrier) collapse to the
236/// shared `SignatureInvalid` / `Expired` / `IssuerInvalid` /
237/// `AudienceInvalid` / `MissingClaim` / `InvalidFormat` variants —
238/// consumer logs retain the precise M-row identifier via the engine's
239/// Display when needed.
240fn map_auth_error(err: AuthError) -> IdVerifyError {
241 use AuthError as E;
242 use SharedAuthError as S;
243 match err {
244 // ── OIDC-specific (M66-M73 + M29-mirror) ─────────────────────
245 E::NonceMissing => IdVerifyError::NonceMissing,
246 E::NonceMismatch => IdVerifyError::NonceMismatch,
247 E::NonceConfigEmpty => IdVerifyError::MissingClaim("nonce"),
248 E::AtHashMissing => IdVerifyError::AtHashMissing,
249 E::AtHashMismatch => IdVerifyError::AtHashMismatch,
250 E::CHashMissing => IdVerifyError::CHashMissing,
251 E::CHashMismatch => IdVerifyError::CHashMismatch,
252 E::AzpMissing => IdVerifyError::AzpMissing,
253 E::AzpMismatch => IdVerifyError::AzpMismatch,
254 E::AuthTimeMissing => IdVerifyError::AuthTimeMissing,
255 E::AuthTimeStale => IdVerifyError::AuthTimeStale,
256 E::AcrMissing => IdVerifyError::AcrMissing,
257 E::AcrNotAllowed => IdVerifyError::AcrNotAllowed,
258 E::UnknownClaim(name) => IdVerifyError::UnknownClaim(name),
259 E::CatMismatch(value) => IdVerifyError::CatMismatch(value),
260
261 // ── JOSE-layer (shared with access_token, mirrors token::jwt) ─
262 E::Jose(
263 S::AlgNone
264 | S::AlgNotWhitelisted
265 | S::AlgHmacRejected
266 | S::AlgRsaRejected
267 | S::AlgEcdsaRejected
268 | S::HeaderJku
269 | S::HeaderX5u
270 | S::HeaderJwk
271 | S::HeaderX5c
272 | S::HeaderCrit
273 | S::HeaderExtraParam
274 | S::HeaderB64False
275 | S::KidUnknown
276 | S::TypMismatch
277 | S::NestedJws
278 | S::DuplicateJsonKeys
279 | S::HeaderUnparseable
280 | S::PayloadUnparseable
281 | S::NotJwsCompact,
282 ) => IdVerifyError::SignatureInvalid,
283 E::Jose(
284 S::OversizedToken | S::JwsJsonRejected | S::JwePayload | S::LaxBase64,
285 ) => IdVerifyError::InvalidFormat,
286 // Note: id_token::AuthError is currently exhaustively covered
287 // by the OIDC-specific arms above + the two Jose arms (every
288 // SharedAuthError variant carries through one of the Jose
289 // patterns). When the engine adds a new outer variant in a
290 // future phase, this match will fail to compile — that is the
291 // intended forward-compat signal: the SDK gets the new
292 // `IdVerifyError` row paired with the engine variant in the
293 // same PR rather than silently swallowing it via a catch-all.
294 }
295}
296
297impl From<AuthError> for IdVerifyError {
298 fn from(err: AuthError) -> Self {
299 map_auth_error(err)
300 }
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used, clippy::expect_used)]
305mod tests {
306 //! Verifier-internal unit tests. Boundary-test coverage
307 //! (MemoryIdTokenVerifier round-trip, compile_fail narrowing) lives
308 //! in `tests/id_token_verifier_boundary.rs` and lands with Phase
309 //! 10.11.E.
310 use super::*;
311 use ppoppo_token::id_token::scopes;
312
313 #[tokio::test]
314 async fn from_jwks_url_with_invalid_url_yields_keyset_unavailable() {
315 // .invalid is RFC 6761-reserved as guaranteed-unresolvable; the
316 // initial fetch in `JwksCache::fetch` must fail. The typed
317 // `KeysetUnavailable` variant is the audit-log signal that
318 // distinguishes "we couldn't reach the IdP" from "the IdP said no."
319 let result = PasIdTokenVerifier::<scopes::Openid>::from_jwks_url(
320 "http://nonexistent.invalid/.well-known/jwks.json",
321 VerifyConfig::new("accounts.ppoppo.com", "rp-client-id"),
322 )
323 .await;
324 let err = result.expect_err("bad URL must fail construction");
325 assert_eq!(err, IdVerifyError::KeysetUnavailable);
326 }
327
328 #[tokio::test]
329 async fn verify_empty_token_yields_invalid_format() {
330 let verifier = PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(
331 VerifyConfig::new("accounts.ppoppo.com", "rp-client-id"),
332 );
333 let nonce = Nonce::new("test-nonce").unwrap();
334 let err = verifier.verify("", &nonce).await.expect_err("empty rejects");
335 assert_eq!(err, IdVerifyError::InvalidFormat);
336 }
337
338 #[tokio::test]
339 async fn verify_two_segment_token_yields_invalid_format() {
340 // JWS Compact requires exactly three segments. A two-segment
341 // token must reject at the SDK boundary, not reach the engine.
342 let verifier = PasIdTokenVerifier::<scopes::Openid>::for_test_skip_fetch(
343 VerifyConfig::new("accounts.ppoppo.com", "rp-client-id"),
344 );
345 let nonce = Nonce::new("test-nonce").unwrap();
346 let err = verifier
347 .verify("aaa.bbb", &nonce)
348 .await
349 .expect_err("2-segment rejects");
350 assert_eq!(err, IdVerifyError::InvalidFormat);
351 }
352
353 #[test]
354 fn from_auth_error_covers_oidc_specific_rows() {
355 // Sample of M66-M73 + M29-mirror — every dedicated variant
356 // routes to the matching IdVerifyError row.
357 assert_eq!(
358 IdVerifyError::from(AuthError::NonceMissing),
359 IdVerifyError::NonceMissing
360 );
361 assert_eq!(
362 IdVerifyError::from(AuthError::AzpMismatch),
363 IdVerifyError::AzpMismatch
364 );
365 assert_eq!(
366 IdVerifyError::from(AuthError::CatMismatch("access".to_owned())),
367 IdVerifyError::CatMismatch("access".to_owned())
368 );
369 assert_eq!(
370 IdVerifyError::from(AuthError::UnknownClaim("backdoor".to_owned())),
371 IdVerifyError::UnknownClaim("backdoor".to_owned())
372 );
373 // NonceConfigEmpty (engine construction-time invariant) maps
374 // to MissingClaim — verify-time SDK contract is "the nonce
375 // claim is required and the value passed in must be non-empty".
376 assert_eq!(
377 IdVerifyError::from(AuthError::NonceConfigEmpty),
378 IdVerifyError::MissingClaim("nonce")
379 );
380 }
381
382 #[test]
383 fn from_auth_error_collapses_jose_to_signature_invalid_or_invalid_format() {
384 // JOSE algorithm + header rejections collapse to SignatureInvalid
385 // (mirrors `token::jwt::map_auth_error`). Serialization-shape
386 // rejections collapse to InvalidFormat.
387 assert_eq!(
388 IdVerifyError::from(AuthError::Jose(SharedAuthError::AlgNone)),
389 IdVerifyError::SignatureInvalid
390 );
391 assert_eq!(
392 IdVerifyError::from(AuthError::Jose(SharedAuthError::KidUnknown)),
393 IdVerifyError::SignatureInvalid
394 );
395 assert_eq!(
396 IdVerifyError::from(AuthError::Jose(SharedAuthError::TypMismatch)),
397 IdVerifyError::SignatureInvalid
398 );
399 assert_eq!(
400 IdVerifyError::from(AuthError::Jose(SharedAuthError::OversizedToken)),
401 IdVerifyError::InvalidFormat
402 );
403 assert_eq!(
404 IdVerifyError::from(AuthError::Jose(SharedAuthError::JwsJsonRejected)),
405 IdVerifyError::InvalidFormat
406 );
407 }
408}