# Shared Signals (SSF / CAEP)

Source: https://permdock.com/docs/adapters/ssf

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 [#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](https://openid.net/three-shared-signals-final-specifications-approved/)) define how identity providers transmit such events: Entra, Okta and Auth0 transmit today and Keycloak ships an [experimental transmitter](https://www.keycloak.org/2026/07/experimental-ssf-support). A receiver in PermDock turns "stale for N minutes" into "stale until the IdP says so".

## API [#api]

```ts
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](/docs/concepts/extension-interfaces)) replaces the built-in `joseTokenVerifier` over `jwks`; `discovery: '<issuer>'` may stand in for `issuer` plus `jwks` exactly as in [`permdock/jwt`](/docs/adapters/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 [#shared-replay-store]

```ts
// 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 [#openid-connect-back-channel-logout]

The same receiver accepts an OpenID Connect [Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) `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`:

```ts
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](/docs/standards/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 [#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 [#what-it-validates]

| Check | Failure |
| --- | --- |
| JWS signature, `alg` allow-list | `400 invalid_key` / `invalid_request` per RFC 8935 |
| `iss`, `aud`, `iat` | `400 invalid_issuer` / `invalid_audience` |
| `jti` replay | acknowledged, not dispatched, logged |
| Event URI known | unknown events acknowledged and ignored unless `onEvent['*']` is set |
| `sub_id` format | supported formats mapped; others handed to `subject` raw |
| `logout_token`: `typ: logout+jwt`, the backchannel-logout `events` key, `sub` or `sid` present, no `nonce` | `400 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`](/docs/adapters/jwt) one (`Ed25519`, `ES256`, `PS256`, `RS256`; polymorphic `EdDSA` accepted for a `crv: Ed25519` key with a `permdock doctor` warning).

## CAEP Interoperability Profile [#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](https://openid.net/developers/specs/) 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](/docs/standards/watch-list) is reviewed each release, and this section is updated when the profile reaches final.

## How denials surface [#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 [#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 [#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.

## Related standards [#related-standards]

* [Shared Signals and CAEP](/docs/standards/shared-signals-caep): SSF 1.0, CAEP 1.0 event catalogue, RFC 8935 push, RFC 8936 poll, transmitters.
* [OpenID Connect](/docs/standards/openid-connect): Back-Channel Logout 1.0 as the second revocation input, `sid` as `subject.session`.
* [JOSE](/docs/standards/jose): the `TokenVerifier` both inputs are verified with; `secevent+jwt` and `logout+jwt` as registered `typ` values.
* [Standards watch list](/docs/standards/watch-list): the CAEP Interoperability Profile, OpenID Provider Commands and `session_expiry` rows that affect snapshot freshness.
* [Snapshots](/docs/concepts/snapshots): what gets invalidated.
* [Next.js adapter](/docs/adapters/next): `tag` and `updateTag` integration.
* [React adapter](/docs/adapters/react): 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`.
