Skip to main content

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}