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 event type. Handlers receive the resolved subject, the raw event claims, event_timestamp and the jti.
  • 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 or iat + clockTolerance. The memory store evicts expired keys on read.
  • A SET carrying several events records each event that succeeded. When a later handler fails, the receiver answers 400, 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.

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 and 400 with error: invalid_request on a token that fails verification, as the specification requires; 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 event URI maps to a short name (session-revoked, credential-change, assurance-level-change, token-claims-change, device-compliance-change).
  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. Handler failures are reported per event (400 with an err body for push, retained for redelivery for poll). 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 fallback 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.
  • 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. 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