authentication-verifier
Offline PASETO v4.public verification for Main X Index peer services.
The authentication-service is
the federation's single sign-on provider: it exchanges a server-side
cookie session for short-lived PASETO v4.public (Ed25519-signed)
cross-service tokens, and publishes its Ed25519 public key(s) at
/.well-known/paseto-keys. Every other service verifies those tokens
offline with this crate — fetch the key set once at boot, build a
Verifier, then verify each bearer token locally. No shared secret, no
per-request introspection hop.
v0.2.0 pivots from RS256 JWT to PASETO v4.public (BREAKING). Per the canonical design (authentication-sessions.md), JWT is removed from the auth path: the session is a Postgres-backed cookie session and the only cross-service token is a short-lived PASETO. See CHANGELOG.md for the migration note.
v0.3.0 adds ABAC (additive). Per (authorization-attributes.md), verified
Claimscarry subject attributes in the newattrsclaim, and theabacmodule ships the family's shared, pure policy engine (Policy::evaluateover attrs + action + entity, first-match-wins, default allow-read / deny-mutation).scope/rolesare deprecated for authorization. Pre-0.3 tokens verify unchanged (attrs⇒ empty).
v0.4.0 adds record-level resource attributes (additive). Per §9,
Policy::evaluate_with_resourcefeeds attributes of the specific loaded record into the decision viaresource.*whenkeys (e.g. deny write on a high-sensitivity record unlessaccess=admin).Policy::evaluateis unchanged.
v0.5.0 adds ownership templates + environment attributes (additive). A
whenvalue$sub/resource.owner: ["$sub"]= owned by the caller), andPolicy::evaluate_with_contextadds anenv.*namespace for request-time / network context (§4/§10). Prior methods unchanged.
v0.6.0 adds obligations (additive). An allow rule may attach
obligations(e.g."mask","audit") that theDecisioncarries for the enforcement point to honour — the engine does not interpret them (§11).Decision::requires("mask")is the convenience check.
v0.7.0 adds
ReloadablePolicy(additive). A thread-safe holder (new/current/store) that lets a service hot-swap the active policy at runtime — no restart — with a lock-light per-request read path. The reload trigger is the service's concern.
v0.8.0 adds
ReloadableVerifier(additive). The same holder shape for theVerifier, so a service can hot-swap its key set for key rotation — e.g. via a periodic re-fetch of/.well-known/paseto-keys— without a restart. Keep the current keys on a failed fetch.
Unreleased since v0.8.0.
Cargo.tomlis still0.8.0; three further changes have landed in code/tests but not yet in a datedCHANGELOG.mdrelease heading (see CHANGELOG.md[Unreleased]): hardenedfrom_paseto_keys_url(HTTPS-only except loopback HTTP, timeout, no redirects, a 64 KiB body cap), an ABAC fix so a negatedresource./env.rule can't match vacuously when no resource/environment was supplied, afuzz/cargo-fuzz harness, and (2026-07-27) the verifier becoming algorithm-agile: a key naming an algorithm this build doesn't implement is kept and diagnosed asUnsupportedAlgorithmrather than silently dropped — seespec/index.md§5.
- Spec: spec/index.md
- Agent guide: AGENTS.md
- Design: authentication-sessions.md §5, authorization-attributes.md
- Issuing service: authentication-service-with-loco
Quick start
[]
= "0.8"
# or, to fetch the key set over HTTPS at boot:
# authentication-verifier = { version = "0.8", features = ["fetch"] }
# in-monorepo alternative (path dependency):
# authentication-verifier = { path = "../authentication-verifier-rust-crate" }
use ;
// Boot: build from a paseto-keys document you already have…
let keys: Value = from_str?;
let verifier = from_paseto_keys_value?;
// …or fetch it over HTTPS (requires the `fetch` feature):
// let verifier = Verifier::from_paseto_keys_url(
// "https://auth.example.gov/.well-known/paseto-keys",
// "authentication-service",
// "main-x-service",
// ).await?;
// Per request: verify the bearer token locally.
let claims: Claims = verifier.verify?;
println!; // the user pid (UUID)
Construct the Verifier once and share it behind an Arc; verify
is read-only and allocation-light.
Authorization (ABAC)
After verifying, decide what the caller may do with the shared
policy engine — the same code all nine entity services embed in their
blanket /api/* guards:
use ;
// Boot: load the configured policy, falling back to the built-in
// default (svc=true ⇒ everything; access=admin ⇒ destructive+write;
// access=write ⇒ write; otherwise read-only).
let policy = var
.ok
.and_then
.unwrap_or_else;
// Per request: derive the action from the HTTP method (plus the
// crate's destructive named POSTs), then evaluate.
let decision = policy.evaluate;
if !decision.allowed
401 means missing/bad credential (verification failed); 403 means
valid credential, policy denied. See
authorization-attributes.md
for the attribute model, policy language, and default policy.
API summary
| Item | Purpose |
|---|---|
Verifier::from_paseto_keys_value(&keys, issuer, audience) |
Build from an in-memory paseto-keys document. Every entry with a kid is loaded; an entry naming an algorithm this build doesn't implement is kept (diagnosable, not silently skipped — see "Algorithm agility" below) but only Ed25519 entries are usable; a duplicate kid is an error, not last-wins; an empty key set is permitted and rejects every token with UnknownKid. |
Verifier::from_paseto_keys_url(url, issuer, audience).await |
Fetch the key set, then build. Requires features = ["fetch"] (pulls in reqwest with rustls). Requires https://, except http:// to a loopback host (dev/CI); 10 s timeout; no redirects; 64 KiB response cap. |
Verifier::verify(token) -> Result<Claims, VerifyError> |
Select the key by the token footer kid, dispatch on its algorithm, check the PASETO v4.public (Ed25519) signature, then enforce iss, aud, exp, and nbf. |
Verifier::key_count() -> usize |
Number of usable (Ed25519) keys loaded. |
Verifier::unsupported_key_count() -> usize / Verifier::algorithms() -> Vec<String> |
How many loaded keys this build cannot use, and the algorithm labels seen — for logging/metrics during an algorithm rollout. |
ReloadableVerifier |
A hot-swappable verifier holder (v0.8): new(verifier), current() -> Arc<Verifier> (per-request snapshot), store(verifier) (runtime key-set swap for rotation, with no restart). |
Claims |
Verified claims: sub (user pid, UUID string), iss, aud, iat, nbf, exp, sid (originating auth-service session), attrs (ABAC subject attributes; empty on pre-0.3 tokens), plus scope/roles (deprecated for authorization). |
Decision |
{ allowed, reason, obligations } — the outcome; obligations (v0.6) are the deciding allow rule's advisory instructions ("mask", "audit") for the enforcement point. Decision::requires("mask") checks one. |
Policy / Rule / Action / Decision (the abac module, re-exported at the root) |
The shared ABAC engine: Policy::from_json loads a configured policy, Policy::default_policy() is the built-in coarse tier, Policy::evaluate(&claims, action, entity) decides first-match-wins with default allow-read / deny-mutation. Pure — no I/O, no clock, no panics. |
Policy::evaluate_with_resource(&claims, action, entity, &resource) |
As evaluate, plus a BTreeMap<String, Vec<String>> of record-level resource attributes matched by resource.* when keys (v0.4). A service passes attributes of the record it just loaded so policies can gate on e.g. record sensitivity. |
Policy::evaluate_with_context(&claims, action, entity, &resource, &env) |
As above, plus environment attributes matched by env.* when keys (v0.5) — request-time / network context the service supplies (e.g. env.after_hours), keeping the engine deterministic. A when value $sub/$email resolves to the caller's identity for ownership rules. |
ReloadablePolicy |
A hot-swappable policy holder (v0.7): new(policy), current() -> Arc<Policy> (per-request snapshot), store(policy) (runtime swap). Lets a service reload the policy without a restart; the reload trigger is the service's concern. |
VerifyError variants
| Variant | Meaning |
|---|---|
Keys(String) |
The key-set document was missing or structurally invalid (no key array; Ed25519 entry missing kid or public-key material; bad key bytes; a duplicate kid). |
Malformed(String) |
The token isn't a structurally valid v4.public token, or its footer isn't {"kid": ...} — distinct from a signature failure. |
MissingKid |
The token footer carries no kid, so no key can be selected. |
UnknownKid(String) |
No loaded key matches the token's kid — stale key cache, wrong issuer, or forgery. Refetch the key set to pick up a key rotation. |
Paseto(String) |
The Ed25519 signature check failed. |
Claim(String) |
The signature was valid but iss / aud / exp / nbf did not satisfy the policy. |
UnsupportedAlgorithm { kid, algorithm } |
The kid selected a key whose algorithm this build doesn't implement. Distinct from UnknownKid on purpose: refetching the key set will not fix it, upgrading the binary will (algorithm agility, 2026-07-27). |
Fetch(String) |
Fetching the key set over HTTP failed (only with the fetch feature). |
Algorithm agility
Since 2026-07-27, Verifier dispatches on each key's declared
algorithm rather than assuming Ed25519, so a future non-Ed25519 key in
the published set can't silently verify as if it were one. Keys for an
algorithm this build doesn't implement are kept, not dropped — a
token naming one fails as UnsupportedAlgorithm (upgrade the binary),
not UnknownKid (refetch the key set), which is the correct diagnosis
for a partial algorithm rollout. See
authentication-sessions.md
§5.1 for why this exists ahead of any actual algorithm switch.
The paseto-keys / kid contract
This crate is the peer-side mirror of the auth-service's own token
verification: same PASETO v4.public format, same Claims shape, same
footer-kid selection — but keyed off the published key set rather
than a locally held key, so any service can embed it.
- The service publishes Ed25519 public keys at
/.well-known/paseto-keys, each entry carrying akidand the base64url (no padding) public-key bytes. - Tokens are PASETO v4.public; the footer carries the
kidthat selects the verifier key — a stable identifier, set on every token. Claimsis duplicated byte-identically between the service and this crate by convention; the service's cross-crate contract test pins the round-trip.- Default issuer/audience:
authentication-service/main-x-service. - Revocation (signout) is enforced at the auth service via the session.
Peers honour a token until
exp; the service keeps TTLs short (default ~300 s) to bound that window.
Testing
License
MIT OR Apache-2.0 OR GPL-2.0 OR GPL-3.0 OR BSD-3-Clause