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.
Status: planned Phase: 3
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'
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(`permdock:${subject.id}`),
'credential-change': ({ subject }) => updateTag(`permdock:${subject.id}`),
'assurance-level-change': ({ subject, event }) => updateTag(`permdock:${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 deliveryreceiver.push(request)is a Fetch handler for RFC 8935 push delivery: it acceptsapplication/secevent+jwt, verifies, dispatches and returns202.receiver.poll(options)runs RFC 8936 poll delivery from a long-lived process: it fetches pending SETs, dispatches them and acknowledges byjti.onEventis keyed by CAEP event type. Handlers receive the resolvedsubject, the raweventclaims,event_timestampand thejti.subjectmaps the SETsub_id(formatsiss_sub,email,opaque, and others defined by the Subject Identifiers RFC) to the id PermDock uses intagand snapshots.
Request lifecycle
- Delivery: a SET arrives by push (HTTP POST) or poll (fetched batch).
- Verification: signature against
jwks,issequalsissuer,audcontainsaudience,iatwithin tolerance,jtinot seen before (replay store, in-memory by default, pluggable). - Parsing: the
eventsclaim is walked; each CAEP event URI maps to a short name (session-revoked,credential-change,assurance-level-change,token-claims-change,device-compliance-change). - Subject resolution:
sub_idis passed tosubject; unknown subjects are logged and acknowledged (an event for a user this app does not know is not an error). - Dispatch:
onEvent[type]runs. Typical handlers callupdateTagso the next navigation refetches the snapshot, and, when the app has a realtime channel, push aninvalidate()to connected clients. - Acknowledgement: push returns
202 Accepted; poll acknowledges processedjtivalues in the next request. Handler failures are reported per event (400with anerrbody for push, retained for redelivery for poll). on('decision')is not involved; the receiver emits its ownon('event')for audit.
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 |
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.
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
onEventis 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 thesubjectmapper, so a transmitter that emits more than the profile is not rejected. tests/integrationrecords SETs from transmitters that claim the profile and replays them against the receiver, so a profile revision that changes a required shape shows up as a failing fixture rather than a silent drop.
The profile is an implementer's draft and may change before Phase 3; 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()andgetPermission()compute from a fresh snapshot on the next request; a revoked session resolves to an anonymous subject and every check is denied. - Client:
usePermissionreportsstatus: 'stale'until the new snapshot arrives, thenallowedflips;Protectedshowsfallbackfor 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 in tests/integration against recorded SETs from the transmitters listed above and a Keycloak container. The Next.js example (apps/examples/next) documents mounting receiver.push as a route handler.
Related standards
- Shared Signals and CAEP: SSF 1.0, CAEP 1.0 event catalogue, RFC 8935 push, RFC 8936 poll, transmitters.
- Standards watch list: the CAEP Interoperability Profile, OpenID Provider Commands and
session_expiryrows that affect snapshot freshness. - Snapshots: what gets invalidated.
- Next.js adapter:
tagandupdateTagintegration. - React adapter: client
invalidateandstalestatus.
Open questions
- Replay store: in-memory is fine for a single instance; multi-instance deployments need a shared store, and the interface for it is undecided.
- Whether to ship a stream-management helper (creating the stream at the transmitter,
add_subject) or leave transmitter configuration to the IdP console. - How
assurance-level-changeshould influence decisions beyond invalidation, for example asubject.context.aalvalue that conditions can reference. - Whether client push (WebSocket or SSE
invalidate) belongs in this adapter or in the React adapters withpermdock/ssfonly invalidating server tags. - Keycloak's transmitter is experimental; event shapes may change before Phase 3.
Cloud
permdock/cloud is the thin, optional client for PermDock Cloud; it implements ApprovalStore, DecisionSink and SnapshotSource over a documented HTTP API, never sits in the decision path, and is provisioned from the Vercel Marketplace.
OpenAPI
permdock/openapi emits OpenAPI 3.2 security schemes, per-operation security and x-permdock-permissions from permission references, hooks into hono-openapi, @hono/zod-openapi, oRPC and trpc-to-openapi, hands an Overlay to next-openapi-gen and other producers, and imports OpenAPI documents into a catalog.