Skip to main content

ppoppo_sdk_core/verifier/
jwt.rs

1//! Production [`BearerVerifier`] adapter — verifies PAS-issued JWTs.
2//!
3//! [`JwtVerifier`] is the only place inside the SDK that knows the
4//! token format. It calls [`ppoppo_token::access_token::verify`] under a TTL-cached
5//! JWKS ([`super::keyset::JwksCache`]), maps the engine's [`Claims`]
6//! payload to an SDK-shaped [`VerifiedClaims`], and routes any
7//! [`ppoppo_token::access_token::AuthError`] to the boundary's [`VerifyError`]
8//! enum so audit logs retain the M-code via Display fallback.
9//!
10//! Single textbook constructor [`Self::from_jwks_url`] — Findings 2/3
11//! collapsed `KeySet` and `with_config` out of the public surface.
12
13use std::collections::BTreeMap;
14use std::str::FromStr;
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use jiff::Timestamp;
19use ppoppo_clock::ArcClock;
20use ppoppo_clock::native::WallClock;
21
22// Engine `VerifyConfig` is the cryptographic verify config (audience,
23// issuer, required EpochEnforcement stance). Aliased to disambiguate from
24// the SDK boundary's [`super::VerifyConfig`] (was `Expectations`) which
25// carries the per-deployment iss/aud expectations folded into the SDK
26// verifier at construction.
27use ppoppo_token::access_token::{
28    AuthError, Claims, EpochEnforcement, VerifyConfig as EngineVerifyConfig,
29};
30use ppoppo_token::SharedAuthError;
31use super::jwks_cache::JwksCache;
32use super::{BearerVerifier, VerifiedClaims, VerifyConfig, VerifyError};
33use crate::audit::{AuditEvent, AuditSink, VerifyErrorKind};
34use crate::session_liveness::{SessionLiveness, SessionLivenessError};
35use crate::types::{Ppnum, PpnumId, SessionId};
36
37/// PAS JWT verifier (RFC 9068, EdDSA).
38///
39/// Constructed once per consumer deployment with the JWKS URL and the
40/// per-deployment [`VerifyConfig`]. Cheap to clone — internal state is
41/// `Arc`-shared, so you can store the verifier behind
42/// `Arc<dyn BearerVerifier>` in a request extension or per-route layer
43/// without measurable overhead.
44///
45/// `JwksCache` and `ArcClock` have no useful `Debug` representation,
46/// so this is a manual impl that surfaces only the expectations shape.
47#[derive(Clone)]
48pub struct JwtVerifier {
49    jwks: JwksCache,
50    expectations: VerifyConfig,
51    clock: ArcClock,
52    /// M48 audit emission port. `None` (the default from
53    /// [`Self::from_jwks_url`]) means the verifier silently skips
54    /// audit emission on failure — matching pre-Phase-9 behavior so
55    /// existing consumers (RCW / CTW today) are unaffected. Opt in
56    /// with [`Self::with_audit`].
57    audit_sink: Option<Arc<dyn AuditSink>>,
58    /// Required sv-axis stance (RFC_202607150428 Q3 — the former
59    /// `Option<Arc<dyn EpochRevocation>>` silent default is retired).
60    /// [`EpochEnforcement::Enforced`] gates every verify through the
61    /// engine's `check_epoch`; the named non-`Enforced` stances skip
62    /// the gate but are declared at construction via
63    /// [`EpochEnforcement::declare`] (boot log visibility).
64    epoch: EpochEnforcement,
65    /// Phase 11.Z 0.10.0 (RFC_2026-05-08 §4.2 lock) — L2
66    /// [`SessionLiveness`] port for per-request session-row revocation
67    /// enforcement. `None` (the default) means the verifier
68    /// short-circuits the L2 check — every verify admits past the
69    /// session-row axis. Opt in with [`Self::with_session_liveness`]
70    /// to enforce per-session logout / `LogoutAll` row-revocation.
71    /// Skipped silently when the bearer's `sid` claim is `None`
72    /// (machine credentials, AI-agent flows — lenient per the
73    /// existing [`VerifiedClaims::session_id`] invariant).
74    session_liveness: Option<Arc<dyn SessionLiveness>>,
75}
76
77impl std::fmt::Debug for JwtVerifier {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("JwtVerifier")
80            .field("expectations", &self.expectations)
81            .finish_non_exhaustive()
82    }
83}
84
85impl JwtVerifier {
86    /// Single textbook constructor — collapses {JWKS construction,
87    /// VerifyConfig, VerifyConfig} into one boundary call.
88    ///
89    /// Consumer never sees the underlying `JwksCache`. The default
90    /// engine `VerifyConfig::access_token(issuer, audience)` covers
91    /// every concrete RCW/CTW/3rd-party scenario.
92    ///
93    /// **Required stance**: `epoch` is the sv-axis [`EpochEnforcement`]
94    /// declaration (RFC_202607150428 Q3). There is no default — a
95    /// consumer that does not enforce the axis must pass a NAMED
96    /// [`EpochEnforcement::Unenforced`] (WARN-declared at boot) or
97    /// [`EpochEnforcement::EnforcedElsewhere`]; production perimeters
98    /// pass [`EpochEnforcement::Enforced`] over the canonical
99    /// `Cache + Fetcher` composition (`pas_external::epoch::
100    /// CompositeEpochRevocation`).
101    ///
102    /// **Builder family**:
103    ///
104    /// - [`Self::with_audit`] — wire an [`AuditSink`] for M48
105    ///   verify-failure emission (Phase 9). Defaults to no emission;
106    ///   wrap in [`crate::RateLimitedAuditSink`] for log-flood DoS
107    ///   defense (M49).
108    /// - [`Self::with_session_liveness`] — L2 session-row revocation.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`VerifyError::KeysetUnavailable`] if the initial JWKS
113    /// fetch fails. The verifier cannot serve verifications without
114    /// at least one usable key snapshot.
115    pub async fn from_jwks_url(
116        jwks_url: impl Into<String>,
117        expectations: VerifyConfig,
118        epoch: EpochEnforcement,
119    ) -> Result<Self, VerifyError> {
120        let jwks = JwksCache::fetch(jwks_url).await?;
121        epoch.declare(&format!("JwtVerifier[aud={}]", expectations.audience));
122        Ok(Self {
123            jwks,
124            expectations,
125            clock: Arc::new(WallClock),
126            audit_sink: None,
127            epoch,
128            session_liveness: None,
129        })
130    }
131
132    /// Wire an audit sink for M48 verify-failure emission.
133    ///
134    /// Every Err path inside [`BearerVerifier::verify`] will, after
135    /// this builder, also emit an [`AuditEvent`] through the supplied
136    /// sink. The sink is expected to be cheap (`Arc`-shared); it is
137    /// not consulted on the success path.
138    ///
139    /// **Composition**: for log-flood DoS defense (M49), wrap the
140    /// real sink in [`crate::RateLimitedAuditSink`] before passing
141    /// here:
142    ///
143    /// ```ignore
144    /// use std::sync::Arc;
145    /// use pas_external::{
146    ///     AuditSink, MemoryRateLimiter, JwtVerifier, RateLimitedAuditSink,
147    /// };
148    /// # async fn wire(real_sink: Arc<dyn AuditSink>) -> Result<(), Box<dyn std::error::Error>> {
149    /// let limited: Arc<dyn AuditSink> = Arc::new(RateLimitedAuditSink::new(
150    ///     real_sink,
151    ///     Arc::new(MemoryRateLimiter::default()),
152    /// ));
153    /// let verifier = JwtVerifier::from_jwks_url(
154    ///     "https://accounts.ppoppo.com/.well-known/jwks.json",
155    ///     pas_external::VerifyConfig::new("accounts.ppoppo.com", "client-id"),
156    ///     pas_external::EpochEnforcement::Unenforced {
157    ///         reason: "doc example — pass Enforced(port) in production",
158    ///     },
159    /// )
160    /// .await?
161    /// .with_audit(limited);
162    /// # let _ = verifier;
163    /// # Ok(()) }
164    /// ```
165    ///
166    /// Calling repeatedly replaces the sink (no chaining); the last
167    /// call wins. By design, only one sink per verifier — composition
168    /// for multiple destinations belongs in the adapter layer
169    /// (a `FanoutAuditSink` is a future possibility).
170    /// Inject an [`ArcClock`] for deterministic time control.
171    ///
172    /// Sets the clock used for:
173    /// - `now: i64` passed to the token engine on every verify call
174    /// - `created_at` timestamp in [`AuditEvent`] failure records
175    /// - JWKS staleness check in the underlying [`JwksCache`]
176    ///
177    /// Defaults to [`WallClock`]. Tests inject `FrozenClock` to
178    /// control time-sensitive behavior without sleeping.
179    ///
180    /// This builder is on `JwtVerifier` only (NOT on `BearerVerifier`
181    /// trait) — no breaking change for consumers holding
182    /// `Arc<dyn BearerVerifier>`.
183    #[must_use]
184    pub fn with_clock(mut self, clock: ArcClock) -> Self {
185        self.jwks = self.jwks.with_clock(clock.clone());
186        self.clock = clock;
187        self
188    }
189
190    #[must_use]
191    pub fn with_audit(mut self, sink: Arc<dyn AuditSink>) -> Self {
192        self.audit_sink = Some(sink);
193        self
194    }
195
196    /// Wire the L2 [`SessionLiveness`] port for per-request session-row
197    /// revocation enforcement (Phase 11.Z 0.10.0, RFC_2026-05-08 §4.2
198    /// lock). The L2 sibling of the required L1 `EpochEnforcement` ctor stance.
199    ///
200    /// With no port wired, the verifier short-circuits the L2 check —
201    /// every token admits past the session-row axis (matching pre-0.10.0
202    /// behavior, where L2 was inline-composed in the consumer's
203    /// `AuthProvider::verify_token`).
204    ///
205    /// With a port wired, every verify call (after engine signature +
206    /// claims + L1 sv-axis succeed) consults `port.check(sid)` for the
207    /// bearer's `sid` claim:
208    ///
209    /// - `Ok(())` → admit.
210    /// - `Err(`[`SessionLivenessError::Revoked`]`)` → reject as
211    ///   [`VerifyError::SessionRevoked`] (HTTP 401 + clear cookies in
212    ///   the perimeter).
213    /// - `Err(`[`SessionLivenessError::Transient`]`)` → reject as
214    ///   [`VerifyError::SessionLivenessLookupUnavailable`] (HTTP 503,
215    ///   fail-closed per `STANDARDS_AUTH_INVALIDATION` §3).
216    ///
217    /// **Lenient on no-`sid` claim**: tokens without `sid` (machine
218    /// credentials, AI-agent flows, R6 legacy admit per
219    /// [`VerifiedClaims::session_id`]) admit without consulting the port.
220    /// Non-session-bound tokens have no row to look up. RFC_2026-05-08
221    /// §4.2 lock decision.
222    ///
223    /// Canonical wiring shape — RCW post-0.10.0:
224    ///
225    /// ```ignore
226    /// use std::sync::Arc;
227    /// use pas_external::SessionLiveness;
228    ///
229    /// # async fn wire(
230    /// #     verifier: pas_external::JwtVerifier,
231    /// #     liveness: Arc<dyn SessionLiveness>,
232    /// # ) -> pas_external::JwtVerifier {
233    /// // RCW's PgSessionLiveness adapter against scrcall.user_sessions.
234    /// // CTW mirror: same shape over scctime.user_sessions.
235    /// verifier.with_session_liveness(liveness)
236    /// # }
237    /// ```
238    ///
239    /// Calling repeatedly replaces the port (no chaining); the last
240    /// call wins.
241    #[must_use]
242    pub fn with_session_liveness(mut self, port: Arc<dyn SessionLiveness>) -> Self {
243        self.session_liveness = Some(port);
244        self
245    }
246
247    /// Test-support ctor — constructs a verifier without performing
248    /// a JWKS fetch.
249    ///
250    /// The internal keyset is empty, so any engine verify path
251    /// rejects on `KidUnknown` (mapped to
252    /// [`VerifyError::SignatureInvalid`] per `map_auth_error`).
253    /// Adapter-side rejection paths
254    /// ([`VerifyError::InvalidFormat`], [`VerifyError::IdTokenAsBearer`])
255    /// are fully exercisable since they reject before consulting the
256    /// keyset.
257    ///
258    /// Used by Phase 9.D's audit-emission integration tests to drive
259    /// the verify path without a wiremock-shaped JWKS endpoint.
260    /// NOT for production use — use [`Self::from_jwks_url`] instead.
261    #[cfg(any(test, feature = "test-support"))]
262    #[must_use]
263    pub fn for_test_skip_fetch(expectations: VerifyConfig) -> Self {
264        Self {
265            jwks: JwksCache::for_test_empty(),
266            expectations,
267            clock: Arc::new(WallClock),
268            audit_sink: None,
269            // Provably inert: the empty keyset rejects every token at the
270            // signature step, BEFORE the engine's epoch gate — no epoch
271            // stance is reachable from this ctor. Declared, not silent.
272            epoch: EpochEnforcement::Unenforced {
273                reason: "test ctor (empty keyset — epoch gate unreachable)",
274            },
275            session_liveness: None,
276        }
277    }
278
279    /// Test-support ctor with a **caller-supplied keyset**, so `verify` can run
280    /// a full signature-passing path — reaching engine checks that run *after*
281    /// signature verification, the `sv` epoch gate in particular — without a
282    /// wiremock JWKS endpoint. Pair with `ppoppo_token::SigningKey::test_pair()`
283    /// to mint tokens the returned verifier will accept.
284    #[cfg(any(test, feature = "test-support"))]
285    #[must_use]
286    pub fn for_test_with_keys(
287        expectations: VerifyConfig,
288        keyset: ppoppo_token::KeySet,
289        epoch: EpochEnforcement,
290    ) -> Self {
291        Self {
292            jwks: JwksCache::for_test_with_keys(keyset),
293            expectations,
294            clock: Arc::new(WallClock),
295            audit_sink: None,
296            epoch,
297            session_liveness: None,
298        }
299    }
300
301    /// Internal: emit an audit event before returning a `VerifyError`.
302    ///
303    /// Returns the original `err` so call sites stay terse:
304    ///
305    /// ```ignore
306    /// return Err(self.emit_failure(token, VerifyError::InvalidFormat).await);
307    /// ```
308    ///
309    /// When no sink is wired, this is a no-op that returns the error
310    /// directly. The `Option` check happens before any token-decode
311    /// work so the no-audit path stays zero-overhead.
312    async fn emit_failure(&self, bearer_token: &str, err: VerifyError) -> VerifyError {
313        let Some(sink) = self.audit_sink.as_ref() else {
314            return err;
315        };
316
317        let kind = VerifyErrorKind::from(&err);
318        let (client_id_hint, kid_hint) = peek_token_hints(bearer_token);
319        let mut metadata = BTreeMap::new();
320        if let VerifyError::Other(msg) = &err {
321            metadata.insert(
322                "engine_msg".to_owned(),
323                serde_json::Value::String(msg.clone()),
324            );
325        }
326        let event = AuditEvent::from_hints(
327            kind,
328            self.clock.now(),
329            client_id_hint,
330            kid_hint,
331            metadata,
332        );
333        sink.record_failure(event).await;
334        err
335    }
336}
337
338/// Map [`VerifyError`] to the audit-layer kind enum.
339///
340/// Lives here (in `token/jwt.rs`, gated by `well-known-fetch`) rather
341/// than in `audit/sink.rs` to preserve the dependency direction: the
342/// always-compiled audit module knows nothing about [`VerifyError`]
343/// (which is gated behind `feature = "token"`); the JWT adapter, which
344/// produces [`VerifyError`], maps to the audit-side kind enum at the
345/// boundary. Inverting this would force `feature = "token"` to become
346/// transitively required for the audit module, which is the opposite
347/// of Phase 9 refinement #3.
348impl From<&VerifyError> for VerifyErrorKind {
349    fn from(err: &VerifyError) -> Self {
350        match err {
351            VerifyError::InvalidFormat => Self::InvalidFormat,
352            VerifyError::SignatureInvalid => Self::SignatureInvalid,
353            VerifyError::Expired => Self::Expired,
354            VerifyError::IssuerInvalid => Self::IssuerInvalid,
355            VerifyError::AudienceInvalid => Self::AudienceInvalid,
356            VerifyError::MissingClaim(claim) => Self::MissingClaim((*claim).to_owned()),
357            VerifyError::KeysetUnavailable => Self::KeysetUnavailable,
358            VerifyError::IdTokenAsBearer => Self::IdTokenAsBearer,
359            VerifyError::SessionVersionStale => Self::SessionVersionStale,
360            VerifyError::SessionVersionLookupUnavailable => {
361                Self::SessionVersionLookupUnavailable
362            }
363            VerifyError::SessionRevoked => Self::SessionRevoked,
364            VerifyError::SessionLivenessLookupUnavailable => {
365                Self::SessionLivenessLookupUnavailable
366            }
367            VerifyError::Other(_) => Self::Other,
368        }
369    }
370}
371
372/// Best-effort decode the rejected token's `client_id` (payload) and
373/// `kid` (header) for audit-event hinting.
374///
375/// Returns `(None, None)` for any token that fails to parse — the
376/// token has already been rejected by definition, so its claims are
377/// untrusted; consumers MUST NOT treat absence as a security signal.
378/// The hints exist purely for grouping / dashboard pivot.
379///
380/// Defensive decode mirrors [`peek_id_token_shape`] — no panics on
381/// malformed input. A garbage token returns `(None, None)`, which
382/// composes via [`crate::audit::compose_source_id`] into the
383/// `"anon::nokid"` canonical bucket.
384fn peek_token_hints(token: &str) -> (Option<String>, Option<String>) {
385    use base64::Engine as _;
386
387    let mut parts = token.split('.');
388    let header_b64 = parts.next();
389    let payload_b64 = parts.next();
390
391    let kid_hint = header_b64.and_then(|h| {
392        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
393            .decode(h)
394            .ok()?;
395        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
396        value
397            .get("kid")
398            .and_then(|k| k.as_str())
399            .map(|s| s.to_owned())
400    });
401
402    let client_id_hint = payload_b64.and_then(|p| {
403        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
404            .decode(p)
405            .ok()?;
406        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
407        value
408            .get("client_id")
409            .and_then(|c| c.as_str())
410            .map(|s| s.to_owned())
411    });
412
413    (client_id_hint, kid_hint)
414}
415
416#[async_trait]
417impl BearerVerifier for JwtVerifier {
418    async fn verify(&self, bearer_token: &str) -> Result<VerifiedClaims, VerifyError> {
419        // Adapter-side reject before engine entry — JWS Compact has
420        // exactly three segments separated by `.`. The engine also
421        // checks structurally, but rejecting upstream keeps the audit
422        // log signal cleaner ("malformed at SDK boundary" vs "engine
423        // M07/M11/M15").
424        if bearer_token.is_empty() || !looks_like_jws_compact(bearer_token) {
425            return Err(self.emit_failure(bearer_token, VerifyError::InvalidFormat).await);
426        }
427
428        // M73 — id_token presented as a Bearer. Refuse BEFORE engine
429        // entry so the audit log gets a dedicated `IdTokenAsBearer`
430        // signal instead of being masked by the engine's
431        // `TypMismatch → SignatureInvalid` collapse (γ1 — defense in
432        // depth: check both `typ="JWT"` header and `cat="id"` payload).
433        // Order matters: a token with valid signature would otherwise
434        // fail at `check_header::run` and surface as `SignatureInvalid`,
435        // hiding the developer-misuse signal.
436        if peek_id_token_shape(bearer_token) {
437            return Err(self
438                .emit_failure(bearer_token, VerifyError::IdTokenAsBearer)
439                .await);
440        }
441
442        let cfg = EngineVerifyConfig::access_token(
443            self.expectations.issuer.clone(),
444            self.expectations.audience.clone(),
445            self.epoch.clone(),
446        );
447        let keyset = self.jwks.snapshot().await;
448        let now = self.clock.now().as_second();
449        let claims = match ppoppo_token::access_token::verify(bearer_token, &cfg, &keyset, now).await {
450            Ok(c) => c,
451            Err(e) => {
452                let mapped = map_auth_error(e, &self.expectations);
453                return Err(self.emit_failure(bearer_token, mapped).await);
454            }
455        };
456
457        // Phase 11.Z 0.10.0 — L2 session-row liveness check
458        // (RFC_2026-05-08 §4.2 lock). Lenient on no-`sid`: tokens
459        // without a `sid` claim admit without consulting the port,
460        // matching the VerifiedClaims::session_id None invariant for
461        // machine credentials / AI-agent flows / R6 legacy admit.
462        if let Some(port) = self.session_liveness.as_ref()
463            && let Some(sid_str) = claims.sid.as_deref() {
464                let sid = SessionId::from(sid_str.to_owned());
465                match port.check(&sid).await {
466                    Ok(()) => {}
467                    Err(SessionLivenessError::Revoked) => {
468                        return Err(self
469                            .emit_failure(bearer_token, VerifyError::SessionRevoked)
470                            .await);
471                    }
472                    Err(SessionLivenessError::Transient(_)) => {
473                        return Err(self
474                            .emit_failure(
475                                bearer_token,
476                                VerifyError::SessionLivenessLookupUnavailable,
477                            )
478                            .await);
479                    }
480                }
481            }
482            // No `sid` claim → admit without consulting the port (lenient).
483
484        match claims_to_verified(claims) {
485            Ok(session) => Ok(session),
486            Err(err) => Err(self.emit_failure(bearer_token, err).await),
487        }
488    }
489}
490
491fn looks_like_jws_compact(token: &str) -> bool {
492    token.split('.').count() == 3
493}
494
495/// M73 — return `true` when the JWS Compact `token` carries an
496/// id_token-shaped header or payload. γ1 (defense in depth): both
497/// signals are cheap to read and either alone is conclusive.
498///
499/// **Header `typ`**: RFC 9068 access tokens carry `typ="at+jwt"`;
500/// OIDC id_tokens carry `typ="JWT"`. Anything other than `at+jwt` is
501/// treated as not-an-access-token here. We deliberately do NOT only
502/// match the literal string `"JWT"` — a token whose header omits `typ`
503/// or carries some third value (`id+jwt`, `application/jwt`) is also
504/// not-an-access-token, and lumping them all into the M73 reject
505/// surfaces the same audit signal: "this isn't the access token shape".
506///
507/// **Payload `cat`**: ppoppo's domain claim distinguishing access
508/// (`cat="access"`) from id (`cat="id"`). Specifically `cat="id"` is
509/// the conclusive id_token signal; absence is admitted (legacy /
510/// non-ppoppo issuers may not carry it — the engine will surface that
511/// downstream).
512///
513/// **Defense in depth**: catching both prevents two failure modes —
514/// (a) a forger who sets `typ="at+jwt"` on a payload with `cat="id"`
515/// (the engine's typ check would pass, M45 would reject `cat="id"` as
516/// `UnknownClaim` since it's not in the access-allowlist, but only
517/// after signature verify), and (b) a misconfigured IdP that emits
518/// `typ="JWT"` on an access token (rare but possible during a buggy
519/// rollout). Either alone returns `true`.
520///
521/// **Best-effort decode**: a malformed token (bad base64, bad JSON)
522/// returns `false` — the engine's M01-M16a + M31-M34 are the
523/// authoritative layer for parse rejection. We only signal positively
524/// when we successfully decode AND find an id-token-shape marker.
525pub(crate) fn peek_id_token_shape(token: &str) -> bool {
526    use base64::Engine as _;
527
528    let mut parts = token.split('.');
529    let Some(header_b64) = parts.next() else { return false; };
530    let Some(payload_b64) = parts.next() else { return false; };
531
532    let header_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(header_b64) {
533        Ok(b) => b,
534        Err(_) => return false,
535    };
536    let payload_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64) {
537        Ok(b) => b,
538        Err(_) => return false,
539    };
540
541    let header_json: serde_json::Value = match serde_json::from_slice(&header_bytes) {
542        Ok(v) => v,
543        Err(_) => return false,
544    };
545    let payload_json: serde_json::Value = match serde_json::from_slice(&payload_bytes) {
546        Ok(v) => v,
547        Err(_) => return false,
548    };
549
550    let typ = header_json.get("typ").and_then(|v| v.as_str());
551    if matches!(typ, Some(t) if t != "at+jwt") {
552        return true;
553    }
554
555    let cat = payload_json.get("cat").and_then(|v| v.as_str());
556    if cat == Some("id") {
557        return true;
558    }
559
560    false
561}
562
563#[cfg(test)]
564#[allow(clippy::unwrap_used, clippy::expect_used)]
565mod m73_tests {
566    //! M73 unit coverage for `peek_id_token_shape`.
567    //!
568    //! Tested at the helper level (not through `JwtVerifier::verify`)
569    //! because constructing a `JwtVerifier` requires a live JWKS
570    //! fetch; pulling in `wiremock` for one boundary check would be
571    //! disproportionate. The helper is `pub(crate)`, the verify wiring
572    //! is one `if peek...() { return Err(...); }` line, and the test
573    //! covers every branch the wiring depends on.
574    use super::*;
575    use base64::Engine as _;
576
577    fn forge(header: serde_json::Value, payload: serde_json::Value) -> String {
578        let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
579            .encode(serde_json::to_vec(&header).unwrap());
580        let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
581            .encode(serde_json::to_vec(&payload).unwrap());
582        format!("{h}.{p}.<sig>")
583    }
584
585    #[test]
586    fn typ_jwt_alone_is_id_token_shape() {
587        // Even if `cat` says "access", `typ="JWT"` is the canonical
588        // OIDC id_token header — not a Bearer-class access token.
589        let token = forge(
590            serde_json::json!({"alg": "EdDSA", "typ": "JWT", "kid": "k"}),
591            serde_json::json!({"cat": "access", "sub": "01HSAB00000000000000000000"}),
592        );
593        assert!(peek_id_token_shape(&token));
594    }
595
596    #[test]
597    fn cat_id_alone_is_id_token_shape() {
598        // Even if `typ="at+jwt"` (header looks like access), `cat="id"`
599        // is the conclusive ppoppo-domain signal. γ1 defense in depth.
600        let token = forge(
601            serde_json::json!({"alg": "EdDSA", "typ": "at+jwt", "kid": "k"}),
602            serde_json::json!({"cat": "id", "sub": "01HSAB00000000000000000000"}),
603        );
604        assert!(peek_id_token_shape(&token));
605    }
606
607    #[test]
608    fn typ_jwt_and_cat_id_both_signal() {
609        // Canonical id_token shape — both markers present.
610        let token = forge(
611            serde_json::json!({"alg": "EdDSA", "typ": "JWT", "kid": "k"}),
612            serde_json::json!({"cat": "id", "sub": "01HSAB00000000000000000000"}),
613        );
614        assert!(peek_id_token_shape(&token));
615    }
616
617    #[test]
618    fn proper_access_token_shape_returns_false() {
619        // Canonical RFC 9068 access token — `typ="at+jwt"` + `cat="access"`.
620        // Must fall through to the engine for full verification.
621        let token = forge(
622            serde_json::json!({"alg": "EdDSA", "typ": "at+jwt", "kid": "k"}),
623            serde_json::json!({"cat": "access", "sub": "01HSAB00000000000000000000"}),
624        );
625        assert!(!peek_id_token_shape(&token));
626    }
627
628    #[test]
629    fn missing_typ_and_cat_admits_to_engine() {
630        // No typ, no cat — ambiguous. The engine's M13 + M45 layers will
631        // catch the violation downstream; the M73 peek doesn't itself
632        // signal because it only fires on positive id_token markers.
633        let token = forge(
634            serde_json::json!({"alg": "EdDSA", "kid": "k"}),
635            serde_json::json!({"sub": "01HSAB00000000000000000000"}),
636        );
637        assert!(!peek_id_token_shape(&token));
638    }
639
640    #[test]
641    fn malformed_base64_returns_false_not_panic() {
642        // Defense: a garbage token must defer to the engine for parse
643        // rejection (it will surface as `InvalidFormat` /
644        // `SignatureInvalid`), not crash here.
645        assert!(!peek_id_token_shape("not.valid.token"));
646        assert!(!peek_id_token_shape("!!!.@@@.###"));
647        assert!(!peek_id_token_shape(""));
648    }
649
650    #[test]
651    fn unrecognized_typ_value_is_id_token_shape() {
652        // Anything other than `at+jwt` is treated as not-an-access-token
653        // — surfaces the same audit signal as `typ="JWT"`. Rationale:
654        // the BearerVerifier surface is meant to receive RFC 9068
655        // tokens; any other `typ` value is a class mismatch.
656        let token = forge(
657            serde_json::json!({"alg": "EdDSA", "typ": "id+jwt", "kid": "k"}),
658            serde_json::json!({"cat": "access", "sub": "01HSAB00000000000000000000"}),
659        );
660        assert!(peek_id_token_shape(&token));
661    }
662}
663
664/// Map engine [`AuthError`] to the SDK boundary [`VerifyError`].
665///
666/// Algorithm + header rejections collapse to `SignatureInvalid` /
667/// `InvalidFormat` because their semantic at the SDK boundary is "the
668/// token cannot be trusted." Audit logs that need the precise M-code
669/// route through the [`VerifyError::Other`] Display fallback (engine
670/// `AuthError` Display retains the row identifier).
671fn map_auth_error(err: AuthError, _expectations: &VerifyConfig) -> VerifyError {
672    use AuthError as E;
673    use SharedAuthError as S;
674    match err {
675        // JOSE wire-format errors travel via the `Jose` carrier variant
676        // since the 10.1.B SharedAuthError split. Algorithm + header +
677        // kid + typ collapse to SignatureInvalid; serialization-shape
678        // (oversize / JSON-form / JWE / lax base64) collapse to
679        // InvalidFormat. The audit log disambiguates via Display
680        // fallback when the precise M-row matters.
681        E::Jose(
682            S::AlgNone
683            | S::AlgNotWhitelisted
684            | S::AlgHmacRejected
685            | S::AlgRsaRejected
686            | S::AlgEcdsaRejected
687            | S::HeaderJku
688            | S::HeaderX5u
689            | S::HeaderJwk
690            | S::HeaderX5c
691            | S::HeaderCrit
692            | S::HeaderExtraParam
693            | S::HeaderB64False
694            | S::KidUnknown
695            | S::TypMismatch
696            | S::NestedJws
697            | S::DuplicateJsonKeys
698            | S::HeaderUnparseable
699            | S::PayloadUnparseable
700            | S::NotJwsCompact,
701        ) => VerifyError::SignatureInvalid,
702        E::Jose(
703            S::OversizedToken | S::JwsJsonRejected | S::JwePayload | S::LaxBase64,
704        ) => VerifyError::InvalidFormat,
705        // Time-bound rejections — caller treats these as "refresh".
706        E::Expired | E::ExpUpperBound | E::IatFuture | E::NotYetValid => VerifyError::Expired,
707        // Issuer / audience — typed for audit pivot.
708        E::IssMismatch => VerifyError::IssuerInvalid,
709        E::AudMismatch => VerifyError::AudienceInvalid,
710        // Missing claims — surface the canonical claim name.
711        E::ExpMissing => VerifyError::MissingClaim("exp"),
712        E::AudMissing => VerifyError::MissingClaim("aud"),
713        E::IatMissing => VerifyError::MissingClaim("iat"),
714        E::JtiMissing => VerifyError::MissingClaim("jti"),
715        E::SubMissing => VerifyError::MissingClaim("sub"),
716        E::ClientIdMissing => VerifyError::MissingClaim("client_id"),
717        // Phase 11.Z (RFC §3 Row 3) — sv-axis enforcement typed
718        // variants. Pre-11.Z these collapsed into `Other(String)`
719        // along with every engine variant the SDK didn't typify.
720        E::SessionVersionStale => VerifyError::SessionVersionStale,
721        E::SessionVersionLookupUnavailable => VerifyError::SessionVersionLookupUnavailable,
722        // Catch-all keeps the engine row identifier in the Display so
723        // the audit log can pivot on the M-code without the SDK
724        // pre-translating every variant — engine evolution doesn't
725        // require lockstep enum updates here.
726        other => VerifyError::Other(other.to_string()),
727    }
728}
729
730fn claims_to_verified(claims: Claims) -> Result<VerifiedClaims, VerifyError> {
731    // `sub` is a ULID per the engine's Phase 4 domain rules. A failure
732    // here would mean the issuer drifted from the contract — fail
733    // closed with a typed error.
734    let ppnum_id = ulid::Ulid::from_str(&claims.sub)
735        .map(PpnumId)
736        .map_err(|_| VerifyError::MissingClaim("sub"))?;
737
738    let ppnum = match claims.active_ppnum {
739        Some(p) => Some(Ppnum::try_from(p).map_err(|_| VerifyError::MissingClaim("active_ppnum"))?),
740        None => None,
741    };
742
743    let session_id = claims.sid.map(SessionId::from);
744
745    let expires_at = Timestamp::from_second(claims.exp)
746        .map_err(|_| VerifyError::MissingClaim("exp"))?;
747
748    Ok(VerifiedClaims::new(ppnum_id, ppnum, session_id, expires_at))
749}
750
751#[cfg(test)]
752#[allow(clippy::unwrap_used, clippy::expect_used)]
753mod epoch_wiring_tests {
754    //! The `sv` epoch gate reached **through** `JwtVerifier::verify` — the
755    //! link `:3202` / RCW / CTW depend on: the required `EpochEnforcement`
756    //! stance forwards the port into the engine `VerifyConfig` at verify
757    //! time (RFC_202607150428 Q3 — the opt-in builder is retired). The
758    //! engine's `check_epoch` reject logic and the
759    //! `CompositeEpochRevocation` composer are tested elsewhere; this closes the
760    //! one link between them — that an *Enforced* verifier actually consults the
761    //! port and refuses, and that the named non-Enforced stances admit.
762    //! `for_test_with_keys` supplies a real in-memory keyset so
763    //! verify reaches post-signature checks without a wiremock JWKS endpoint,
764    //! the pattern this crate deliberately keeps out of boundary tests
765    //! (RFC_202607150428 S5/S6).
766    use super::*;
767    use ppoppo_token::SigningKey;
768    use ppoppo_token::access_token::{
769        EpochRevocation, EpochRevocationError, IssueConfig, IssueRequest, issue,
770    };
771    use std::sync::Arc;
772
773    const ISSUER: &str = "accounts.ppoppo.com";
774    const CLIENT: &str = "rcw-client-id";
775    const SUB: &str = "01HSAB00000000000000000000";
776
777    /// An `EpochRevocation` stub pinned to a fixed current epoch.
778    #[derive(Debug)]
779    struct FixedEpoch(i64);
780    #[async_trait::async_trait]
781    impl EpochRevocation for FixedEpoch {
782        async fn current(&self, _sub: &str) -> Result<i64, EpochRevocationError> {
783            Ok(self.0)
784        }
785    }
786
787    fn mint(signer: &SigningKey, sv: Option<u64>) -> String {
788        let cfg = IssueConfig::access_token(ISSUER, CLIENT, signer.kid());
789        let mut req = IssueRequest::new(SUB, CLIENT, std::time::Duration::from_secs(3600))
790            .with_account_type("human");
791        if let Some(v) = sv {
792            req = req.with_session_version(v);
793        }
794        let now = jiff::Timestamp::now().as_second();
795        issue(&req, &cfg, signer, now).expect("mint")
796    }
797
798    fn wired(keyset: ppoppo_token::KeySet, current: i64) -> JwtVerifier {
799        JwtVerifier::for_test_with_keys(
800            VerifyConfig::new(ISSUER, CLIENT),
801            keyset,
802            EpochEnforcement::Enforced(Arc::new(FixedEpoch(current))),
803        )
804    }
805
806    /// A wired verifier ADMITS a current token — anti-vacuous anchor: proves
807    /// the rejections below are the port acting, not the verifier being inert.
808    #[tokio::test]
809    async fn wired_verifier_admits_current_sv() {
810        let (signer, keyset) = SigningKey::test_pair();
811        let token = mint(&signer, Some(5)); // 5 >= current 5
812        wired(keyset, 5)
813            .verify(&token)
814            .await
815            .expect("a token at the current epoch must be admitted");
816    }
817
818    /// A wired verifier REFUSES a token whose `sv` is behind the account epoch
819    /// — proof that `JwtVerifier::verify` consults the port (break-glass reach).
820    #[tokio::test]
821    async fn wired_verifier_rejects_stale_sv() {
822        let (signer, keyset) = SigningKey::test_pair();
823        let token = mint(&signer, Some(3)); // 3 < current 10 → stale
824        let err = wired(keyset, 10)
825            .verify(&token)
826            .await
827            .expect_err("stale sv must reject");
828        assert!(
829            matches!(err, VerifyError::SessionVersionStale),
830            "stale token must surface SessionVersionStale, got {err:?}",
831        );
832    }
833
834    /// S6: a wired verifier REFUSES a token with **no `sv` claim** (R6
835    /// legacy-admit retired). This is the missing-sv reject reaching through the
836    /// SDK verify path — the exact enforcement `:3202` gains from S5-PCS+S6.
837    #[tokio::test]
838    async fn wired_verifier_rejects_missing_sv() {
839        let (signer, keyset) = SigningKey::test_pair();
840        let token = mint(&signer, None); // no sv claim
841        let err = wired(keyset, 0)
842            .verify(&token)
843            .await
844            .expect_err("missing sv must reject (S6)");
845        assert!(
846            matches!(err, VerifyError::SessionVersionStale),
847            "missing-sv token must reject post-S6, got {err:?}",
848        );
849    }
850
851    /// Q3 typed stance: a NAMED `Unenforced` verifier admits past the sv
852    /// axis — the declared-gap semantics RCW/CTW run under until their
853    /// substrate lands (RFC_202607150428 §6·S5). Pins that the stance
854    /// short-circuit is behavior-identical to the retired `epoch: None`
855    /// (a stale AND a missing sv both admit), so flipping a consumer to
856    /// `Unenforced` is a declaration, never a behavior change.
857    #[tokio::test]
858    async fn unenforced_stance_admits_stale_and_missing_sv() {
859        let (signer, keyset) = SigningKey::test_pair();
860        let verifier = JwtVerifier::for_test_with_keys(
861            VerifyConfig::new(ISSUER, CLIENT),
862            keyset,
863            EpochEnforcement::Unenforced {
864                reason: "test: declared-gap semantics under verification",
865            },
866        );
867        let stale = mint(&signer, Some(3));
868        verifier
869            .verify(&stale)
870            .await
871            .expect("Unenforced stance must admit a stale-sv token (declared gap)");
872        let missing = mint(&signer, None);
873        verifier
874            .verify(&missing)
875            .await
876            .expect("Unenforced stance must admit a missing-sv token (declared gap)");
877    }
878}