Skip to main content

pas_external/token/
port.rs

1//! γ port — `BearerVerifier`, `AuthSession`, `Expectations`, `VerifyError`.
2//!
3//! The SDK's verification surface, format-blind by design. Consumers
4//! receive an [`AuthSession`] that exposes typed accessors for the
5//! values they need (`ppnum_id`, `ppnum`, `session_id`, `expires_at`)
6//! without ever seeing the underlying JWT or the `jsonwebtoken` /
7//! `ppoppo_token` types. Swapping the production [`PasJwtVerifier`]
8//! adapter for the in-memory test adapter (`MemoryBearerVerifier`,
9//! gated behind `test-support`) requires zero consumer changes — the
10//! port is the contract.
11//!
12//! D-04 (locked γ, 2026-05-05): port-and-adapter SDK boundary; the
13//! engine becomes the only place that knows JWT.
14
15use async_trait::async_trait;
16use time::OffsetDateTime;
17
18use crate::types::{Ppnum, PpnumId, SessionId};
19
20/// Verification port for incoming bearer tokens.
21///
22/// Implementations swap the cryptographic backend without altering the
23/// caller's surface. The production [`super::PasJwtVerifier`] verifies
24/// PAS-issued JWTs against a TTL-cached JWKS; the test-support
25/// `MemoryBearerVerifier` returns canned [`AuthSession`] values keyed
26/// by the bare token string.
27///
28/// `verify` is async because the production adapter performs
29/// stale-on-failure JWKS refresh inside the verify path, and any
30/// future 3rd-party adapter is free to make HTTP calls. Caller
31/// middleware that needs synchronous semantics wraps the call in
32/// `tokio::block_on`; the port itself stays uniformly async.
33///
34/// The single `bearer_token` parameter mirrors the M38 transport-blind
35/// invariant: the engine never reaches into request framing, and
36/// neither does the SDK port. Consumer middleware extracts the bare
37/// token before calling.
38#[async_trait]
39pub trait BearerVerifier: Send + Sync {
40    async fn verify(&self, bearer_token: &str) -> Result<AuthSession, VerifyError>;
41}
42
43/// Per-deployment expectations folded into the verifier at construction.
44///
45/// `issuer` is the PAS instance URL (`accounts.ppoppo.com` in
46/// production); `audience` is the consumer's OAuth `client_id`. Both
47/// are static per-deployment — multi-tenant consumers instantiate
48/// multiple verifiers, never rotate `Expectations` on the per-call
49/// hot path.
50///
51/// Held inside [`super::PasJwtVerifier`] (and optionally inside
52/// `MemoryBearerVerifier`) so the [`BearerVerifier::verify`] signature
53/// stays one-parameter — the port is as small as it can be while
54/// still doing meaningful work.
55#[derive(Debug, Clone)]
56pub struct Expectations {
57    pub issuer: String,
58    pub audience: String,
59}
60
61impl Expectations {
62    /// Construct from owned strings. Consumer wiring typically reads
63    /// these from environment variables at startup.
64    #[must_use]
65    pub fn new(issuer: impl Into<String>, audience: impl Into<String>) -> Self {
66        Self {
67            issuer: issuer.into(),
68            audience: audience.into(),
69        }
70    }
71}
72
73/// Verified bearer-token outcome, opaque to the underlying token format.
74///
75/// Internal storage is the engine's typed `Claims` payload, but no
76/// consumer ever touches it — accessors return SDK-shaped types
77/// (`Ppnum`, `PpnumId`, `SessionId`, `OffsetDateTime`) that are stable
78/// across format migrations (PASETO → JWT just happened; future
79/// formats re-implement `BearerVerifier` and ship a new `AuthSession`
80/// constructor).
81///
82/// No `into_inner` escape hatch by design (Phase 6.1 audit Finding 4):
83/// every claim consumer code might need is exposed as a typed accessor.
84/// If a future field is needed, add an accessor here before the consumer
85/// ships — never widen to raw claims.
86#[derive(Debug, Clone)]
87pub struct AuthSession {
88    ppnum_id: PpnumId,
89    ppnum: Option<Ppnum>,
90    session_id: Option<SessionId>,
91    expires_at: OffsetDateTime,
92}
93
94impl AuthSession {
95    /// Build from typed components. SDK-internal — `PasJwtVerifier`
96    /// constructs after engine `verify` returns; `MemoryBearerVerifier`
97    /// constructs in test setup. Marked `pub(crate)` so external
98    /// adapters cannot fabricate sessions outside the SDK's
99    /// verification path.
100    ///
101    /// `dead_code` allowed because under just `feature = "token"`
102    /// (no `well-known-fetch`, no `test-support`) the constructor has
103    /// no caller — yet the type itself is still part of the
104    /// `BearerVerifier` trait surface that consumers may implement
105    /// directly. Removing the constructor would break the symmetry
106    /// with `for_test`.
107    #[allow(dead_code)]
108    pub(crate) fn new(
109        ppnum_id: PpnumId,
110        ppnum: Option<Ppnum>,
111        session_id: Option<SessionId>,
112        expires_at: OffsetDateTime,
113    ) -> Self {
114        Self {
115            ppnum_id,
116            ppnum,
117            session_id,
118            expires_at,
119        }
120    }
121
122    /// Test-support constructor — same shape as [`Self::new`] but
123    /// available outside the SDK when the `test-support` feature is
124    /// enabled. Consumers writing integration tests wire in
125    /// pre-built sessions through `MemoryBearerVerifier::insert`.
126    #[cfg(any(test, feature = "test-support"))]
127    #[must_use]
128    pub fn for_test(
129        ppnum_id: PpnumId,
130        ppnum: Option<Ppnum>,
131        session_id: Option<SessionId>,
132        expires_at: OffsetDateTime,
133    ) -> Self {
134        Self::new(ppnum_id, ppnum, session_id, expires_at)
135    }
136
137    /// Stable subject identifier (ULID, `sub` claim).
138    #[must_use]
139    pub fn ppnum_id(&self) -> &PpnumId {
140        &self.ppnum_id
141    }
142
143    /// Digit-form ppnum carried in the `active_ppnum` claim. `None`
144    /// for AI-agent / machine tokens that have no human ppnum, and
145    /// for any token where the issuer omitted the claim. Consumer
146    /// code defaults to display-only use; trust decisions key off
147    /// `ppnum_id` (immutable ULID).
148    #[must_use]
149    pub fn ppnum(&self) -> Option<&Ppnum> {
150        self.ppnum.as_ref()
151    }
152
153    /// Session row identifier (`sid` claim) when the issuer bound the
154    /// token to a stored session. `None` for non-session-bound tokens
155    /// (machine credentials, AI-agent flows, R6 legacy admit). When
156    /// present, consumer middleware uses it for per-row liveness
157    /// checks via `SessionStore::find`.
158    #[must_use]
159    pub fn session_id(&self) -> Option<&SessionId> {
160        self.session_id.as_ref()
161    }
162
163    /// Per-account session-version (`sv` claim) when present. Today's
164    /// engine [`ppoppo_token::access_token::Claims`] does not surface `sv` — the
165    /// canonical flow fetches it from PAS userinfo (see
166    /// [`crate::middleware::SessionValidator`]). The accessor exists
167    /// for forward compatibility with a future engine that surfaces
168    /// `sv` directly; production callers always observe `None` today.
169    #[must_use]
170    pub fn session_version(&self) -> Option<i64> {
171        None
172    }
173
174    /// Expiry (`exp` claim) as a wall-clock instant. Caller code can
175    /// compare against `OffsetDateTime::now_utc()` for soft-refresh
176    /// logic; the engine has already enforced expiry in
177    /// [`BearerVerifier::verify`] so this value is informational by
178    /// the time it reaches consumer code.
179    #[must_use]
180    pub fn expires_at(&self) -> OffsetDateTime {
181        self.expires_at
182    }
183}
184
185/// Verification failure surface.
186///
187/// One variant per logical failure class. The PAS-engine variants
188/// (`SignatureInvalid`, `Expired`, `IssuerMismatch`, `AudienceMismatch`,
189/// `MissingClaim`, `KeysetUnavailable`) reflect the boundary contract:
190/// audit logs map them 1:1 to engine `AuthError` rows. Adapter-side
191/// variants (`InvalidFormat`) cover failures upstream of engine entry.
192#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
193pub enum VerifyError {
194    /// Bearer string did not parse as a JWS Compact serialization.
195    /// Adapter-side reject before engine entry.
196    #[error("invalid bearer token format")]
197    InvalidFormat,
198
199    /// Cryptographic signature verification failed (engine M16).
200    #[error("signature verification failed")]
201    SignatureInvalid,
202
203    /// `exp` claim is in the past (engine M19).
204    #[error("token expired")]
205    Expired,
206
207    /// `iss` did not match [`Expectations::issuer`] (engine M23). The
208    /// engine does not expose the *actual* value because the failed
209    /// match means we cannot trust any payload field — the SDK
210    /// surfaces just "issuer invalid" and the audit log carries the
211    /// caller's expected value alongside this variant.
212    #[error("issuer invalid (M23)")]
213    IssuerInvalid,
214
215    /// `aud` did not match [`Expectations::audience`] (engine M21/M22).
216    #[error("audience invalid (M21/M22)")]
217    AudienceInvalid,
218
219    /// A required claim was absent or malformed.
220    #[error("missing required claim: {0}")]
221    MissingClaim(&'static str),
222
223    /// JWKS fetch failed and there is no usable cached snapshot
224    /// (initial bootstrap failure or `with_initial` constructed with
225    /// an empty key set). Distinct from `SignatureInvalid` so audit
226    /// logs distinguish "we couldn't even attempt verification" from
227    /// "verification failed."
228    #[error("keyset unavailable")]
229    KeysetUnavailable,
230
231    /// M73 — id_token presented as a Bearer token. RFC 9068 §1 (negative)
232    /// + OIDC Core §1.2 intent: id_tokens authenticate the user *to the
233    /// RP*; access_tokens authorize the RP *to the resource server*.
234    /// The two are not interchangeable. Many 3rd-party RPs misuse
235    /// id_token for API access — pas-external is the BearerVerifier
236    /// surface for resource servers, so an id_token-shaped JWT here is
237    /// always wrong.
238    ///
239    /// Distinct from `SignatureInvalid` (which is the engine's catch-all
240    /// for "token cannot be trusted") so audit logs distinguish a
241    /// developer-misuse signal ("you're sending the wrong token class")
242    /// from a forgery signal ("the signature didn't verify"). Rejected
243    /// BEFORE engine entry so the audit log does not get the same
244    /// signal masked by `TypMismatch → SignatureInvalid` collapsing.
245    #[error("M73: id_token presented as Bearer — use access_token for resource access")]
246    IdTokenAsBearer,
247
248    /// Catch-all for engine variants that don't map to a structural
249    /// SDK rejection. Carries the engine's `AuthError` Display so the
250    /// audit log retains the M-code.
251    #[error("verification failed: {0}")]
252    Other(String),
253}