Continuous Access Evaluation (CAEP/SSF)
A session that was legitimate when it started does not stay legitimate. The user’s password gets reset, their laptop drops out of compliance, an administrator revokes their session — and none of that reaches your service until the next token refresh, which may be an hour away.
The Shared Signals Framework closes that gap: a cooperating provider pushes you a signed
Security Event Token (SET) the moment something changes. authkestra-ssf is the receiving
side of that exchange.
Standards implemented
Section titled “Standards implemented”| Specification | What it defines | Where it lands |
|---|---|---|
| RFC 8417 | The SET itself: a JWT whose payload is a set of events | SecurityEventToken, SetVerifier |
| RFC 8935 | Push delivery over HTTP, and the 202/400 response semantics | PushReceiver, PushResponse |
| RFC 9493 | Structured subject identifiers (sub_id) |
SubjectIdentifier |
| OpenID CAEP 1.0 | The event vocabulary: session revoked, credential change, and friends | CaepEvent |
What is implemented today — and what is not
Section titled “What is implemented today — and what is not”Implemented:
- Ingest and validate SETs. Explicit
typchecking, an algorithm allow-list that can never containnone, signature verification against a single key or akid-based resolver, issuer and audience checks,iatfreshness with configurable leeway and optional maximum age,exphonoured when a transmitter sends one, non-emptyevents, andjtireplay detection. - Typed CAEP events. All five CAEP 1.0 event types with their specific claims. Event types this crate does not model (RISC, SCIM, vendor extensions) are preserved verbatim rather than dropped, so a handler that understands them still can.
- A framework-agnostic push receiver implementing the RFC 8935 response semantics, including
the registered error codes (
invalid_request,invalid_key,invalid_issuer,invalid_audience,authentication_failed,access_denied).
Not implemented yet:
- Session state is not updated when a revocation signal arrives. Nothing in the engine reacts
to a
session-revokedevent on its own; you write aSetHandlerand do it yourself. - No middleware rejects tokens invalidated by a CAEP event.
- No Axum or Actix routes are wired.
PushReceiver::receivereturns a plain data structure; turning it into a route is a few lines in your own handler today.
The three gaps are tracked in authkestra#25.
Setting up a receiver
Section titled “Setting up a receiver”[dependencies]authkestra-ssf = "0.11"jsonwebtoken = { version = "11", features = ["rust_crypto"] }use std::sync::Arc;use std::time::Duration;
use authkestra_ssf::{InMemorySetReplayGuard, PushReceiver, SetVerifier};use jsonwebtoken::{Algorithm, DecodingKey};
let verifier = SetVerifier::builder("https://idp.example.com/") .audience("https://sp.example.com/caep") .algorithms([Algorithm::EdDSA]) .key(transmitter_key) .max_age(Duration::from_secs(60 * 60)) .replay_guard(Arc::new(InMemorySetReplayGuard::new(Duration::from_secs(60 * 60)))) .build()?;
let receiver = PushReceiver::new(Arc::new(verifier)) .with_handler(Arc::new(MyHandler));Then, in whatever HTTP handler you expose at your delivery endpoint:
let response = receiver.receive(content_type_header, body_bytes).await;// response.status() -> 202, 400 or 500// response.content_type() -> "application/json" on failure// response.content_language() -> "en" on failure (RFC 8935 §2.3 requires it)// response.body() -> {"err": ..., "description": ...} on failureReacting to an event
Section titled “Reacting to an event”use async_trait::async_trait;use authkestra_ssf::{CaepEvent, HandlerError, SecurityEventToken, SetHandler};
struct MyHandler;
#[async_trait]impl SetHandler for MyHandler { async fn handle( &self, set: &SecurityEventToken, event: &CaepEvent, ) -> Result<(), HandlerError> { if let CaepEvent::SessionRevoked(revoked) = event { // `revoked.metadata.subject` is the RFC 9493 subject identifier, when the // transmitter sent one; `set.sub_id` is the SET-level one. let _ = revoked; // ... look the subject up and drop its sessions ... } Ok(()) }}Returning HandlerError::Rejected answers the transmitter with 400 access_denied; returning
HandlerError::Internal answers 500, so a conformant transmitter retries. Handlers run before
the response is produced, because this crate has no store of its own — if you want the
asynchronous shape RFC 8935 §2 suggests, enqueue inside the handler and return immediately.
Your handlers must be idempotent
Section titled “Your handlers must be idempotent”The replay slot for a SET is taken during verification, before any handler runs — that is what
makes two concurrent deliveries of the same SET dispatch only once. When a handler returns
HandlerError::Internal, the receiver gives that slot back before answering 500, so the
transmitter’s retry is actually verified and dispatched instead of being acknowledged as a
duplicate and dropped.
The consequence is that a retry replays the whole delivery: every event in the SET, through
every handler, including the ones that already succeeded before the one that failed. Write
handlers that can run twice — key your writes on set.jti, or make them upserts.
HandlerError::Rejected keeps the slot, since a 400 tells the transmitter not to retry.
One window is left open deliberately: a concurrent duplicate that arrives between the record and
the release is answered 202 without dispatch. If the original delivery then fails, that
particular duplicate has already been answered — but the transmitter’s retry of the failed
delivery finds the slot free and delivers the event, so it is not lost. Holding the slot across
dispatch instead would close the window at the cost of losing an event on every handler failure.
Behaviours that surprise people
Section titled “Behaviours that surprise people”- A retransmitted SET is answered
202, not an error. RFC 8935 §2 requires the recipient to respond to a repeat transmission exactly as it would to a first one, so replay detection suppresses handler dispatch, not the acknowledgement. Content-Typemust beapplication/secevent+jwt. Anything else is a400 invalid_request.- A malformed event payload is refused without spending the
jti. Events are decoded as part of verification, so if a transmitter sends acredential-changemissing its required claims you get a400 invalid_request, the replay slot stays free, and the corrected retransmission under the samejtiis processed normally instead of being mistaken for a duplicate. - Error responses do not echo your configuration. A wrong-issuer or wrong-audience SET gets
invalid_issuer/audience mismatchand nothing more, and a stale or future-dated one does not reveal youriat_leewayormax_age: the push endpoint is unauthenticated, so anything that describes your policy stays in your logs (as structured tracing fields) rather than going back to whoever POSTed. - Explicit typing is mandatory here. RFC 8417 §2.3 only requires
typ: secevent+jwtwhen a SET could be confused with another kind of JWT — which is exactly the situation a general receiver is in, so this crate always requires it. expis optional;nbfis ignored. A SET describes something that already happened, so RFC 8417 §2.2 discouragesexpentirely and never profilesnbf. Freshness comes fromiat.- An empty
jtiis refused. RFC 8417 §2.2 makesjtithe SET’s unique identifier. An empty or whitespace-only one identifies nothing, and would collapse the replay guard to one slot per issuer — so it is a400 invalid_request.
Body size limits
Section titled “Body size limits”PushReceiver refuses any body larger than DEFAULT_MAX_BODY_BYTES (1 MiB) with
400 invalid_request, before it parses anything. Change it with:
let receiver = PushReceiver::new(verifier).with_max_body_bytes(64 * 1024);Set a limit in your HTTP layer as well. By the time receive is called, the body has already
been read into memory by the framework, so this check is a second line of defence — it stops an
oversized body reaching the parser and the signature verifier, but it cannot prevent the
allocation. What actually bounds what gets buffered is axum’s DefaultBodyLimit, actix’s
PayloadConfig, or your reverse proxy’s client_max_body_size.
Building the models in your own tests
Section titled “Building the models in your own tests”The public models are #[non_exhaustive], so they cannot be built with struct-literal syntax from
outside the crate — but every one of them has a constructor, precisely so that the code you write
to map a CAEP event onto your own domain stays unit-testable without minting and verifying a real
SET:
use authkestra_ssf::{CaepMetadata, CredentialChange, CredentialType, ChangeType, SecurityEventToken};
let set = SecurityEventToken::new("https://idp.example.com/", "jti-1", 1_700_000_000, events);let mut change = CredentialChange::new(CredentialType::Password, ChangeType::Update);change.metadata = CaepMetadata::empty();None of these constructors validate anything: a SET your service should act on comes out of
SetVerifier, never out of a constructor.