PermDock
Adapters

Shared Signals (SSF / CAEP)

permdock/ssf receives Shared Signals Framework security event tokens (push and poll) and maps CAEP events to snapshot and cache invalidation so permissions go stale when the IdP says so, not on a timer.

permdock/ssf is a receiver for Security Event Tokens delivered under the Shared Signals Framework. It verifies incoming CAEP events such as session-revoked, credential-change and assurance-level-change, resolves the affected subject, and calls the app's onEvent handlers, which typically invalidate the Next.js cache tag and client snapshots for that user.

Purpose

PermDock snapshots are cached: on the server behind use cache: private and updateTag, on the client in PermDockProvider, on React Native in persisted storage. Without a signal, a revoked session or a demoted role stays effective until the cache expires. Shared Signals Framework 1.0 and CAEP 1.0 (final since 2 September 2025, OpenID Foundation) define how identity providers transmit such events: Entra, Okta and Auth0 transmit today and Keycloak ships an experimental transmitter. A receiver in PermDock turns "stale for N minutes" into "stale until the IdP says so".

API

import { createPermDock } from "permdock/ssf";
import { updateTag } from "next/cache";
import { snapshotTag } from "permdock/next";

export const { receiver } = createPermDock(policy, {
  issuer: "https://login.example.com", // expected SET issuer
  audience: "https://app.example.com/ssf", // this receiver
  jwks: "https://login.example.com/.well-known/jwks.json",
  subject: (setSubject) => userIdFrom(setSubject), // maps the SET sub_id to a PermDock principal id
  onEvent: {
    "session-revoked": ({ subject }) => updateTag(snapshotTag(subject.id)),
    "credential-change": ({ subject }) => updateTag(snapshotTag(subject.id)),
    "assurance-level-change": ({ subject, event }) =>
      updateTag(snapshotTag(subject.id)),
  },
});

app.post("/ssf/events", (c) => receiver.push(c.req.raw)); // RFC 8935 push delivery
receiver.poll({ endpoint: "https://login.example.com/ssf/poll", every: "30s" }); // RFC 8936 poll delivery
  • receiver.push(request) is a Fetch handler for RFC 8935 push delivery: it accepts application/secevent+jwt, verifies, dispatches and returns 202.
  • receiver.poll(options) runs RFC 8936 poll delivery from a long-lived process: it fetches pending SETs, dispatches them and acknowledges by jti. Each poll request is aborted after 30 seconds, or earlier through options.signal.
  • onEvent is keyed by CAEP 1.0 event name (session-revoked, session-established, session-presented, credential-change, assurance-level-change, token-claims-change, device-compliance-change, risk-level-change) or RISC name (account-disabled, account-purged, credential-compromise), plus '*'. Handlers receive the resolved subject, the raw event claims, event_timestamp and the jti.
  • onEvent.verification receives an SSF 1.0 stream verification event (https://schemas.openid.net/secevent/ssf/event-type/verification) as { state, jti, issuer }. The receiver answers 202 with or without the handler, and emits a verification audit event carrying the state, so the transmitter's verification request can be confirmed end to end.
  • The RISC events account-disabled, account-purged and credential-compromise revoke the whole account: revocations gets session-revoked for the principal with no session, and approvals cancels every pending request of the principal.
  • subject maps the SET sub_id (formats iss_sub, email, opaque, complex, and others defined by the Subject Identifiers RFC) to the id PermDock uses in tag and snapshots. A SET without sub_id falls back to sub, then to the event's own subject member (the pre-1.0 CAEP layout some transmitters still send). The session of a complex subject becomes subject.session, so a session-revoked event ends only that session.
  • verifier (optional, a TokenVerifier from extension interfaces) replaces the built-in joseTokenVerifier over jwks; discovery: '<issuer>' may stand in for issuer plus jwks exactly as in permdock/jwt; the document's issuer then becomes the expected SET issuer, for the built-in verifier and a custom one alike.
  • replay (optional, a ReplayStore) replaces the in-memory jti store. memoryReplayStore() is the default. Keys are opaque strings namespaced by issuer, so receivers for two IdPs can share one store without a jti collision. claim(key, expiresAt?) records a key and returns true only for the first caller, and release(key) forgets it when a handler failed. A store with both makes concurrent deliveries of one SET dispatch once; a store with only seen and remember(key, expiresAt?) still works, but two simultaneous deliveries can both dispatch. The receiver passes the SET exp + clockTolerance, or for a SET without exp iat + maxTokenAge + clockTolerance, so a key outlives every moment its SET would still verify. The memory store evicts expired keys on read.
  • maxTokenAge (optional, seconds, default 86400) bounds a SET without exp (RFC 8417 section 2.2 allows one): once iat is older, the delivery is refused with invalid_request and cause expired, so a captured SET cannot be replayed after its replay record is evicted.
  • A SET carrying several events records each event that succeeded. When a later handler fails, the receiver answers 500 with an empty body, the transmitter retries, and only the events that did not succeed are dispatched again.
  • revocations (optional, a RevocationFeed) ends and revalidates open streams and sockets. After the handler succeeds, a verified session-revoked (CAEP and Back-Channel Logout) publishes session-revoked for the principal and subject.session, which aborts that session's connections; credential-change, assurance-level-change and token-claims-change publish changed for the principal, which makes every connection it holds resolve its subject again. A failed handler publishes nothing, and a throwing feed never fails the delivery.
  • approvals (optional, an ApprovalStore) lets session-revoked (CAEP and Back-Channel Logout) call cancelApprovals for the principal and subject.session. Pending requests are marked rejected with resolvedBy: 'system:ssf'. The receiver emits an approvals-cancelled audit event with the count.

Transmitter

A service that emits signals uses the same entry. Each builder returns a SetEvent; signSet signs it, and pushSet delivers it or setPollHandler serves it.

import {
  caepEvents,
  memorySetQueue,
  pushSet,
  setPollHandler,
  signSet,
} from "permdock/ssf";

const set = await signSet(
  caepEvents.sessionRevoked({
    subject: { format: "iss_sub", iss: issuer, sub: user.id },
    initiatingEntity: "admin",
  }),
  signer,
  { audience: stream.aud },
);

const delivery = await pushSet(set, {
  endpoint: stream.delivery.endpoint_url,
  authorization: stream.delivery.authorization_header,
});
if (!delivery.ok && delivery.retry) {
  await retryLater(set);
}

const queue = memorySetQueue();
await queue.enqueue(stream.stream_id, jti, set);
export const POST = setPollHandler({
  queue,
  authorize: (request) => streamForBearer(request.headers.get("authorization")),
});
  • signSet(event, signer, { audience, jti?, txn?, issuedAt? }) writes sub_id, events with exactly one entry, and txn; iss is the signer's own. cloudSigner refuses secevent+jwt: a SET signed with the environment's keys would read as one from the Cloud.
  • pushSet never throws. A 2xx is { ok: true }; a 400 maps the receiver's RFC 8935 err (invalid_request, invalid_key, invalid_issuer, invalid_audience, authentication_failed, access_denied) to the kebab-case cause; 401 and 403 are authentication-failed and access-denied; a network failure, a timeout, a 429 or a 5xx is unavailable with retry: true.
  • setPollHandler applies the request's acks and setErrs to the SetQueue, then answers { sets, moreAvailable? } with up to maxEvents (default and cap 100). It answers at once whatever returnImmediately says. authorize returns the stream id the caller may poll, or null for 401.
  • SetQueue is an extension interface: enqueue, pending, ack and fail, each per stream. memorySetQueue() loses its SETs on restart; more than one instance needs a shared queue.
  • streamVerificationEvent(stream, state) is the SSF 1.0 verification event a transmitter sends when a receiver calls its verification endpoint. SSF_PUSH_DELIVERY and SSF_POLL_DELIVERY are the delivery method URNs.

Transmitter endpoints

ssfTransmitter serves the SSF 1.0 stream-management API over an SsfStreamStore and a SetQueue, and fanOutSet sends one event to every stream that asked for it:

import {
  caepEvents,
  fanOutSet,
  memorySetQueue,
  memorySsfStreamStore,
  ssfTransmitter,
} from "permdock/ssf";

const streams = memorySsfStreamStore();
const queue = memorySetQueue();
const transmitter = ssfTransmitter({
  issuer: "https://signals.example.com",
  endpoint: "https://signals.example.com/ssf",
  streams,
  queue,
  signer,
  authorize: (request) =>
    receiverForBearer(request.headers.get("authorization")),
});
app.all("/ssf/*", (c) => transmitter(c.req.raw));
app.get("/.well-known/ssf-configuration", (c) => transmitter(c.req.raw));

await fanOutSet(
  caepEvents.sessionRevoked({ subject, initiatingEntity: "admin" }),
  { streams, queue, signer },
);
RouteMethodSSF 1.0
/.well-known/ssf-configurationGET, no credentialsSection 7.1 metadata: issuer, jwks_uri, both delivery methods, endpoints
<endpoint>/streamPOST, GET, PATCH, PUT, DELETESection 8.1.1 stream configuration
<endpoint>/statusGET, POSTSection 8.1.2 stream status
<endpoint>/verifyPOSTSection 8.1.4 verification: signs streamVerificationEvent and delivers it
<endpoint>/poll?stream_id=<id>POSTRFC 8936 poll through setPollHandler, for a poll stream
  • authorize(request) answers the receiver the credentials authenticate, or null (or a throw) for 401. A receiver reads, changes and polls only the streams it created; another receiver's stream is 404.
  • POST /stream takes the receiver-supplied delivery, events_requested and description and answers 201 with the full configuration. The transmitter sets the rest: stream_id (a UUID), iss, aud (audience(receiver), default the receiver), events_supported (eventsSupported, default every CAEP and RISC event the receiver handles) and events_delivered, which is events_requested within events_supported. Without delivery a stream is poll, and a poll stream's endpoint_url is <endpoint>/poll?stream_id=<id>. A push stream needs an http or https endpoint_url.
  • PATCH keeps the receiver-supplied properties the body leaves out; PUT resets them to their defaults. Both name the stream in the body's stream_id; GET and DELETE take it as a query parameter, and GET without one lists the receiver's streams.
  • Every error is RFC 9457 Problem Details: 400 with a detail naming the property, 404, 405 with Allow, 409 for verifying a disabled stream, 413 for a body over 64 KiB.
  • fanOutSet(event, { streams, queue, signer, fetch? }) reads every stream, keeps the ones that are not disabled and list event.type in events_delivered, and signs the event once per distinct aud. A push stream gets it through pushSet; a poll stream, and a paused push stream, get it in the queue. It answers one { stream, jti, queued, delivery } row per stream that took the event; a push with delivery.retry is the caller's to retry.
  • A paused stream holds its SETs: a poll answers no sets, and a push stream's queue is pushed in order when it is enabled again. disabled and DELETE drop the stream's pending SETs.
  • SsfStreamStore is an extension interface: create(owner, configuration), get, list(owner?), update(configuration), delete, status and setStatus. get and status answer null for an unknown stream, and a new stream is enabled. memorySsfStreamStore() loses its streams on restart. testSsfStreamStore(store) from permdock/testing is its conformance suite.
  • Subjects are not managed per stream: the metadata says default_subjects: "ALL", and the add and remove subject endpoints are not served.

Poll client

pollSets({ endpoint, authorization?, acks?, setErrs?, maxEvents?, returnImmediately? }) is one RFC 8936 poll request for a receiver that verifies SETs itself, such as a Cloud relaying another transmitter's stream. It answers { ok: true, sets: [{ jti, set }], moreAvailable } with the SETs unverified, or the same failure pushSet maps, plus invalid-request for a 2xx body without a sets object. It never throws. maxEvents defaults to 100 and 0 only acknowledges; returnImmediately defaults to true. receiver.poll makes the same requests.

Shared replay store

// Redis: SET NX EX is the atomic claim, TTL from expiresAt
const ttl = (expiresAt?: number) =>
  expiresAt === undefined
    ? 3600
    : Math.max(1, expiresAt - Math.floor(Date.now() / 1000));
const replay: ReplayStore = {
  seen: async (key) => (await redis.exists(`ssf:${key}`)) === 1,
  remember: async (key, expiresAt) => {
    await redis.set(`ssf:${key}`, "1", { EX: ttl(expiresAt) });
  },
  claim: async (key, expiresAt) =>
    (await redis.set(`ssf:${key}`, "1", { NX: true, EX: ttl(expiresAt) })) ===
    "OK",
  release: async (key) => {
    await redis.del(`ssf:${key}`);
  },
};

// Postgres: claim is INSERT … ON CONFLICT DO NOTHING RETURNING key; release is DELETE

OpenID Connect Back-Channel Logout

The same receiver accepts an OpenID Connect Back-Channel Logout logout_token: a JWT with typ: logout+jwt, an events claim keyed http://schemas.openid.net/event/backchannel-logout, sub and/or sid, and no nonce. Mount it on the URL registered as the RP's backchannel_logout_uri:

app.post("/oidc/logout", (c) => receiver.logout(c.req.raw)); // application/x-www-form-urlencoded, logout_token=…

receiver.logout verifies the token with the same TokenVerifier and iss / aud / iat / jti rules (Back-Channel Logout section 2.6), rejects one carrying nonce, and dispatches it as a session-revoked event whose subject is resolved from sub and whose session is sid. A handler that keys its cache on subject.session (OpenID Connect) invalidates exactly the session the OP ended; a handler keyed on subject.id invalidates every session for the user, which is the CAEP session-revoked behaviour and a safe default. The response is 200 on success, 400 with error: invalid_request on a token that fails verification, as the specification requires, and 500 when the handler throws; no body is trusted to name the OP.

Back-Channel Logout and CAEP session-revoked are two deliveries of one signal, and PermDock treats them identically after verification: SSF for providers that transmit CAEP, Back-Channel Logout for the many OPs that implement only OIDC. Neither is a decision input; both invalidate.

Request lifecycle

  1. Delivery: a SET arrives by push (HTTP POST) or poll (fetched batch).
  2. Verification: signature against jwks, iss equals issuer, aud contains audience, iat within tolerance, exp honoured when present and optional as RFC 8417 section 2.2 allows, jti not seen before (replay store, in-memory by default, pluggable).
  3. Parsing: the events claim is walked; each CAEP or RISC event URI maps to its short name, and the SSF verification URI to verification.
  4. Subject resolution: sub_id is passed to subject; unknown subjects are logged and acknowledged (an event for a user this app does not know is not an error).
  5. Dispatch: onEvent[type] runs. Typical handlers call updateTag so the next navigation refetches the snapshot, and, when the app has a realtime channel, push an invalidate() to connected clients.
  6. Acknowledgement: push returns 202 Accepted; poll acknowledges processed jti values in the next request. A handler failure is a server error, not a malformed SET: push answers 500 with an empty body so the transmitter retries, and poll retains the event for redelivery. A polled SET that fails verification or lacks jti, iat or events can never succeed, so the acknowledgement request reports it under RFC 8936 setErrs ({ err, description } by jti) and the transmitter stops redelivering it.
  7. on('decision') is not involved; the receiver emits its own on('event') for audit.

What it validates

CheckFailure
JWS signature, alg allow-list400 invalid_key / invalid_request per RFC 8935
iss, aud, iat400 invalid_issuer / invalid_audience
jti replayacknowledged, not dispatched, logged
Event URI knownunknown events acknowledged and ignored unless onEvent['*'] is set
sub_id formatsupported formats mapped; others handed to subject raw
logout_token: typ: logout+jwt, the backchannel-logout events key, sub or sid present, no nonce400 invalid_request (Back-Channel Logout section 2.8)

The receiver never trusts the SET body to identify the transmitter; the signature and issuer do. It does not accept unsigned or none-algorithm tokens, and its algorithm allow-list is the permdock/jwt one (Ed25519, ES256, PS256, RS256; polymorphic EdDSA accepted for a crv: Ed25519 key with a permdock doctor warning).

CAEP Interoperability Profile

SSF 1.0 and CAEP 1.0 leave a transmitter and a receiver several choices: which event types to emit, which delivery method to offer, which subject identifier formats to use. Two conformant implementations can therefore fail to interoperate. The OpenID Foundation's Shared Signals Working Group published the CAEP Interoperability Profile 1.0 as an implementer's draft in July 2026 to close that gap: it fixes the subset of CAEP event types and the delivery behaviour that an interoperable transmitter must produce and an interoperable receiver must accept.

permdock/ssf targets the receiver side of the profile:

  • The event types the profile requires are the ones onEvent is keyed by; the receiver accepts them without configuration and treats profile-required delivery as the baseline rather than an option.
  • Events and subject identifier formats outside the profile still work through onEvent['*'] and the subject mapper, so a transmitter that emits more than the profile is not rejected.
  • packages/permdock/tests/testing/fixtures/ssf/transmitters.json holds SETs in the shapes Entra, Okta and Auth0 transmit, and ssf-transmitters.test.ts replays them against the receiver with no network, so a transmitter or profile change that alters a required shape shows up as a failing fixture rather than a silent drop.

The profile is an implementer's draft; its row on the standards watch list is reviewed each release, and this section is updated when the profile reaches final.

How denials surface

This adapter does not make permission decisions, so there are no denials in the PermDock sense. Its effect is indirect and visible in three places:

  • Server: after updateTag, getPermDock() and getPermission() compute from a fresh snapshot on the next request; a revoked session resolves to an anonymous subject and every check is denied.
  • Client: usePermission reports status: 'stale' until the new snapshot arrives, then allowed flips; Protected shows denied for permissions that were removed.
  • Audit: on('event') records event type, subject id, transmitter and the tags invalidated, so a revocation and the subsequent denials can be correlated.

Delivery failures return the RFC 8935 error object (err, description) so the transmitter retries.

Example app

None. The receiver is exercised against the recorded Entra, Okta and Auth0 SETs in permdock/testing. The Next.js example (apps/examples/next) documents mounting receiver.push as a route handler.

Why

  • Recorded SETs, not live transmitters. The receiver's risk is shape drift: a transmitter that puts the subject somewhere new, or a session in a complex identifier, turns a revocation into a silently acknowledged unknown subject. Replaying each transmitter's shape as a committed fixture catches that on every pnpm test without tenants, secrets or network. The signature is re-created with a test key because the transmitters' keys are private; signature verification is covered by the permdock/jwt suites.
  • One SSF implementation for both roles. The transmitter's SETs are tested against this receiver in tests/ssf/transmitter.test.ts, push and poll, so a SET PermDock Cloud emits is one the receiver is known to accept.
  • Keycloak waits. Its transmitter is experimental and the only self-hostable one, so a container suite would test a moving target. Its SETs join the fixture file once the transmitter is stable.
  • Shared Signals and CAEP: SSF 1.0, CAEP 1.0 event catalogue, RFC 8935 push, RFC 8936 poll, transmitters.
  • OpenID Connect: Back-Channel Logout 1.0 as the second revocation input, sid as subject.session.
  • JOSE: the TokenVerifier both inputs are verified with; secevent+jwt and logout+jwt as registered typ values.
  • Standards watch list: the CAEP Interoperability Profile, OpenID Provider Commands and session_expiry rows that affect snapshot freshness.
  • Snapshots: what gets invalidated.
  • Next.js adapter: tag and updateTag integration.
  • React adapter: client invalidate and stale status.

assurance-level-change is invalidation only. The next subjectFrom* carries the new acr / amr; assurance() on the grant decides. The event never raises a policy.

The receiver does not manage streams: creating the stream at the transmitter and add_subject stay in the IdP console. The transmitter types the stream documents (SsfTransmitterConfiguration, SsfStreamConfiguration, SsfStreamStatus) and leaves the stream-management routes to the service that hosts them. It also pushes nothing to clients; an onEvent handler that has a realtime channel sends invalidate() itself, and server-side streams and sockets end through revocations.

Last updated on

On this page