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}