Skip to content

Add Shared Signals Framework Transmitter capability#48256

Open
thomasdarimont wants to merge 219 commits intokeycloak:mainfrom
thomasdarimont:issue/gh-48254-ssf-tx-support-v1
Open

Add Shared Signals Framework Transmitter capability#48256
thomasdarimont wants to merge 219 commits intokeycloak:mainfrom
thomasdarimont:issue/gh-48254-ssf-tx-support-v1

Conversation

@thomasdarimont
Copy link
Copy Markdown
Contributor

@thomasdarimont thomasdarimont commented Apr 20, 2026

Adds Shared Signals Framework support to Keycloak in the SSF Transmitter role: Keycloak signs Security Event Tokens (SETs, RFC 8417) describing realm/user/session/credential events and delivers them to OAuth clients
registered as SSF Receivers, either by HTTP PUSH (RFC 8935) or HTTP POLL (RFC 8936).

Targets the OpenID Shared Signals Framework 1.0 (Final) specification plus the CAEP Interoperability Profile 1.0. Ships the legacy SSE CAEP profile alongside for Apple Business Manager / Apple School Manager interop, since Apple device-fleet enrolment is a concrete drive-use case.

Gated behind Profile.Feature.SSF experimental, opt-in.

Background

Issue #43614 originally proposed SSF Receiver support (Keycloak ingesting SETs from upstream IdPs / risk engines). After exploring both sides, we're shipping the Transmitter first (see #48254) because it covers the strongest community asks (federate Keycloak events to downstream SaaS, Apple device fleet revoke flow) and lets us validate the SSF data-plane against real receivers before designing the harder "action mapping" question on the Receiver side. Receiver support remains on the roadmap and is tracked separately via #43614.

Scope (experimental)

In:

  • Compliance with SSF 1.0, CAEP 1.0, RISC 1.0, RFC 8935, RFC 8936, RFC 9493, RFC 8417
  • SSF Transmitter support (Keycloak Realm can act as a SSF Transmitter)
  • SSF Stream management (CRUD, status, verification)
  • SSF Subjects management (subjects)
  • SET delivery via HTTP PUSH (RFC 8935) and HTTP POLL (RFC 8936) with POLL in a return-immediately form
  • SSF events temporarily stored in durable outbox with cluster-aware drainer and exponential backoff
  • SSF Receivers managed as OIDC Clients with client credentials grant or auth code grant (currently only one stream per client)
  • Support for SSF Stream, CAEP 1.0 and RISC 1.0 events (custom events via SPI)
  • CAEP credential-change / session-revoked / (device-compliance-change) event mapping from native Keycloak events
  • Support for RFC 9493 Subject Identifiers for Security Event Tokens
  • Support for SSF Receiver subject event subscription with subject selection (per-user / per-orgssf.notify.attribute, support fordefault_subjectspolicy (ALL, NONE))
  • Support for Synthetic event emittance via REST endpoint for non-Keycloak-native event sources (external IAM solution)
  • Per-receiver "Emit-only events" gate to suppress auto-emit per event type per receiver
  • Support for legacy SSE CAEP profile for Apple Business Manager / Apple School Manager interop (verified)
  • Per-realm SSF admin REST + Admin UI for SSF-enabled clients (Receiver / Stream / Subjects / Events)
  • Prometheus metrics (dispatcher, drainer, poll, verification, outbox depth, delivery metrics)

Out (tracked as separate follow-up issues):

  • SSF Receiver role for Keycloak (ingestion of SETs)
  • POLL long-polling (returnImmediately=false honoured)
  • Dedicated SSF signing key (separate from realm OIDC signing key)
  • Chunked HELD release for very large backlogs
  • Performance characterization + security review
  • Formal interop matrix (caep.dev, ABM)

Tasks

  • All code gated behind Profile.Feature.SSF (experimental, off by default)
  • Per-realm ssf.transmitterEnabled toggle; per-client ssf.enabled toggle
  • SSF event listener registered as global (not user-toggleable per realm)
  • Receiver-facing endpoints conformant with SSF 1.0
  • CAEP credential-change / session-revoked / device-compliance mapping pass interop testing against caep.dev
  • SSE CAEP profile narrowed shape works with Apple Business Manager
  • Integration test coverage for the dispatch / outbox / push / poll pipeline (100+ tests)
  • Prometheus metrics exposed under keycloak_ssf_*
  • Design notes published

Documentation

A more detailed description for the design Design + behaviour can be found here: (Design Document)

Fixes #48254

Signed-off-by: Thomas Darimont thomas.darimont@googlemail.com

This PR was partially co-authored with Claude AI

@thomasdarimont thomasdarimont added team/core-iam kind/feature Categorizes a PR related to a new feature labels Apr 20, 2026
@thomasdarimont thomasdarimont force-pushed the issue/gh-48254-ssf-tx-support-v1 branch from 6b46c3e to 279353a Compare April 20, 2026 07:53
@thomasdarimont
Copy link
Copy Markdown
Contributor Author

thomasdarimont commented Apr 20, 2026

@pedroigor @sguilhen as discussed here is the initial PR with a fully featured SSF Transmitter implementation without the SSF Receiver support.

The SSF feature is now aligned with the structure of the SCIM feature as multiple sub modules:
image

@thomasdarimont
Copy link
Copy Markdown
Contributor Author

thomasdarimont commented Apr 20, 2026

Admin UI Screenshots

If the SSF feature is enabled for the server the SSF Transmitter capability can be enabled for the realm.
If enabled a link to the ssf-configuration metadata is added.
image

If the SSF feature is enabled for the server and for the current realm, the SSF Receiver capability can be enabled on an authenticated client.
image

The SSF tab of a Client shows details about the SSF Receiver configuration.
image

image

The Stream tab shows the current SSF stream configuration:
image

The Subjects tab allows to manage interested subjects for a stream.
image

The Events Search tab allows to lookup the state of the event processing for an event.
image

The Emit Events tab allows to emit supported events for a subject.
image

@keycloak-github-bot
Copy link
Copy Markdown

Unreported flaky test detected

If the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR.

org.keycloak.testsuite.model.singleUseObject.SingleUseObjectModelTest#testCluster

Keycloak CI - Store Model Tests

java.lang.AssertionError: 
threads didn't terminate in time: [main (RUNNABLE):
	at java.management@25.0.2/sun.management.ThreadImpl.dumpThreads0(Native Method)
	at java.management@25.0.2/sun.management.ThreadImpl.dumpAllThreads(ThreadImpl.java:505)
	at java.management@25.0.2/sun.management.ThreadImpl.dumpAllThreads(ThreadImpl.java:493)
...

Report flaky test

Copy link
Copy Markdown

@keycloak-github-bot keycloak-github-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreported flaky test detected, please review

@thomasdarimont thomasdarimont force-pushed the issue/gh-48254-ssf-tx-support-v1 branch 6 times, most recently from 9be57a4 to df16cab Compare April 23, 2026 17:10
Copy link
Copy Markdown

@keycloak-github-bot keycloak-github-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreported flaky test detected, please review

Preparation for the file-by-file SSF runtime cutover. Adds the
generic-outbox plumbing the dispatcher / poll service / stream
service migrations will reach for, in parallel with the existing
SsfEventStore plumbing so call sites can migrate one at a time
without breaking the world. Build stays green.

- OutboxStore: new enqueueHeld(...) mirroring enqueuePending; both
delegate to a shared enqueueInStatus helper. Closes the gap with
SSF's existing enqueueHeldPush / enqueueHeldPoll semantics so the
dispatcher's HELD-on-paused-stream branch has a generic landing
spot.
- SsfTransmitterContext: parallel outboxStoreFactory field plus
outboxStore(session) / outboxStoreFactory() accessors. Legacy
eventStore* accessors stay for the duration of the migration so
existing callers keep compiling.
- DefaultSsfTransmitterProviderFactory: new createOutboxStore(session)
factory method (default new OutboxStore(session)), threaded into
createTransmitterContext alongside the existing
createPendingEventStore.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Redirects the dispatcher's four enqueue sites from SsfEventStore to
OutboxStore. After this commit the SSF dispatcher writes new rows into
OUTBOX_ENTRY (entry_kind = "ssf-push" / "ssf-poll") instead of
SSF_PENDING_EVENT.

- Constructor overloads: Function<KeycloakSession, SsfEventStore> ->
Function<KeycloakSession, OutboxStore>; field renamed
eventStoreFactory -> outboxStoreFactory.
- enqueueForPush -> enqueuePending(SsfOutboxKinds.PUSH, ...) with
streamId mapped onto the new container_id column and the SET's
jti as correlation_id.
- enqueueForPoll -> enqueuePending(SsfOutboxKinds.POLL, ...).
- holdEvent (PUSH/RISC_PUSH) -> enqueueHeld(SsfOutboxKinds.PUSH, ...).
- holdEvent (POLL/RISC_POLL) -> enqueueHeld(SsfOutboxKinds.POLL, ...).
- Javadoc references to SsfEventStore / SsfEventStatus updated to
OutboxStore / OutboxEntryStatus.
- DefaultSsfTransmitterProviderFactory.createDispatcher now passes
ctx.outboxStoreFactory() into the dispatcher.

Intermediate state worth noting: the legacy SsfPushOutboxDrainerTask
is still the only drainer scheduled, and it reads SSF_PENDING_EVENT,
not OUTBOX_ENTRY. Events the dispatcher writes after this commit will
sit in OUTBOX_ENTRY until the drainer cutover (next commit) wires up
OutboxDrainerTask + SsfPushDeliveryHandler. Build remains green; do
not run a server between this commit and the drainer cutover.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Replaces the SSF-specific drainer + cleanup task in the transmitter
factory with the generic OutboxDrainerTask and OutboxCleanupTask. The
SsfPushDeliveryHandler from the earlier prep commit is now the
delivery brain the drainer hands rows to.

- scheduleOutboxDrainer constructs an OutboxDrainerTask with an
OutboxConfig for entryKind="ssf-push" (batchSize, OutboxBackoff
using DEFAULT_HTTP_PUSH_CURVE, deadLetter / delivered retention,
pendingMaxAge), a SsfPushDeliveryHandler wired to createPushDelivery
+ metricsBinder, and createOutboxStore as the per-session DAO
factory. Timer task name stays "SsfPushOutboxDrainerTask" so
existing dashboards / log filters match. Startup log line picks
up the entryKind label.
- outboxDrainerMaxAttempts default switches to
OutboxBackoff.DEFAULT_MAX_ATTEMPTS (same value, generic source).
- Cleanup listener in createProviderEventListener now translates
RealmRemovedEvent / ClientRemovedEvent into OutboxCleanupTask
runs (CLIENT scope renamed to OWNER for the generic version).
submitOutboxCleanup queues two tasks per event, one per SSF
entryKind ("ssf-push" + "ssf-poll"), so realm/client removal
cleans up both PUSH and POLL outbox rows.
- createDrainerTask, createOutboxConfig, createSsfPushDeliveryHandler,
createOutboxCleanUpTask: new generic-outbox-shaped factory methods
replacing createSsfPushOutboxDrainerTaskConfig and the legacy
cleanup-task helper.
- Imports for SsfPushOutboxDrainerTask, SsfPushOutboxDrainerTaskConfig,
SsfPushOutboxBackoff, SsfOutboxCleanupTask removed; OutboxBackoff,
OutboxCleanupTask, OutboxConfig, OutboxDrainerTask, SsfOutboxKinds,
SsfPushDeliveryHandler added. Two javadoc references updated to
point at the generic types.

Runtime state after this commit:
- SSF dispatcher writes -> OUTBOX_ENTRY -> generic drainer for
entryKind=ssf-push -> SsfPushDeliveryHandler -> receiver. PUSH
delivery is now end-to-end on the new infrastructure.
- The legacy SsfPushOutboxDrainerTask is no longer scheduled. Any
rows still in SSF_PENDING_EVENT go un-drained until the table
drops in the dead-code cleanup commit; acceptable for an
experimental feature with no real users.
- POLL endpoint still reads from SsfEventStore (old table) — that
cuts over in the next commit.
- Stream lifecycle and admin REST still use SsfEventStore — pending.

Do not run a server between this commit and the POLL cutover: new
POLL events are written to OUTBOX_ENTRY but PollDeliveryService still
reads SSF_PENDING_EVENT.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Redirects the SSF POLL endpoint's ack / nack / read / count call
sites from SsfEventStore to OutboxStore with entryKind="ssf-poll".
After this commit POLL receivers read from OUTBOX_ENTRY directly,
matching where the dispatcher already writes.

- Field/parameter eventStore: SsfEventStore -> outboxStore: OutboxStore.
- Ack -> outboxStore.ackPendingForOwner(SsfOutboxKinds.POLL, ...).
- Nack -> outboxStore.nackPendingForOwner(SsfOutboxKinds.POLL, ...).
- Read -> outboxStore.lockPendingForOwner(SsfOutboxKinds.POLL, ...).
- moreAvailable count -> outboxStore.countForOwnerByStatus(
SsfOutboxKinds.POLL, ownerId, OutboxEntryStatus.PENDING).
- Row mapping: row.getJti() -> row.getCorrelationId(),
row.getEncodedSet() -> row.getPayload().
- Imports: drop SsfEventEntity / SsfEventStatus / SsfEventStore;
add OutboxEntryEntity / OutboxEntryStatus / OutboxStore /
SsfOutboxKinds.
- DefaultSsfTransmitterProviderFactory.createPollDelivery now
passes provider.context().outboxStore(session) into the service.

Runtime state after this commit:
- PUSH and POLL are both end-to-end on the new infrastructure.
- Stream lifecycle ops (pause/resume/disable, events_requested
narrowing, push <-> poll migration) still reach into
SsfEventStore from StreamService — pending in the next commit.
- Admin REST endpoints (stats, delete, queued) still on
SsfEventStore — pending.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Redirects every SSF stream-lifecycle SsfEventStore touchpoint in
StreamService to the generic OutboxStore. After this commit the SSF
runtime is fully consistent on the new outbox: dispatcher writes,
drainer + handler reads, POLL reads, and stream lifecycle all share
the OUTBOX_ENTRY table.

- Field/constructor: eventStoreFactory: Function<KeycloakSession,
SsfEventStore> -> outboxStoreFactory: Function<KeycloakSession,
OutboxStore>.
- Push <-> poll delivery-method change: rewritten as a kind
migration. The new method maps to SsfOutboxKinds.PUSH or POLL;
the current kind is the opposite; one call to
migrateEntryKindForOwner re-tags the queued backlog. The legacy
SsfEventEntity.DELIVERY_METHOD_* constants are no longer needed.
- events_requested narrow: deadLetterQueuedForOwnerNotMatchingTypes
is called once per kind (PUSH + POLL) and the count is summed.
DEAD_LETTER_REASON_EVENT_TYPE_NO_LONGER_REQUESTED moves from
SsfEventStore onto StreamService as a public static final so the
log/audit string survives the SsfEventStore deletion.
- Stream-delete cascade: deleteByOwner(PUSH, ownerId)
+ deleteByOwner(POLL, ownerId).
- Stream-status transitions in updateStreamStatus:
* disabled  -> deleteQueuedByOwner per kind (SSF spec: a
disabled stream MUST NOT transmit AND will not hold).
* enabled-> releaseHeldForOwner per kind.
* enabled -> paused -> holdPendingForOwner per kind.
- DefaultSsfTransmitterProvider.streamService() passes
context.outboxStoreFactory() into the service.
- Imports: drop SsfEventEntity / SsfEventStore; add OutboxStore /
SsfOutboxKinds.

Runtime state after this commit:
- PUSH, POLL, stream lifecycle, and realm/client cleanup are all
end-to-end on OUTBOX_ENTRY.
- Admin REST endpoints (stats, delete, queued, getPendingEvent)
still use SsfEventStore -- 2c covers those.
- Dead code (SsfEventEntity, SsfEventStore, SsfEventStatus,
SsfPushOutboxDrainerTask, SsfPushOutboxDrainerTaskConfig,
SsfPushOutboxBackoff, SsfOutboxCleanupTask) and the legacy
SSF_PENDING_EVENT table land in 2d.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Redirects every admin REST endpoint that touched SsfEventStore to
OutboxStore. SSF push and poll rows live under separate kinds in the
generic outbox; the admin endpoints aggregate across both so the REST
shape doesn't expose the implementation split.

- Imports: drop SsfEventEntity / SsfEventStatus / SsfEventStore;
add OutboxEntryEntity / OutboxEntryStatus / OutboxStore /
SsfOutboxKinds.
- SSF_OUTBOX_KINDS = List.of(SsfOutboxKinds.PUSH, SsfOutboxKinds.POLL):
the two kinds the admin endpoints loop over.
- getEventStats (realm), getClientEventStats (per-client): loop
both kinds, call countStatusesFor* / oldestCreatedAtPerStatusFor*,
merge into one SsfEventStatsRepresentation. New private helpers
mergeCounts (sum), mergeOldest (min), toEventStatsRepresentation
(rep builder lifted out of the inline duplication).
- deleteEvents / deleteClientEvents (status[+olderThan]): parse
OutboxEntryStatus.valueOf(...), loop kinds, sum the deleted
counts. Per-client now uses deleteByOwnerAndStatus(OlderThan).
- deleteQueuedEvents / deleteClientQueuedEvents: loop both kinds via
deleteQueuedByRealm / deleteQueuedByOwner.
- getPendingEvent: single-row jti lookup tries both kinds via
findByOwnerAndCorrelationId, returns the first non-null match;
404 if neither matches.
- toPendingEventRepresentation: takes OutboxEntryEntity; field
mapping correlationId -> jti, entryType -> eventType, containerId
-> streamId, payload -> encodedSet/decodedSet, entryKind ->
deliveryMethod via new deliveryMethodLabel helper (ssf-push ->
"PUSH", ssf-poll -> "POLL", unknown kinds pass through). Wire
shape of SsfPendingEventRepresentation is unchanged.
- One javadoc reference SsfEventStatus#QUEUED -> OutboxEntryStatus#QUEUED.

Runtime state after this commit:
- The full SSF stack (dispatcher, drainer, POLL service, stream
lifecycle, realm/client cleanup, admin REST) runs against
OUTBOX_ENTRY. SsfEventStore / SsfEventEntity / SsfEventStatus /
SsfPushOutboxDrainerTask / SsfPushOutboxDrainerTaskConfig /
SsfPushOutboxBackoff / SsfOutboxCleanupTask now have zero
callers and are dead code.

The dead-code deletion + SSF_PENDING_EVENT drop + legacy changelog /
JpaEntityProvider removal land in 2d.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
The "Pending" prefix was a holdover from when the rep only surfaced
via the PENDING-only lookup endpoint. The rep already covers any
outbox status (PENDING, HELD, DELIVERED, DEAD_LETTER) — fields like
deliveredAt, attempts, and lastError confirm the broader contract —
so dropping the prefix matches what the class actually returns.

- SsfPendingEventRepresentation.java -> SsfEventRepresentation.java
via git mv so blame/history follows.
- Class declaration renamed; class-level javadoc updated to call
out that the rep covers any status rather than only PENDING.
- SsfAdminResource: import, both @Schema(implementation = ...)
references, return type, and two javadoc @link references
updated. Helper method toPendingEventRepresentation renamed to
toEventRepresentation for consistency.
- Wire shape (field names, JSON keys) is unchanged.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
…ated

Strips out the legacy SSF push outbox now that the dispatcher, drainer,
POLL service, stream lifecycle, realm/client cleanup, and admin REST
all run against the generic OutboxStore from earlier commits. Pure
deletion + a handful of stragglers retyped against the new types; no
behavior change.

Production code retyped:
- SsfMetricsBinder: RealmStatus record's status field flips from
SsfEventStatus to OutboxEntryStatus.
- SsfTransmitterContext: drop eventStoreFactory field, constructor
parameter, eventStore(session), and eventStoreFactory(). Only the
outboxStore / outboxStoreFactory accessors remain.
- DefaultSsfTransmitterProviderFactory: drop createPendingEventStore,
remove the legacy factory parameter from createTransmitterContext,
drop the SsfEventStore import. createOutboxStore is the canonical
extension point now.
- StreamService.DEAD_LETTER_REASON_EVENT_TYPE_NO_LONGER_REQUESTED:
value restored to "event_type_no_longer_requested" to keep the
audit string (and the existing test assertion) stable.
- SsfEventStatsRepresentation, PollRequest, SsfPushDeliveryHandler,
OutboxStore, OutboxEntryEntity: javadoc / comment references to
deleted SSF types replaced with the generic equivalents or removed.
- SsfTransmitterStreamManagementTests: runtime portions migrated to
OutboxStore / OutboxEntryEntity / OutboxEntryStatus /
SsfOutboxKinds.PUSH.

Deleted (15 files):
- 7 SSF-specific outbox classes: SsfEventEntity, SsfEventStatus,
SsfEventStore, SsfPushOutboxBackoff, SsfPushOutboxDrainerTask,
SsfPushOutboxDrainerTaskConfig, SsfOutboxCleanupTask.
- 2 JPA-provider plumbing classes: SsfJpaEntityProvider,
SsfJpaEntityProviderFactory; their store/jpa parent directory
is empty and removed.
- 1 META-INF service file
(org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory)
that pointed at the deleted factory.
- 4 SSF-specific Liquibase changelogs (ssf-changelog-master.xml +
1.0.0 / 1.0.1 / 1.0.2). The SSF_PENDING_EVENT table never reached
a real release, so no <dropTable> migration is needed — the
changelog simply disappears.
- SsfEventStoreTests.java — its referenced types are gone. The
next commit re-creates it as OutboxStoreTests against the
generic store, preserving coverage.

Build state after this commit:
- The full SSF stack and the generic outbox infrastructure both
compile; production code no longer references any deleted symbol.
- The legacy SsfEventStoreTests is gone; the OutboxStoreTests
rewrite (next turn) restores the test coverage.
- The timer task name "SsfPushOutboxDrainerTask" intentionally
survives as a stable string identifier so ops dashboards / log
filters keep matching.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
…the generic store

Restores the test coverage that was deleted in the dead-code cleanup
commit. Tests now exercise the generic OutboxStore against synthetic
entryKind values ("test-kind" + "test-kind-other") rather than SSF
push/poll, so the suite verifies generic store semantics independent
of any specific consumer.

- Enqueue: defaults, dedup on (entryKind, ownerId, correlationId),
no dedup across kinds, enqueueHeld persists with HELD status.
- Drainer reads: lockDueForDrain — next_attempt_at gating, batch
limit, entryKind filtering.
- Row transitions: markDelivered (clears lastError, stamps
deliveredAt), recordFailure (bumps attempts, schedules retry,
truncates last_error to 2048), markDeadLetter (terminal,
bumps attempts), promoteStaleQueuedToDeadLetter (promotes
PENDING + HELD past the cutoff to DEAD_LETTER; terminal rows
preserved; attempts NOT bumped).
- Stats: countStatusesForRealm / countStatusesForOwner /
oldestCreatedAtPerStatusForRealm / oldestCreatedAtPerStatusForOwner
with cross-realm and cross-owner isolation; statuses with zero
rows absent (no synthetic zero-rows).
- POLL reader: lockPendingForOwner (arrival order, owner filter),
countForOwnerByStatus, ackPendingForOwner (silently scoped,
idempotent), nackPendingForOwner (records receiver-supplied
reason in lastError).
- Owner lifecycle: releaseHeldForOwner, holdPendingForOwner,
deadLetterQueuedForOwner, deadLetterQueuedForOwnerNotMatchingTypes
(with empty-allow-list fallback), migrateEntryKindForOwner
(queued migrate, terminal preserved, sibling-owner isolation).
- Admin / cascade deletes: deleteByRealm, deleteByRealmAndStatus[OlderThan],
deleteByOwnerAndStatus, deleteQueuedByRealm, deleteQueuedByOwner.
- Retention purges: purgeDeliveredOlderThan, purgeDeadLetterOlderThan.
- Cleanup task: realm-scope drain, owner-scope drain with
sibling-owner survival.

The file lives at ssf/tests/base/src/test/java/.../outbox/OutboxStoreTests.java
because that's where the runOnServer integration-test scaffolding is
wired. Extracting to a more neutral location can come later if the
outbox grows another consumer that wants its own integration tests.
The test config enables Profile.Feature.SSF since the SSF transmitter
is currently the only registered consumer of the outbox machinery.

Coverage parity with the deleted SsfEventStoreTests plus a few new
generic-only tests: cross-kind dedup isolation, lockDueForDrain
filtering on entryKind, dedicated enqueueHeld test.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Per Keycloak team request: nest the durable event-queue machinery
under org.keycloak.events.* alongside the existing event-listener and
event-storage infrastructure rather than sitting at the top level.
Pure rename, no behavior change.

- 7 source files moved via git mv from
model/jpa/src/main/java/org/keycloak/outbox/ to
model/jpa/src/main/java/org/keycloak/events/outbox/:
OutboxBackoff, OutboxCleanupTask, OutboxConfig,
OutboxDeliveryHandler, OutboxDeliveryOutcome, OutboxDrainerTask,
OutboxStore. Empty old directory removed.
- Package declaration on each moved file updated.
- Imports rewritten across the tree (model/jpa, tests/base,
ssf/transmitter, ssf/services, ssf/tests/base).
- Three javadoc references in jpa-changelog-26.7.0.xml updated to
point at the new package.

The OutboxEntryEntity / OutboxEntryStatus pair stays in
org.keycloak.models.jpa.entities — that's the established convention
for JPA entities and there's no reason to move them.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
…rectly

Replace the context-mediated outboxStoreFactory()/outboxStore() lookups
in DefaultSsfTransmitterProviderFactory with this::createOutboxStore,
matching the pattern already used by createDrainerTask and
createOutboxCleanUpTask. Keeps the factory the single owner of how an
OutboxStore is built so subclasses overriding createOutboxStore see the
override across every wiring site.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Production code lives in org.keycloak.events.outbox; the tests should
mirror that. Now nested under the existing org.keycloak.tests.events
package, so Base2TestSuite picks them up transitively via the existing
@SelectPackages entry — no explicit suite registration needed.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Failed push deliveries previously left "delivery failed" in last_error
regardless of cause, e.g. a DNS lookup failure, receiver replying 503, malformed
stream config all looked the same in the admin view. The rich detail was
logged at WARN/ERROR by PushDeliveryService and immediately discarded.

Two-step propagation:

- PushDeliveryService.deliverEvent now returns a structured
PushDeliveryOutcome (delivered / httpFailure / transportFailure /
invalidConfig) instead of a bare boolean. The HTTP status + response
body or the underlying exception class + message are carried up
intact.

- OutboxDeliveryHandler#deliver returns a new OutboxDeliveryResult that
pairs the outcome with an errorMessage string. The drainer persists
errorMessage into last_error, so the admin grid + log scans see the
receiver's status / the transport exception directly.

SsfPushDeliveryHandler.formatLastError builds the one-line summary:

HTTP 503 https://...: Failed to fetch JWKS from transmitter...
UnknownHostException https://...: ssf.caep.dev: nodename nor servname...
InvalidStreamConfig: missing endpoint URL

Detail fields are capped at 1KB so the column (VARCHAR 2048) absorbs the
prefix + URL + detail comfortably; the store's truncateError caps the
final write as defense in depth.

Deliberately not adding a structured metadata channel: every retry would
rewrite a CLOB JSON document, and the prior failure detail would linger
on DELIVERED rows until the 24h retention purge — bloat without enough
operational upside over a richer last_error line.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
getServiceAccountClientLink() returns the internal client UUID
(set via ClientManager.enableServiceAccount → user.setServiceAccountClientLink(client.getId())),
not the public clientId. Compare against client.getId() so the gate
correctly accepts the receiver's own SA bearer and rejects regular
users (link == null) and other clients' SAs (link == other UUID).

Adds four SA-gate tests to SsfTransmitterStreamManagementTests:
SA bearer accepted, regular-user bearer rejected, opt-out via
ssf.requireServiceAccount=false, and a runOnServer white-box test
for the cross-SA branch that can't be reached via the public token
endpoint. Refactors the password-grant helper onto the test-framework
OAuthClient.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Receiver clients supply the push delivery URL; without a gate the
transmitter would POST signed SETs to anything they declare —
loopback, cloud IMDS, RFC 1918. Adds a per-receiver allow-list
ssf.validPushUrls (## delimited, redirect-URI match semantics with
bare-* rejection) and a per-receiver ssf.allowedDeliveryMethods
family gate. Receiver-supplied URLs must be https and resolve to a
public host unless the allow-insecure-push-targets SPI flag is set
(default false). Validator factory-pluggable via
SsfTransmitterServiceBuilder#createPushUrlValidator and cached on
SsfTransmitterContext. On rejection the server log carries a
suggested allow-list entry derived from the URL so operators can
fix the client attribute in one step.

Updates real-push test fixtures (SPI flag + ssf.validPushUrls on
receiver clients) and adds unit + integration coverage for the
gate's match semantics and rejection cases.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
URL allow-list validation now runs before the duplicate-stream guard
and before applyLegacyFields. Two upsides:

- A receiver with both a stale stream and a misconfigured push URL
gets the actionable URL diagnostic (400) first instead of the
duplicate error (409) that hides it.
- applyLegacyFields writes a client attribute on the SSE_CAEP path;
moving it after URL validation makes "rejection means no client
state mutation" a guarantee rather than a coincidence.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
New "Delivery" section between Authentication and Events surfaces
the two SSRF-gate attributes:

- ssf.allowedDeliveryMethods — pair of Push/Poll checkboxes. Both
rendered as checked when the attribute is unset (matches the
server-side blank-default behaviour). At least one must remain
checked; toggling off the last one silently re-enables it so the
UI never produces an ambiguous blank value the server would read
back as "both allowed".

- ssf.validPushUrls — MultiLineInput list editor (one URL per row,
add/remove buttons), conditionally rendered only when Push is in
the selected methods. Stored as ##-separated via stringify, same
shape as request_uris and post.logout.redirect.uris.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Two concurrent enqueue paths producing the same
(entryKind, ownerId, correlationId) triple, e.g. admin emit retries
across nodes, retried synthetic emits with a stable jti, and
similar, could trip UC_OUTBOX_KIND_OWNER_CORRELATION between the
in-memory dedup find and the persist, surfacing as an unhandled
PersistenceException that obscured whether the event was captured.

Replace the persist+flush+catch path with an HQL INSERT ... VALUES
... ON CONFLICT DO NOTHING named query (Hibernate 6.5+, dialect-
portable). On a race the storage engine no-ops our insert,
executeUpdate returns 0, and we re-fetch the racing row to return
its id. No exception thrown, no JTA rollback-only marker, the
caller's surrounding transaction now survives the race cleanly.
JDBC batching is preserved (no forced flush).

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
ssf.stream.eventsSupported, ssf.stream.eventsRequested, and
ssf.stream.eventsDelivered were persisted as full canonical event-
type URIs (~60 chars each, e.g.
https://schemas.openid.net/secevent/caep/event-type/credential-change),
which add up across the per-stream cap of 32 entries and pressure
the per-client attribute size budget.

Compact at storage time via the existing SsfEventRegistry: each URI
is rewritten to its short alias (CaepCredentialChange etc.) on save
and re-expanded to the canonical URI on load. Custom event types
whose provider factory did not register an alias pass through
unchanged, so the optimisation is opportunistic rather than
mandatory. The wire shape (StreamConfig in memory and the receiver-
facing JSON) stays canonical-URI; only the at-rest representation
changes.

Backwards-compatible: read-side accepts both forms, so receivers
that pre-date this change continue to load correctly, and the next
stream save (any PATCH / PUT / status flip) rewrites them to
aliases automatically. ssf.supportedEvents (per-client allow-list,
authored against the alias vocabulary by the admin UI) was already
in this shape and is unchanged.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Removes 20 unused event classes (6 CAEP, 14 RISC including the
RiscEvent base) plus their STANDARD_EVENT_FACTORIES registrations
in DefaultSsfEventProviderFactory and the matching cases in
SsfEventValidationTest.

Kept: CaepEvent (base), CaepCredentialChange (+ its
CaepCredentialChangeChangeTypeDeserializer), CaepSessionRevoked —
the v1 emittable set, also covers SSE CAEP / ABM interop.
EMITTABLE_EVENT_TYPES drops CaepDeviceComplianceChange so the
admin emit endpoint stops advertising an event the transmitter
can't materialise. NATIVELY_EMITTED_EVENT_TYPES was already
{CaepCredentialChange, CaepSessionRevoked} and stays.

Future event catalogues (full RISC, additional CAEP) plug back in
via SsfEventProviderFactory without re-introducing the deleted
classes. RISC delivery-method URIs in Ssf are kept, as those are
transport identifiers, distinct from RISC event types.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
…ctManagementResource

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
scheduleTransmitterInitiatedAsyncStreamVerification spun up a fresh
single-thread ScheduledExecutorService per stream-create, then shut
it down. Bypasses Keycloak's executor lifecycle and burns an OS
thread for one ~1.5s task.

Hand the dispatch to ExecutorsProvider's named pool
("ssf-stream-verification") instead, with the verification delay
provided by CompletableFuture.delayedExecutor. The JDK's shared
timer waits without holding a worker thread, then forwards to the
managed pool when due. Operators can tune the pool via the
standard executor SPI configuration the same way every other
Keycloak managed pool is tuned.

No change to verification timing or transaction semantics; the
sub-session begin/commit body is preserved verbatim.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Switch OUTBOX_ENTRY.PAYLOAD and METADATA from CLOB/@lob to
NCLOB/@Nationalized so payloads round-trip cleanly under
databases configured for national-character storage (Oracle
NLS, SQL Server NVARCHAR/NTEXT defaults).

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Operators can now externalize an SSF stream's push-delivery
authorization header by setting the client attribute to
${vault.x}; ClientStreamStore resolves it on read so the secret
no longer has to live in the database. A no-op round-trip from
the receiver preserves the placeholder rather than overwriting
it with the resolved plaintext, and StreamService rejects vault
syntax submitted via the receiver-facing API to prevent a rogue
receiver from indirectly reading arbitrary vault entries.

Renames validateFieldLengths to validateFieldConstraints since
it now enforces both length caps and content rules.

Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
@thomasdarimont thomasdarimont force-pushed the issue/gh-48254-ssf-tx-support-v1 branch from 1fc3ec8 to cfa9ae3 Compare May 9, 2026 14:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSF Transmitter Support

5 participants