# STANDARDS — pas-external session-liveness contract
**Audience**: Consumers of `pas-external` who persist PAS `refresh_token`s
server-side (CTW, RCW, future Leptos fullstack apps).
**Last updated**: 2026-05-05 (pas-external v0.6.0 — JWT/RFC 9068 era)
> **Token format**: PAS access tokens are JWTs (RFC 9068, EdDSA) verified
> through `ppoppo-token` via the γ port [`BearerVerifier`]. The S-L
> invariants below are token-format-agnostic — refresh_token is opaque
> regardless, sv survived the migration. See §10 v0.6.0 for the surface
> rename catalog (`KeySet` → `PasJwtVerifier`, etc.).
This document is the **SSOT for the session-liveness design**. Consumer
standards docs (`STANDARDS_AUTH_CLASSYTIME.md`, `STANDARDS_AUTH_ROLLCALL.md`)
reference this file for the shared invariants and describe only the
consumer-specific delta (schema column names, service bucket, cleanup
interval).
## 1. Problem
A consumer that holds PAS `refresh_token` for its users must answer two
questions every time a session is touched:
1. **Is this session still alive?** — PAS may have revoked the token
server-side (user logged out elsewhere, admin revoke, etc.).
2. **Is this session ours to trust?** — if the database was leaked, can an
attacker walk away with renewable credentials?
PAS is the **single source of truth** for (1). Independent TTLs ("session
expires 7 days after creation") *look* conservative but actually mask
revocation — a user who logs out on another device is still logged in on this
one until the TTL fires.
For (2), any plaintext `refresh_token` column is a credential store: a one-off
DB dump hands attackers every active user's PAS token. The SDK prevents
plaintext from ever crossing the SDK→consumer boundary at the type level on
both write and read paths (see §3 S-L1).
## 2. Design
| AES-256-GCM at-rest encryption | [`TokenCipher`] + middleware encrypts before `SessionStore::create` | configures `REFRESH_TOKEN_KEY`, supplies cipher to `PasAuthConfig::with_refresh_token_cipher` |
| Newtype enforcement of "ciphertext only" | [`EncryptedRefreshToken`] (the only shape `SessionStore::create` ever sees) | calls `.into_inner()` to obtain the persistable `String` |
| PAS liveness round-trip | [`attempt_liveness_refresh`] | decides when to call it (stale-check gate) |
| Revoked vs transient classification | [`AuthClient::send_classified`] → [`PasFailure`] consumed by [`attempt_liveness_refresh`] | decides what to do with the outcome |
| Trusted-proxy XFF walking | `PasAuthConfig::with_xff_trusted_proxies(n)` | knows its proxy topology |
| Loopback-only DEV_AUTH guard | `PasAuthConfig::from_env` refuses non-loopback redirect_uri when DEV_AUTH=1 | does not bypass via direct builder calls in prod |
| Session persistence (`last_verified_at`, `revoked_at`, ciphertext column) | — | owns schema + repository |
| `AuthContext` construction | — | owns domain model |
The SDK ships primitives, not middleware-for-everything. Each consumer
implements its own `SessionStore::find` using the primitives; this keeps
schemas and domain models from leaking into the SDK.
## 3. The six invariants
Consumers that enable the `axum` feature (which transitively enables
`session-liveness` and `token`) and wire `attempt_liveness_refresh` into
their `SessionStore::find` **must** satisfy all six. S-L6 enforcement is
built into `SessionValidator`, returned by `PasAuth::session_validator()`
(renamed from `PasAuth::resolver()` / `SvAwareSessionResolver` in v0.5.0).
Each invariant corresponds to a consumer-side check that auditors can
verify without reading SDK source.
### S-L1 — `refresh_token` is encrypted at rest (AES-256-GCM), enforced by type
The stored column holds the output of [`TokenCipher::encrypt`] — a
base64-encoded `nonce[12] || ciphertext+tag`. Plaintext storage, logging, and
echoing in error messages are forbidden.
**Type-level enforcement:** Both directions of the SDK→consumer boundary
use [`EncryptedRefreshToken`] — plaintext never crosses on either path:
- **Write path**: `NewSession.refresh_token` is
`Option<EncryptedRefreshToken>`. The SDK middleware encrypts the
plaintext PAS response *inside the OAuth callback* using the cipher
supplied to [`PasAuthConfig::with_refresh_token_cipher`].
- **Read path**: `SessionStore::get_refresh_ciphertext` returns
`Option<EncryptedRefreshToken>`. Consumers wrap the stored column value
via [`EncryptedRefreshToken::from_stored`] and **do not decrypt** — the
SDK owns decrypt for both S-L3 and S-L6 paths via [`pas_refresh`].
A consumer that forgets to call `with_refresh_token_cipher` ends up with
`refresh_token = None` on the write path (no liveness checks possible)
and `cipher = None` on the read path (any stored ciphertext fails closed
to `Expired` with a logged misconfiguration error) — failing closed
instead of leaking plaintext.
**Verification**: grep the consumer's repository for the column name —
`SessionStore::create` should call `.into_inner()` on the
`EncryptedRefreshToken` and store the resulting `String` directly.
`SessionStore::get_refresh_ciphertext` should read the column and wrap
the value via `EncryptedRefreshToken::from_stored(...)`. **No `encrypt()`
or `decrypt()` call should appear in consumer code** (the SDK does both).
The schema `COMMENT` line names the column purpose and the env var
(typical: `REFRESH_TOKEN_KEY`) so DB tooling surfaces it.
### S-L2 — PAS is the liveness SSOT
The consumer does not independently decide "this session is valid." There is
no local absolute TTL (`expires_at`). Validity is exactly "PAS accepted this
refresh_token within the last `interval` seconds OR the session has not yet
aged past the interval since creation."
**Verification**: `SessionStore::find` must gate on
`session.needs_liveness_check(interval)` before serving the cached auth
context. No branch returns `Some(auth_ctx)` on a stale session without
calling [`attempt_liveness_refresh`].
### S-L3 — transient failures do not force logout
When [`attempt_liveness_refresh`] returns `LivenessFailure::Transient`, the
consumer **serves the cached session**. A network blip, PAS 5xx, or timeout
must not cascade into a fleet-wide re-auth storm.
Only `LivenessFailure::Revoked` (PAS 4xx, or cipher failure) calls
`mark_revoked` and drops the session.
The HTTP-status → outcome mapping lives inside the SDK now:
[`AuthClient::send_classified`] reads the status once and produces a
[`PasFailure`] (`Rejected` / `ServerError` / `Transport`). The
`pas_refresh` deep core then translates that into `Refreshed` /
`Rejected` / `Transient`. Consumers do not see HTTP status codes —
`LivenessFailure` is the SSOT.
Cipher decrypt likewise happens inside [`pas_refresh`] (v0.2.0+);
consumers pass the wrapped ciphertext, never plaintext.
[`attempt_liveness_refresh`] takes `&EncryptedRefreshToken` (newtype) —
consumers no longer pass a plaintext or ciphertext `String`. Wrap the
stored column value via [`EncryptedRefreshToken::from_stored`] before
calling.
**Verification**: unit-test the consumer-side branch on the boundary
trait — drive [`attempt_liveness_refresh`] with a `MemoryPasAuth`
scripted to return `PasFailure::ServerError { status: 503, .. }` and
assert the outcome is `Transient`; `PasFailure::Rejected { status: 400,
.. }` must yield `Revoked { PasRejected }`. The SDK's
`tests/liveness_boundary.rs` covers this; consumer tests are a
belt-and-suspenders against accidental inversion. The `test-support`
feature (v0.1.0+) re-exports `MemoryPasAuth` for downstream integration
tests.
### S-L4 — `ciphertext IS NULL` sessions skip liveness
DEV_AUTH dev-login and any future non-OAuth session path stores
`refresh_token_ciphertext = None`. `SessionStore::find` must gate the
liveness branch on `ciphertext.is_some()` so dev sessions take the
activity-touch path and are harvested by normal dead-row cleanup once they
age past the cutoff.
**Verification**: the `needs_liveness` predicate in `find()` reads
something like:
```rust
let needs_liveness = session.refresh_token_ciphertext.is_some()
&& session.needs_liveness_check(self.liveness.interval);
```
### S-L5 — rotated ciphertext must be persisted (or the session goes silent)
When [`attempt_liveness_refresh`] returns
`LivenessOutcome::Fresh { rotated_ciphertext: Some(ct) }`, the consumer
**must** persist `ct` (atomically with `last_verified_at = now`) before
serving the request. If the consumer only persists `last_verified_at` and
drops `ct`, the next liveness check decrypts a stale token, PAS responds
4xx, the SDK classifies as `Revoked { PasRejected }`, and a legitimate user
is silently force-logged-out.
OAuth public clients (RCW, CTW): PAS does not currently apply Refresh Token
Rotation (RTR), so `rotated_ciphertext` is almost always `None`. This
invariant remains in force as a forward-compatibility contract — the SDK
exposes the rotated branch precisely because the SDK should not assume the
PAS RTR policy for OAuth clients is permanent.
**Verification**: in `SessionStore::find`, the `Fresh` arm must pattern-match
on `rotated_ciphertext` and pass `Option<&str>` (or `Option<String>`) to the
`touch_verified` repository call. The repository's UPDATE must conditionally
overwrite the ciphertext column when the value is `Some`. A `let _ =
rotated_ciphertext;` is a code-review red flag.
### S-L6 — `session_version` (sv) is enforced via the SDK validator, not the consumer
JWT access tokens (RFC 9068) issued by PAS carry an `sv` (session_version)
claim. PAS increments `sv` on break-glass recovery (spec #005); a token
whose `sv` is below the current PAS value must be rejected even if its
1-hour TTL has not expired. Without this enforcement, a token stolen
*before* break-glass remains usable for its full TTL — defeating the
recovery flow.
The `sv` claim is preserved across the PASETO→JWT migration (Phase 6.1,
v0.6.0): the `ppoppo-token` engine surfaces it as `Claims::session_version`,
and the γ port `BearerVerifier::verify` returns it via
`AuthSession::session_version()`. The enforcement *mechanism* lives in the
SDK middleware and is unchanged by the format swap.
**Type-level enforcement:** `PasAuth::session_validator()` returns
`SessionValidator`, which intercepts every authenticated request and
gates it on the consumer's auth-context `sv()` matching the PAS-side cached
value (60 s consumer-local cache, miss → `/token` round-trip, then trust-
extract `sv` from the just-issued access_token via the engine claim, then
`SessionStore::update_sv`). The post-Phase-10.13.B path collapses the
prior intermediate `/userinfo` call: the access_token's `sv` claim is the
single SSOT (engine `check_epoch` is the verification mechanism on every
subsequent request), so a parallel `UserInfo::session_version` channel
would be unverified duplicate state. The deprecated free function
`validate_sv` was removed pre-1.0 (its per-request-bearer-token shape was
incompatible with the cookie-session middleware pattern).
The consumer's `AuthContext` type **must** implement the `SvAware`
supertrait (`ppnum_id() -> &str`, `sv() -> Option<i64>`). DEV_AUTH sessions
and AI-agent tokens carry `sv = None` and bypass enforcement (spec §4.2.1)
— the validator short-circuits when either side is `None`.
**Verification**:
1. Schema includes `sv BIGINT NULL` on the session row.
2. `NewSession.sv` is wired through `SessionStore::create`, populated by
the SDK callback handler via `token::jwt::peek_session_version` on the
freshly-issued access_token (Phase 10.13.B; was `UserInfo::session_version`
prior).
3. `SessionStore::update_sv(session_id, new_sv)` performs the obvious
UPDATE — called by the validator after a refresh that raised `sv`.
4. `SessionStore::get_refresh_ciphertext(session_id)` is implemented:
load the session row, return the stored ciphertext column wrapped via
[`EncryptedRefreshToken::from_stored`]. **No decrypt in consumer
code** — the SDK owns decrypt via [`pas_refresh`] (v0.2.0+).
5. `AuthContext: SvAware` compiles — the trait bound is what unlocks
`PasAuth::session_validator()`.
6. No call site retains a hand-rolled `validate_sv(...)` wrapper around
the auth middleware. The validator does it.
7. No `impl RefreshTokenResolver for ...` block remains in the consumer
(trait removed pre-1.0).
Multi-pod consumers may inject a shared cache substrate (Redis, KVRocks)
via `PasAuth::session_validator_with_backend(backend)` so a break-glass
converges across pods within network RTT instead of per-pod 60 s TTL. The
injected type implements `SvCachePort` (renamed from `SvCacheBackend` in
v0.4.0); key namespace and TTL are owned by the SDK-internal driver
(`middleware::sv::adapter`) — re-exported from `ppoppo-token` (engine
SSOT) so PAS / PCS / SDK drift becomes a compile-time ripple.
`MemorySvBackend` is the default for single-pod deployments.
## 4. Enabling the feature
```toml
# In your workspace Cargo.toml (pas-external 0.7.x):
pas-external = { version = "0.7", features = ["axum"] }
# `axum` transitively enables `session-liveness` + `token` — TokenCipher,
# EncryptedRefreshToken, and the BearerVerifier port are always in scope
# when you use the middleware. The default `oauth` feature pulls in
# `tokio::sync` + `async-trait` for the sv-validator driver
# (`middleware::sv` module).
```
The `axum` feature pulls in `aes-gcm` + `base64` + `tokio sync` + the
JWT engine (`ppoppo-token`) transitively. Consumers compile an additional
~800 KB of dependencies. Not enabled by default; the
`default = ["oauth", "token"]` profile is for clients that only verify
access tokens locally via `BearerVerifier`.
For pure JWT verification without middleware, prefer the
`well-known-fetch` feature plus [`PasJwtVerifier::from_jwks_url`] over
hand-rolling the JWKS fetch + cache + rotation logic. (`KeySet` is
`pub(crate)` since v0.6.0 — Finding 2 of the deep-module audit. The
single-step constructor hides the cache.)
## 5. Required consumer schema
Exact column names are up to the consumer; the SDK does not introspect the
DB. The *shape* is invariant:
| `refresh_token_ciphertext TEXT NULL` | encrypted PAS refresh_token | NULL only for DEV_AUTH sessions (S-L4) |
| `last_verified_at TIMESTAMPTZ NOT NULL` | last successful PAS liveness confirmation | drives `needs_liveness_check(interval)` and dead-row cleanup |
| `revoked_at TIMESTAMPTZ NULL` | NULL = live | set on PAS `invalid_grant` or explicit logout; cleanup hard-deletes |
| `sv BIGINT NULL` | PAS session_version (S-L6) | trust-extracted from the access_token `sv` claim on create (Phase 10.13.B); updated by `SessionStore::update_sv` after a refresh raises sv. NULL for DEV_AUTH and AI-agent sessions (bypass enforcement) |
Notably absent: **no `expires_at` column**. If the consumer has one, S-L2 is
violated.
## 6. Required env vars
| `PAS_CLIENT_ID` | string | yes | OAuth2 client_id assigned by PAS |
| `PAS_REDIRECT_URI` | URL | yes | Must be loopback (`http://localhost/...` etc.) when DEV_AUTH=1 |
| `COOKIE_KEY` | base64-encoded ≥64 bytes | yes (when secure cookies are on) | `from_env()` refuses to start without it. Generate with `openssl rand -base64 64`. |
| `REFRESH_TOKEN_KEY` | base64-encoded 32 bytes | yes | validate format at startup (not per-request); supply to `PasAuthConfig::with_refresh_token_cipher` |
| `SESSION_LIVENESS_INTERVAL_SECS` | u64 | no (default 900 = 15 min) | trade-off: lower = faster revocation, higher PAS load |
| `DEV_AUTH` | `1` / `true` | no | `from_env()` refuses if PAS_REDIRECT_URI is non-loopback |
**Key rotation** is a policy decision, not an SDK feature. The two supported
shapes are (a) "re-auth everyone" on rotation (zero code, loses all active
sessions), or (b) dual-key window owned by the consumer (decrypt-with-either,
encrypt-with-new). The SDK ships no dual-key helper because the right cutoff
is policy-specific.
## 7. Interaction sequence
```
request lands
│
▼
SessionStore::find(session_id)
│
├── repo.find_by_id(id)
│ └── Some(session) // else Ok(None)
│
├── session.is_revoked()? ────► Yes → Ok(None)
│
├── needs_liveness = ciphertext.is_some()
│ && needs_liveness_check(interval) (S-L4)
│
├── needs_liveness? ──► No → spawn(touch_used), fall through
│
│ Yes
│ │
│ ▼
│ attempt_liveness_refresh(cipher, client, &EncryptedRefreshToken)
│ │
│ ├── Fresh { rotated_ciphertext } (S-L5)
│ │ └── touch_verified(id, now, rotated_ciphertext)
│ │ Ok → fall through
│ │ Err → downgrade to Transient (serve cache)
│ │
│ ├── Revoked
│ │ └── mark_revoked(id, now); return Ok(None)
│ │
│ └── Transient { retry_after } (S-L3)
│ └── log warn, fall through (serve cache)
│
▼
build AuthContext from session.user_id + repo lookups
return Ok(Some(ctx))
```
## 8. Login path (OAuth callback) — what the SDK does for you
```
GET /api/auth/callback
│
├── exchange_code(code, code_verifier) → TokenResponse { access_token, refresh_token, ... }
│
├── userinfo(access_token) → UserInfo { sub, ppnum, ... }
│
├── account_resolver.resolve(ppnum_id, ppnum) → UserId
│
├── ENCRYPT refresh_token ← (only if `with_refresh_token_cipher` was set)
│ │
│ ├── Ok(ct) → NewSession { refresh_token: Some(EncryptedRefreshToken(ct)), ... }
│ └── Err(e) → log error, redirect with error=refresh_token_encryption_failed
│
├── extract_client_ip(headers, xff_trusted_proxies) → walks XFF skipping trusted hops
│
└── session_store.create(NewSession) → SessionId
```
Consumers cannot accidentally store plaintext: the only shape they receive
is `EncryptedRefreshToken`.
## 9. Testing guidance
- **Classifier**: drive [`attempt_liveness_refresh`] with `MemoryPasAuth`
scripted to return each [`PasFailure`] variant
(`Rejected{400|401|403}`, `ServerError{500|503}`, `Transport`) and
assert the resulting `LivenessFailure`. All `Rejected` → `Revoked`;
every `ServerError` / `Transport` → `Transient`. The SDK's own
`tests/liveness_boundary.rs` covers this; consumer tests are a
belt-and-suspenders against accidental inversion. Enable the
`test-support` feature in dev-dependencies to access `MemoryPasAuth`.
- **Cipher**: roundtrip + tamper. Already covered by SDK tests; consumers
need not duplicate unless they compose the cipher into their domain error
type (test the conversion).
- **`SessionStore::find` integration**: mock the PAS server (e.g.,
`wiremock`) to return 400 / 503 / 200 and assert the session row's
`revoked_at` / `last_verified_at` moved accordingly.
- **XFF skip**: assert that with the deployment's expected proxy chain
(e.g. `client, gfe, lb`), `extract_client_ip(headers, n)` returns the
client IP, not the proxy. The SDK ships unit tests for the walk; consumer
tests confirm the chosen `n` matches the actual prod topology.
## 10. Migration history
### v0.6.0 (2026-05-05) — Token format: PASETO v4.public → JWT (RFC 9068, EdDSA)
Implements Phase 6.1 of `RFC_2026-05-04_jwt-full-adoption` (D-04 = γ
port-and-adapter, locked 2026-05-05). The SDK retires its bundled PASETO
verification logic and consumes the published JWT engine (`ppoppo-token`
0.1.0) through a γ-shaped [`BearerVerifier`] port. **Every S-L invariant in
this document is unaffected** — the format swap lives below the port,
above the policy.
1. **Token format**: PASETO v4.public → JWT (RFC 9068, EdDSA). PAS
re-fetches keys from `/.well-known/jwks.json` (replaces
`/.well-known/paseto`).
2. **Verifier surface restructured**: function-style API
(`verify_v4_public_access_token`, `verify_v4_with_keyset`,
`extract_unverified_kid`, `parse_public_key_hex`, `PublicKey`,
`VerifiedClaims`) replaced by port-and-adapter shape:
- [`BearerVerifier`] async trait — single `verify(bearer_token)` method
- [`PasJwtVerifier::from_jwks_url(url, expectations)`] production adapter
- `AuthSession` opaque result with typed accessors (`ppnum_id`, `ppnum`,
`session_id`, `session_version`, `expires_at`)
- `Expectations { issuer, audience }` held at verifier construction
3. **`KeySet` removed from public surface** (now `pub(crate)`).
`PasJwtVerifier::from_jwks_url` is the single entry point.
4. **`axum` feature now requires `token`**. The middleware path consumes
`ppoppo_token::SV_CACHE_TTL` and `sv_cache_key()` directly (Finding G —
type-enforced shared-cache contract).
5. **PASETO is gone from the dependency tree** —
`cargo tree -p pas-external | grep pasetors` returns empty.
### v0.5.0 → v0.6.0 consumer migration checklist
In `Cargo.toml`:
- [ ] Bump `pas-external` to `"0.6"`.
In bare token-verification call sites (consumers using `BearerVerifier`
directly, not via Axum middleware):
- [ ] Replace `KeySet::fetch(paseto_url)` + `keyset.verify(token, iss, aud)`
with `PasJwtVerifier::from_jwks_url(jwks_url, Expectations::new(iss, aud))`
+ `verifier.verify(token)`.
- [ ] Update well-known URL: `/.well-known/paseto` → `/.well-known/jwks.json`.
- [ ] If you read raw claims fields, switch to `AuthSession` typed accessors
(`ppnum_id()`, `ppnum()`, `session_id()`, `session_version()`,
`expires_at()`). No `into_inner()` escape hatch — pre-flight grep audit
confirmed RCW + CTW middleware never accessed raw claims.
In Axum middleware wiring (`PasAuth` chain): **0 LOC changes**. The public
trait surface (`SessionStore`, `AccountResolver`, `SvAware`) is identical;
the JWT swap is below the port.
### v0.5.0 (2026-05-01) — Type rename: `SvAwareSessionResolver` → `SessionValidator`
Public-API rename only — the SDK now speaks "session validator" instead of
"sv-aware session resolver" (the latter exposed an implementation pattern
in the public name). Same shape, same semantics.
1. `SvAwareSessionResolver` → `SessionValidator`. Same generics
(`<S, P, B = MemorySvBackend>`).
2. `SessionValidator::resolve(&jar)` → `SessionValidator::validate(&jar)`.
The base `SessionResolver` keeps `.resolve(&jar)` — the rename
distinguishes "look up" (resolver) from "look up + sv enforce + refresh"
(validator).
3. `PasAuth::resolver()` → `PasAuth::session_validator()`. Likewise
`resolver_with_backend(b)` → `session_validator_with_backend(b)`.
Migration is global find-and-replace; no behavioral change.
### v0.4.0 (2026-05-01) — `SvCacheBackend` → `SvCachePort`; cache strategy folds into driver
Internal cache surface reduced to a single port; the strategy wrapper
(`SvCachePolicy`) folds into the SDK's sync state machine driver.
1. `SvCacheBackend` trait renamed to `SvCachePort` (vocabulary alignment
with the ports & adapters pattern). Method shape unchanged
(`load`, `store`).
2. `SvCachePolicy<B>` removed from public API. The `sv:` namespace prefix
and 60 s TTL now live in SDK-internal driver
(`middleware::sv::adapter`); the port stays namespace- and
TTL-agnostic.
3. `SvAwareSessionResolver::new` takes `Arc<B>` instead of
`SvCachePolicy<B>`. Direct callers (rare — primarily SDK boundary
tests) wrap via `Arc::new`.
4. `CheckResult` no longer public (was only the return type of
`SvCachePolicy::check`, now internal). The `sv_cache_outcome` tracing
field is unchanged.
90 % of consumers (single-pod, default backend) need 0 LOC changes —
`PasAuth::resolver()` stays inferrring `B = MemorySvBackend`.
### v0.3.0 (2026-05-01) — `session_version` module folded into `middleware::sv_cache`
Cache extension surface — previously split across the public
`SessionVersionCache` trait, `MemorySessionVersionCache`,
`SV_CACHE_KEY_PREFIX`, and `SV_CACHE_TTL` — folded into a single
`SvCachePolicy` strategy. The previously-violated TTL invariant
(`SessionVersionCache::set` accepted a `Duration` that the in-memory impl
ignored) is now structurally enforced.
1. `session_version` module **removed**. Replaced by
`middleware::sv_cache`.
2. `SessionVersionCache` trait → `SvCacheBackend`.
`get` → `load`, `set` → `store`. The `_ttl: Duration` parameter that
in-memory impls silently ignored on `set` must now be honored on
`store` (Redis `SETEX`, KVRocks TTL).
3. `MemorySessionVersionCache` → `MemorySvBackend`. Same Arc-internal
shape, same 10 000-entry FIFO cap.
4. `SV_CACHE_KEY_PREFIX` and `SV_CACHE_TTL` no longer public. Consumers
writing custom backends never need them — the policy is the only
public reader.
5. `SvAwareSessionResolver` generic count:
`<S, C, P>` → `<S, P, B = MemorySvBackend>`. Cache concern collapsed
into a single defaulted backend.
6. `PasAuth::resolver_with_cache(cache)` →
`resolver_with_backend(backend)`.
### v0.2.0 (2026-05-01) — Read-path plaintext seam closed; `RefreshTokenResolver` deleted
Breaking changes from v0.1.0. The S-L6 sv-aware refresh path now
routes through the same [`pas_refresh`] deep core that S-L3 already
uses, eliminating the consumer-owned decrypt step. The
`RefreshTokenResolver` trait is removed; its single method folds into
`SessionStore::get_refresh_ciphertext` returning
`Option<EncryptedRefreshToken>`. **No schema change. No env-var
change.** Migration is mechanical and net-negative LOC for consumers.
1. `RefreshTokenResolver` is **removed** from the public surface.
Its only method (`resolve_refresh_token` returning plaintext) was
the last place plaintext crossed the SDK→consumer boundary on the
read path. The replacement
[`SessionStore::get_refresh_ciphertext`] returns the at-rest
ciphertext as `Option<EncryptedRefreshToken>`; consumers wrap the
stored column via [`EncryptedRefreshToken::from_stored`] and never
decrypt.
2. [`pas_refresh`] now takes `&EncryptedRefreshToken` (was
`ciphertext: &str`). The newtype is the only shape ciphertext
travels into the SDK's decrypt site, so plaintext-as-`&str`
cannot accidentally be passed at any call site. Same change for
[`attempt_liveness_refresh`].
3. [`SvAwareSessionResolver`] drops the `R` generic parameter
(4 generics → 3: `S, C, P`). Its public constructor now takes
`cipher: Option<Arc<TokenCipher>>` so the SDK can call
`pas_refresh` internally. `None` is a soft misconfiguration:
ciphertext present + no cipher = `Expired` with a logged error.
4. [`PasAuth::new`] drops the 4th argument (`refresh_resolver`).
Constructor signature: `(config, account_resolver, session_store)`.
The `R` generic on `PasAuth` is removed (3 generics → 2: `S, P`).
5. Consumer-trait surface: 4 traits (`SessionStore`,
`AccountResolver`, `RefreshTokenResolver`, `SvAware`) → 3
(`SessionStore`, `AccountResolver`, `SvAware`). RCW/CTW
migration is `delete impl RefreshTokenResolver` (≈10 LOC) +
`add get_refresh_ciphertext to impl SessionStore` (≈5 LOC) +
drop the 4th arg from `PasAuth::new` call site.
### v0.1.0 → v0.2.0 consumer migration checklist
In `Cargo.toml`:
- [ ] Bump `pas-external` to `"0.2"`.
In adapters (typically `pas_adapter.rs`):
- [ ] Delete the `impl RefreshTokenResolver for ...` block entirely.
(Includes any `cipher.decrypt(...)` call inside.)
- [ ] Add `get_refresh_ciphertext` method to your existing
`impl SessionStore for ...` block. Read the
`refresh_token_ciphertext` column and wrap via
`EncryptedRefreshToken::from_stored(...)`. **No decryption.**
- [ ] If the adapter held a `TokenCipher` field solely to support the
deleted `RefreshTokenResolver` impl, remove it. Keep the cipher
passed to `PasAuthConfig::with_refresh_token_cipher` as before.
In wiring code (typically `app_state.rs` or `main.rs`):
- [ ] Drop the 4th argument from `PasAuth::new(...)`. The signature
is now `PasAuth::new(config, account_resolver, session_store)`.
In any test code that constructed `SvAwareSessionResolver` directly:
- [ ] The `new` signature changed to `(base, store, pas, cache,
cipher: Option<Arc<TokenCipher>>)`. Pass
`Some(Arc::new(cipher))` when the test scenario involves a real
refresh round-trip; `None` to exercise the misconfiguration
fail-CLOSED path.
Per-consumer migration is typically **net-negative LOC** (decrypt
logic removed). The compiler guides every step.
### v0.1.0 (2026-04-30) — `PasAuthPort` port boundary at the PAS network seam
> **Note:** Released as `0.1.0` after a pre-1.0 reset; equivalent in
> scope to the (yanked) `5.0.0` development line. All prior crates.io
> versions (`1.0.1` – `4.0.2`) were yanked on 2026-04-30. While in
> `0.x.y`, breaking changes are minor bumps (SemVer §11).
Breaking changes from v4.x. The PAS-side network boundary is now a
two-method port (`PasAuthPort::refresh` / `PasAuthPort::userinfo`)
with a single failure vocabulary (`PasFailure`). The S-L3 fail-OPEN
liveness path and the S-L6 fail-CLOSED sv-enforcement path now share
one HTTP-status classifier instead of duplicating the table inline.
Consumers gain a deterministic in-memory PAS substitute for their own
integration tests. **No schema change. No env-var change.** Migration
is a `Cargo.toml` bump for production callers; test code that
constructed the SDK's resolver directly gets a one-line constructor
update.
1. `AuthClient::refresh_token` and `AuthClient::get_user_info` are
**removed** from the public surface. Use the trait methods —
`use pas_external::pas_port::PasAuthPort;` then
`client.refresh(&rt).await` / `client.userinfo(&at).await`. The
inherent methods were one-call shims; their HTTP-status reading
logic now lives in `AuthClient::send_classified` and is exercised
through the trait.
2. `classify_refresh_error` is **removed** from the public surface.
Its semantics live in `AuthClient::send_classified` (which produces
a `PasFailure`) plus the `pas_refresh` deep core (which translates
`PasFailure` into `PasRefreshOutcome`). `PasFailure` is now the
unified vocabulary for both S-L3 (fail-open) and S-L6 (fail-closed):
one classifier, two policies.
3. `attempt_liveness_refresh` is now generic over `P: PasAuthPort`
instead of taking `&AuthClient`. Existing call sites compile
unchanged (`&AuthClient` satisfies the bound). The function body
collapses to a `match` over `pas_refresh(...).await`.
4. `TransientCause` loses `Transport` and `Unknown` — both merged into
`PasServerError`. **Document this loss explicitly so consumers
grep-find it.** Detail strings (which transport / which 5xx) are
not exposed at the cause level; consumers needing diagnostic
granularity implement `PasAuthPort` themselves and log inside the
adapter. The merge reflects the policy reality: S-L3 serves cache
for every transient flavor regardless of which.
5. `SvAwareSessionResolver` gains a `P: PasAuthPort` generic with the
field rename `auth_client → pas`. Production users of `PasAuth`
are unaffected — the default type parameter is `P = AuthClient` so
`PasAuth::resolver()` infers correctly. Test code that constructed
`SvAwareSessionResolver` directly (to substitute `MemoryPasAuth`)
gets a public `SvAwareSessionResolver::new(base, store,
refresh_resolver, pas, cache)` constructor.
6. `MemoryPasAuth` is exposed under the new `test-support` Cargo
feature for downstream consumer integration tests. Add
`pas-external = { version = "0.1", features = ["test-support"] }` in
`[dev-dependencies]` to script `expect_refresh` / `expect_userinfo`
the same way the SDK's own boundary tests do. No runtime cost when
the feature is disabled.
### v4.0.x (yanked) → v0.1.0 consumer migration checklist
In `Cargo.toml`:
- [ ] Bump `pas-external` to `"0.1"`.
- [ ] Drop any `pub use pas_external::session_liveness::classify_refresh_error;`
or similar re-export — the function is gone. Drive the same
decision via the public `LivenessFailure` returned by
`attempt_liveness_refresh`, or call the deep core
`pas_refresh(cipher, port, ciphertext)` directly if you need
the typed `PasRefreshOutcome` (`Refreshed` / `Rejected` /
`Transient`) instead of the consumer-shaped `LivenessOutcome`.
In call sites (production code):
- [ ] If you called `client.refresh_token(&rt)` or
`client.get_user_info(&at)` directly: add
`use pas_external::pas_port::PasAuthPort;` and switch to
`client.refresh(&rt).await` / `client.userinfo(&at).await`. The
return types (`TokenResponse`, `UserInfo`) are identical; the
error type changes from `Error` to `PasFailure`. Map at the call
site.
In tests:
- [ ] If you constructed `SvAwareSessionResolver` by hand (most
consumers don't — `PasAuth::resolver()` is the public path): use
the new public constructor `SvAwareSessionResolver::new(base,
store, refresh_resolver, pas, cache)` and pass `Arc::new(...)`
around each substrate. Add the `test-support` feature to
dev-dependencies if you want to script `MemoryPasAuth` here.
Per-consumer migration is typically 0 LOC for production wiring
(`PasAuth::resolver()` keeps inferring `P = AuthClient`) and a
handful of LOC for any test code that touched the resolver
directly. The compiler guides every step.
### v4.0.1 (2026-04-30) — `Ppnum` validation aligned with PAS DB CHECK
Patch release. `Ppnum::try_from` previously rejected values not matching
`len() == 11 && starts_with("777")`; the actual DB CHECK is `^[0-9]{11,}$`.
Prefix is band-allocated (canonical seed `100`) and length is variable
(11 = independent, 15/19/... = dependent sub-agent hierarchy, +4 digits per
nesting level). Production `100`-band and sub-agent ppnums could not
authenticate via consumer apps. Fix widens the accepted set; no consumer
code change required.
### v4.0.0 (2026-04-25) — sv enforcement as default (S-L6 mandatory)
Breaking changes from v3.x:
1. `SessionStore::AuthContext` must now `: SvAware` — expose
`ppnum_id() -> &str` and `sv() -> Option<i64>`. The bound is what
unlocks the new resolver.
2. `SessionStore` gains a fourth method, `update_sv(session_id, new_sv)`.
Persist the value alongside the session row.
3. `PasAuth::new` takes a fourth argument: `refresh_resolver: Arc<R>`
where `R: RefreshTokenResolver`. The trait returns the **plaintext**
PAS refresh token for a session id — typical impl is `find_by_id` +
decrypt with the existing `TokenCipher`.
4. `PasAuth::resolver()` now returns `SvAwareSessionResolver` instead of
the bare `SessionResolver`. Consumers get sv enforcement automatically;
any hand-rolled `validate_sv(...)` wrapper around the auth middleware
must be removed.
5. The free function `validate_sv` is removed. Per-request bearer-token
shape was incompatible with cookie-session middleware.
`SessionVersionCache`, `MemorySessionVersionCache`,
`SV_CACHE_KEY_PREFIX`, and `SV_CACHE_TTL` remain exported for
custom-cache injection via `PasAuth::resolver_with_cache(cache)`.
6. `NewSession` gains `sv: Option<i64>`, populated by the OAuth callback
from `UserInfo::session_version`. Persist alongside the session row.
### v3.1.0 → v4.0.0 consumer migration checklist
In schema:
- [ ] `ALTER TABLE <session_table> ADD COLUMN sv BIGINT NULL;` — existing
rows get `NULL`; the next refresh populates them.
In domain types:
- [ ] Add `pub sv: Option<i64>` to your session struct; populate it in
`SessionStore::create` from `NewSession::sv`.
- [ ] Add `pub ppnum_id: String` (PAS `sub` ULID) to `AuthContext` if not
already present.
- [ ] `impl SvAware for AuthContext`.
In `SessionStore` impl:
- [ ] Implement `update_sv(session_id, new_sv)` — straight UPDATE.
In adapter wiring:
- [ ] Implement `RefreshTokenResolver` for the adapter — `find_by_id` +
decrypt with the existing `TokenCipher`.
- [ ] Update `PasAuth::new(...)` to pass `Arc::new(refresh_resolver)` as
the fourth argument.
- [ ] Remove any `validate_sv(...)` wrapping in the auth middleware —
`PasAuth::resolver()` handles it.
Per-consumer migration is ~80–100 LOC, mechanical; the compiler guides
every step via the new trait bounds.
### v3.1.0 (2026-04-25) — opt-in `sv` validator (additive, non-breaking)
Added the plumbing later promoted to default in v4.0.0:
- `SessionVersionCache` trait — abstracts the 60 s `sv:{ppnum_id}` cache
(KVRocks / Redis / in-memory).
- `MemorySessionVersionCache` — default in-memory impl
(`tokio::sync::RwLock<HashMap>`); single-pod consumers, or consumers
without a shared cache substrate.
- `SessionVersionFetcher` trait — cache-miss source. `HttpUserInfoFetcher`
is the default; reads the new `UserInfo.session_version` field.
- `validate_sv(token_sv, ppnum_id, bearer_token, cache, fetcher)` — entry
point consumers wrapped around their existing
`verify_v4_public_access_token`. Legacy tokens (no `sv` claim) admit
unconditionally (R6 backwards-compat); stale tokens return
`ValidateSvError::Stale`; fetch failure on cache miss returns
`ValidateSvError::Transient` (fail-closed — consumers reject so a
DB/network blip cannot silently admit a revoked token).
The `oauth` feature now transitively pulls in `tokio` (sync) and
`async-trait`. `UserInfo` already used `#[non_exhaustive]`, so adding
`session_version` is SemVer-minor-safe.
This release was deprecated by v4.0.0 (sv enforcement built into
`PasAuth::resolver()`) but the cache/fetcher/free-function primitives
remain exported for the custom-cache injection path.
### v3.0.0 (2026-04-18) — type-level plaintext seam closed
Breaking changes from v2.x:
1. `NewSession.refresh_token: Option<String>` → `Option<EncryptedRefreshToken>`.
Consumers must call `.into_inner()` to obtain the persistable string and
must remove their own `cipher.encrypt()` call from `SessionStore::create`.
2. `axum` feature now depends on `session-liveness` transitively. Consumers
that listed both can drop `session-liveness` from their feature list.
3. `PasAuthConfig::with_refresh_token_cipher(cipher)` is now required for
refresh-token persistence. Without it, `NewSession.refresh_token = None`
and no liveness checks are possible.
4. `AuthClient::new(config)` → `AuthClient::try_new(config) -> Result`. The
silent `unwrap_or_default()` fallback (which produced a no-timeout
client) is removed; build failure is now fatal at startup.
5. `PasAuthConfig::with_xff_trusted_proxies(n)` is the new knob for proxy
topology. Default `n = 0` keeps single-hop deployments correct; GKE
Ingress + GFE typically requires `n = 2`.
6. `PasAuthConfig::from_env()` now refuses to start when (a) `DEV_AUTH=1`
and `PAS_REDIRECT_URI` is non-loopback, or (b) `secure_cookies = true`
and `COOKIE_KEY` is unset. Both replace previous "warn and continue"
paths that silently produced incorrect production deployments.
7. `extract_kid_from_token` renamed to `extract_unverified_kid`. New
`verify_v4_with_keyset(keyset, token, iss, aud)` does the safe
kid-lookup-then-verify dance; consumers should prefer it over building
the lookup themselves.
8. New `well-known-fetch` feature ships [`KeySet`] — a TTL-cached
well-known fetcher with rotation support. Consumers that hand-rolled
key fetching should migrate.
### v2.x → v3.x consumer migration checklist
In `Cargo.toml`:
- [ ] Bump `pas-external` to `"3.0"`.
- [ ] Drop `"session-liveness"` from the feature list (now transitive).
- [ ] Bump `pcs-external` to `"2.0"` if used.
In the OAuth-callback wiring (typically `main.rs` or `app_state.rs`):
- [ ] Build `TokenCipher` *before* `PasAuthConfig`.
- [ ] Add `.with_refresh_token_cipher(cipher.clone())` to the
`PasAuthConfig` builder chain.
- [ ] Add `.with_xff_trusted_proxies(N)` matching the proxy topology.
- [ ] Replace `AuthClient::new(...)` with `AuthClient::try_new(...).expect(...)`.
In `SessionStore::create`:
- [ ] Remove the `cipher.encrypt(rt)` call. The SDK already encrypted.
- [ ] Replace `Some(self.cipher.encrypt(rt)?)` with
`session.refresh_token.map(|t| t.into_inner())`.
In `pcs-external` consumers (RCW only):
- [ ] `auth_request(api_key, body)` now returns `Result<Request<T>, Error>`.
Map the error to your domain error type at each call site.
[`TokenCipher`]: ../src/session_liveness/cipher.rs
[`TokenCipher::encrypt`]: ../src/session_liveness/cipher.rs
[`EncryptedRefreshToken`]: ../src/session_liveness/cipher.rs
[`EncryptedRefreshToken::from_stored`]: ../src/session_liveness/cipher.rs
[`attempt_liveness_refresh`]: ../src/session_liveness/liveness.rs
[`AuthClient::send_classified`]: ../src/oauth.rs
[`PasFailure`]: ../src/pas_port/port.rs
[`pas_refresh`]: ../src/pas_port/core.rs
[`PasAuthConfig::with_refresh_token_cipher`]: ../src/middleware/config.rs
[`PasAuth::new`]: ../src/middleware/auth.rs
[`SvAware`]: ../src/middleware/traits.rs
[`SessionStore::get_refresh_ciphertext`]: ../src/middleware/traits.rs
[`SessionValidator`]: ../src/middleware/validator.rs
[`SvCachePort`]: ../src/middleware/sv_cache.rs
[`MemorySvBackend`]: ../src/middleware/sv_cache.rs
[`BearerVerifier`]: ../src/token/port.rs
[`PasJwtVerifier`]: ../src/token/jwt.rs
[`PasJwtVerifier::from_jwks_url`]: ../src/token/jwt.rs
[`UserInfo::session_version`]: ../src/oauth.rs