Building blocks for the OpenID Shared Signals Framework (SSF) in Java, for applications that receive security events: a framework independent receiver library, a Spring Boot starter, and the foundation of the Quarkus extension quarkus-openid-ssf.
Turn a Java application into an SSF receiver without implementing SET validation, metadata discovery, PUSH and POLL delivery, stream management and replay protection yourself. Reacting to a security event is a handler:
SsfEventHandler handler = (event) -> {
if (event.hasEvent("CaepSessionRevoked")) {
SsfSubject subject = event.subjectFor("CaepSessionRevoked");
// end what you hold for subject.sessionId() or subject.subject()
}
};In Spring Boot and Quarkus the handler is a bean and the transmitter one property; in plain Java a few lines wire the parts together, see Getting started. The receiver is tested against the OpenID conformance suite's SSF and CAEP receiver test plans, with PUSH and POLL delivery.
| Artifact | ||
|---|---|---|
| Plain Java | org.easyssf:easyssf-receiver |
The receiver as a library without framework dependencies: verifier, processor, push handler, poller and stream management, wired into your framework by you. See Assembling the receiver in plain Java. |
| Spring Boot | org.easyssf:easyssf-receiver-spring-boot-starter |
Auto-configuration for Spring Boot 4.1: properties, the push endpoint with Spring Security, resource server and OIDC client integration, JDBC stores, metrics and health. See the README of the starter. |
| Quarkus | io.quarkiverse.openid-ssf:quarkus-openid-ssf-receiver |
The Quarkiverse extension, built on easyssf-receiver: configuration, CDI, a Vert.x push route, Dev UI, native images. Maintained in its own repository, documented at docs.quarkiverse.io. |
The library and the starter are released together under the group id org.easyssf and require
Java 21 or later; the extension follows every easyssf release under the same version, 0.7.0 on
easyssf 0.7.0.
| Module | Depends on | |
|---|---|---|
easyssf-core |
The data structures of SSF shared by receivers and (later) transmitters: SETs, subjects, event types, stream configuration, transmitter metadata. | nothing |
easyssf-receiver |
The receiver, independent of any framework: SET verification, de-duplication, event handlers, push handling, polling, stream management, token revocation and session termination logic. | easyssf-core, Nimbus JOSE + JWT, SLF4J |
easyssf-receiver-jdbc |
The database-backed stores of the receiver (processed SETs, revocations), independent of any framework: the SQL, the schema and a small SsfJdbcOperations interface, implemented over a DataSource or by a framework's template. |
easyssf-receiver |
easyssf-receiver-spring-boot-starter |
The receiver for Spring Boot 4.1 (Spring Security 7.1, servlet stack): configuration properties, auto-configuration, push endpoint, resource server and OIDC client integration. | easyssf-receiver, Spring Boot |
easyssf-test |
Test support: a transmitter that signs and delivers SETs, for the tests of your receiver, see Testing your receiver. | easyssf-core, Nimbus JOSE + JWT |
easyssf-receiver-spring-boot-examples |
Example resource server and OIDC client with a Keycloak setup, and a SCIM provisioning example. | |
easyssf-test-conformance |
Runs the OpenID conformance suite's SSF receiver test plans against a receiver under test, in any framework: the suite started with Testcontainers, the scenarios the receiver plays, and the plan tests a framework's test class extends. | easyssf-receiver, Testcontainers, JUnit |
easyssf-receiver-spring-boot-conformance-tests |
The Spring Boot receiver under test and the four plan tests for it. |
- Receives SETs the transmitter pushes (RFC 8935) to an endpoint of the application, or polls the transmitter for them (RFC 8936).
- Verifies every SET (RFC 8417): signature against the transmitter's JWK Set,
typ,iss,aud,jti,iatandevents. The JWK Set location is discovered from the transmitter's.well-known/ssf-configuration. - Skips SETs it has already processed (
jtide-duplication). - Hands verified events to your
SsfEventHandlers. - Manages its stream at the transmitter if you want: looks it up on startup and creates or updates
it, and offers an
SsfStreamClientfor the stream management API. - Ready-made reactions: token revocation for resource servers and session termination for OIDC clients, which the Spring Boot starter wires up without a line of code.
- Records metrics with Micrometer.
- SCIM Events (RFC 9967): the event types and the
scimsubject, and anSsfScimEventHandlerthat hands you typed create, patch, put, delete, activate and deactivate events.
The releases are on Maven Central. Snapshots of main are on the
Central snapshot repository,
which a build has to enable with <snapshots><enabled>true</enabled></snapshots>.
<dependency>
<groupId>org.easyssf</groupId>
<artifactId>easyssf-receiver-spring-boot-starter</artifactId>
<version>0.7.0</version>
</dependency>easyssf:
receiver:
transmitter-issuer: https://idp.example/realms/demo # required
expected-audience: https://my-app.example # or let the application register the stream
push:
expected-auth-header: Bearer ${SSF_PUSH_SECRET} # recommendedEvery SsfEventHandler bean receives the events. Then create a stream with PUSH delivery at the
transmitter that points to https://my-app.example/ssf/push and sends the configured
Authorization header, or let the application create the stream itself. Resource servers and OIDC
clients do not even need a handler: with Spring Security on the classpath, access tokens of a revoked
session are rejected and local sessions are ended as the events arrive. The
README of the starter describes all of it, the
examples run against a Keycloak with a pre-configured realm.
<dependency>
<groupId>io.quarkiverse.openid-ssf</groupId>
<artifactId>quarkus-openid-ssf-receiver</artifactId>
<version>0.7.0</version>
</dependency>quarkus.openid-ssf.receiver.transmitter-issuer=https://idp.example/realms/demoEvery SsfEventHandler CDI bean receives the events. The extension is maintained in the
quarkiverse/quarkus-openid-ssf repository; its
README has a quick start against the public caep.dev transmitter, and
docs.quarkiverse.io the
configuration reference.
<dependency>
<groupId>org.easyssf</groupId>
<artifactId>easyssf-receiver</artifactId>
<version>0.7.0</version>
</dependency>Create the verifier, the processor with your handlers, and the push handler or the poller, see Assembling the receiver in plain Java. Every other framework integrates the library the same way.
Every SsfEventHandler is invoked for every verified SET: in the Spring Boot starter and in the
Quarkus extension every bean of that type, in plain Java the handlers given to the
SsfSetProcessor.
class StepUpHandler implements SsfEventHandler {
@Override
public void handle(SsfEventContext eventContext) {
// aliases and full event type URIs are both accepted, see SsfEventTypes
if (eventContext.hasEvent("CaepAssuranceLevelChange")) {
SsfSubject subject = eventContext.subjectFor("CaepAssuranceLevelChange");
Map<String, Object> event = eventContext.eventFor("CaepAssuranceLevelChange");
// subject.subject(), subject.sessionId(), subject.email(), subject.raw() ...
}
SsfEventToken token = eventContext.eventToken(); // jti, iss, iat, aud, events, subjectId, txn, claims
}
}For CAEP and RISC events there are typed handlers that spare you the claim names: SsfCaepEventHandler
dispatches every CAEP event of a SET to a method per event (onSessionRevoked, onCredentialChange,
onAssuranceLevelChange, onDeviceComplianceChange, onRiskLevelChange, ...) with an SsfCaepEvent
that reads the common claims of CAEP section 2 (eventTimestamp(), initiatingEntity(), reasonAdmin()
and reasonUser() by language tag) and the event-specific ones (credentialType(), changeType(),
currentLevel(), changeDirection(), currentStatus(), amr(), principal(), ...). SsfRiscEventHandler
does the same for RISC (onAccountDisabled, onIdentifierChanged, onCredentialCompromise, ...) with
SsfRiscEvent (reason(), newValue(), credentialType()). An event type in the CAEP or RISC namespace
that this version does not know reaches onOtherCaepEvent or onOtherRiscEvent, with the common claims
typed and the rest in payload(); the raw map stays available through eventContext.eventFor(...).
class StepUpHandler extends SsfCaepEventHandler {
@Override
protected void onAssuranceLevelChange(SsfCaepEvent event, SsfEventContext eventContext) {
if ("decrease".equals(event.changeDirection())) {
stepUp.require(eventContext.subject().subject(), event.currentLevel());
}
}
}A SET is handled at least once, not exactly once, so handlers must be idempotent. If a handler
throws, the SET is not acknowledged and the transmitter is expected to deliver it again, in which case
all handlers run again, possibly on another instance of the application. With a shared dedup store a
SET is claimed while its handlers run and another instance leaves it alone meanwhile, and a redelivery
of a handled SET is skipped; but an instance that dies after a handler's side effects and before the
success is recorded hands the SET to the next instance after a lease, which a running handler renews
meanwhile. For side effects outside the receiver's stores, a database row, a message, an email, key
them by eventContext.idempotencyKey(), the issuer and jti of the SET.
SsfSubject is the sub_id of the SET: a simple subject identifier (RFC 9493: iss_sub, email,
opaque, account, phone_number, did, uri, aliases; RFC 9967: scim) or a complex subject whose members
(user, session, device, tenant, application, org_unit, group) are identifiers.
subject.userIdentifier() and subject.session() are the members the receiver acts on,
subject.member("device") gives the others, and subject.raw() the claim as received. Each
SsfSubjectIdentifier has a format(), a value() (the sub, email, id, ... of its format) and
its claims(). The shortcuts subject(), sessionId(), email() and opaqueId() cover the common
cases.
eventTimestamp() returns the event_timestamp of the event, which CAEP defines in seconds; a value
that is clearly milliseconds is accepted as well, as some transmitters send that.
Event types and aliases. Event types are identified by their URIs; the aliases are a convenience
of easyssf, the family and the event name in PascalCase: CaepSessionRevoked is
https://schemas.openid.net/secevent/caep/event-type/session-revoked, RiscAccountDisabled and
ScimProvCreateFull likewise. Both forms are accepted wherever an event type is named; use the URIs
where you persist event types, and SsfEventTypes to go from one to the other. Vendor specific event
types get aliases of your own, which then work in events-requested, the handler configuration and
your handlers like the built-in ones; an alias cannot redefine a built-in or an earlier one:
SsfEventTypes.registerAlias("AcmeLogin", "https://events.acme.example/login");easyssf.receiver.event-aliases:
AcmeLogin: https://events.acme.example/login # the Spring Boot starter registers them on startupCritical subject members: a transmitter can declare in its metadata (critical_subject_members)
members of a complex subject that a receiver must interpret. A SET whose subject has such a member
is rejected (invalid_request) unless the member is one the application understands. By default
these are the members SsfSubject gives access to: user, session, device, application,
tenant, org_unit and group. If the application interprets others, or fewer, tell the processor
(SsfSetProcessor.setUnderstoodSubjectMembers, in the starter the property
easyssf.receiver.understood-subject-members).
RFC 9967 delivers provisioning changes of a SCIM service
provider as SETs, over the same push and poll delivery. They pass the receiver like any other SET:
RFC 9967 requires the top-level sub_id that the strict SSF 1.0 mode checks for. What is new is the
vocabulary, and easyssf knows it:
- The event types under
urn:ietf:params:scim:event:with the aliasesScimFeedAdd,ScimFeedRemove,ScimProvCreateNotice,ScimProvCreateFull,ScimProvPatchNotice,ScimProvPatchFull,ScimProvPutNotice,ScimProvPutFull,ScimProvDelete,ScimProvActivate,ScimProvDeactivateandScimMiscAsyncResponse, usable inevents-requestedand wherever else an event type is named. - The
scimsubject identifier:SsfScimSubjectgives you the relativeuri()of the resource, itsresourceType()(Users,Groups),id(),externalId()and any furtherattribute("userName"). SsfScimEvent, the typed payload:operation(),isFull()withdata()(the resource, or thePatchOp),isNotice()withattributes()(the paths that changed),version()(the ETag), and for an asynchronous responsemethod(),status()andresponse(). Thetxnthat groups the SETs of a transaction iseventToken().txn().
SsfScimEventHandler dispatches every SCIM Event of a SET to the method of its operation; override the
ones you act on:
@Component
class ProvisioningHandler extends SsfScimEventHandler {
@Override
protected void onCreate(SsfScimEvent event, SsfEventContext eventContext) {
if (event.isFull() && "Users".equals(event.subject().resourceType())) {
users.create(event.subject().id(), event.data());
}
// a notice event only names event.attributes(): fetch the resource with a SCIM GET of
// event.subject().uri() at the service provider if you need its state
}
@Override
protected void onDeactivate(SsfScimEvent event, SsfEventContext eventContext) {
users.disable(event.subject().id());
}
}event.subject() is null if the SET carries a subject that is not a scim identifier, which RFC
9967 does not allow but a handler should expect. The handler methods have to be idempotent like any
SsfEventHandler.
Deactivation and deletion usually mean the user may no longer be logged in. SsfSubjectClaimsMatcher
matches a scim subject with a logged-in user by comparing attributes of the resource with claims,
so SsfSessionTerminationEventHandler can end their sessions: by default externalId and id (the
id attribute, or the last segment of the uri) with sub, and userName with
preferred_username. If your SCIM service provider and your OpenID Provider share other
identifiers, pass other pairs to SsfSubjectClaimsMatcher.matches(subject, claims, name, pairs)
(DEFAULT_SCIM_ATTRIBUTE_CLAIMS is the default); a multi-valued attribute such as emails matches
if any of its values does, and values compared with the email claim match ignoring case. In the
Spring Boot starter this is the property easyssf.receiver.oidc-client.scim-attribute-claims, see
its README.
The receiver does not call the SCIM service provider itself: fetching a resource after a notice event,
the asynchronous SCIM requests of RFC 9967 and the Set-Txn header are the business of a SCIM client.
The SCIM provisioning example mirrors
users into a local directory this way, with a demo transmitter because Keycloak does not emit SCIM
Events.
easyssf-receiver has no framework dependencies. Assemble the parts you need and call them from
your framework. easyssf-core, easyssf-receiver, easyssf-receiver-jdbc and easyssf-test are
Java modules (org.easyssf.core, org.easyssf.receiver, ...) and work on the module path; the
Spring Boot modules are automatic modules.
<dependency>
<groupId>org.easyssf</groupId>
<artifactId>easyssf-receiver</artifactId>
<version>0.7.0</version>
</dependency>SsfHttpClient httpClient = new JdkSsfHttpClient(); // or the HTTP client of your framework
String issuer = "https://idp.example/realms/demo";
SsfTransmitterMetadataResolver metadata = new SsfTransmitterMetadataResolver(issuer, null, httpClient);
NimbusSsfSetVerifier verifier = new NimbusSsfSetVerifier(issuer,
() -> metadata.resolve().jwksUri().toString(), httpClient);
verifier.setExpectedAudience("https://my-app.example");
// metadata.resolve() also tells specVersion(), deliveryMethodsSupported(), criticalSubjectMembers(),
// authorizationSchemes() and defaultSubjects(). The resolver requires https for the issuer and the
// endpoints of the metadata (http on loopback addresses only); pass SsfTransmitterUriPolicy.INSECURE
// as a fourth argument for a development setup. SsfPoller.setUriPolicy holds the poll endpoint to the
// same rule, as the access token goes there. Response bodies are bounded, see
// JdkSsfHttpClient.setMaxResponseSize.
SsfEventHandler handler = (eventContext) -> {
if (eventContext.hasEvent("CaepSessionRevoked")) {
SsfSubject subject = eventContext.subjectFor("CaepSessionRevoked");
// end the session subject.sessionId() of the user subject.subject()
}
};
SsfSetProcessor processor = new SsfSetProcessor(verifier, new InMemorySsfJtiDedupStore(10_000), List.of(handler));
// PUSH: call this from the endpoint the transmitter posts SETs to
SsfPushHandler pushHandler = new SsfPushHandler(processor, "Bearer " + pushSecret);
SsfPushResponse response = pushHandler.handle(authorizationHeader, requestBody);
// send response.status() and, if not null, response.body() as application/json
// POLL: fetch SETs from the transmitter instead
SsfPoller poller = new SsfPoller(httpClient, tokenProvider, () -> pollEndpoint, processor);
poller.setAckStore(ackStore); // optional: JdbcSsfPollAckStore keeps acknowledgements across restarts
poller.setLongPolling(Duration.ofSeconds(30)); // optional: hold requests instead of polling every interval
poller.start();Ready-made pieces for the common reactions:
SsfTokenRevocationEventHandler+SsfTokenRevocationStore.isRevoked(sid, sub, iat)to reject access tokens of revoked sessions and users.SsfSessionTerminationEventHandler+ yourSsfSessionTerminatorto end local sessions;SsfSubjectClaimsMatchertells whether the subject of an event matches the claims of a user.SsfCaepEventHandlerandSsfRiscEventHandlerto react to CAEP and RISC events by kind, withSsfCaepEventandSsfRiscEventfromeasyssf-corefor the typed payload.SsfScimEventHandlerto react to SCIM Events (RFC 9967) by operation, withSsfScimEventandSsfScimSubjectfromeasyssf-corefor the payload and the resource.SsfStreamClientandSsfStreamRegistrarfor stream management,ClientCredentialsSsfTransmitterTokenProviderfor the access token,MicrometerSsfReceiverMetricsfor metrics.
The Spring Boot starter (easyssf-receiver-spring-boot-autoconfigure) and the Quarkus extension are
such integrations; read either to see the parts assembled.
Tested with the SSF transmitter of Keycloak 26.8 (--features=ssf), see the examples.
- Keycloak publishes its metadata at both locations the receiver looks at, no explicit metadata URL is needed.
- A receiver is a client in Keycloak. The audience of its SETs is
<client-id>/<stream-id>unless the stream is given an explicit audience. - Stream management and polling need an access token of the service account of that client with the
scopes
ssf.readandssf.manage(optional client scopes of the client). Keycloak allows one stream per receiver. - The client attribute
ssf.defaultSubjectsdecides which users a stream covers by default:ALLfor every user,NONEfor none. WithNONEthe receiver is still notified about a user once the subject was added to the stream, withSsfStreamClient.addSubject(...)or in Keycloak's administration console. - When an admin signs a user out of all sessions, Keycloak sends a
session-revokedevent whose subject has asessionmember with the identifierALL. The receiver treats this like a subject that names only the user (SsfSubject.sessionId()isnull). - Keycloak delivers events with its outbox drainer, by default every 30 seconds
(
--spi-ssf-transmitter--default--outbox-drainer-interval).
easyssf-test contains TestTransmitter, a transmitter on a loopback port of the JVM that signs SETs,
serves its metadata and JWK Set, hands out access tokens for its client credentials and emulates the
stream management and poll endpoints. The tests of easyssf itself use it.
<dependency>
<groupId>org.easyssf</groupId>
<artifactId>easyssf-test</artifactId>
<version>0.7.0</version>
<scope>test</scope>
</dependency>// a Spring Boot test; in plain Java, pass transmitter.issuer() and transmitter.jwksUri() to the verifier
static final TestTransmitter transmitter = new TestTransmitter();
@DynamicPropertySource
static void transmitter(DynamicPropertyRegistry registry) {
registry.add("easyssf.receiver.transmitter-issuer", transmitter::issuer);
}
@Test
void revokedSessionIsRejected() {
JWTClaimsSet set = transmitter
.setClaims("CaepSessionRevoked", complex(issSub(transmitter.issuer(), "alice"), opaque("session-1")))
.audience("https://my-app.example")
.build();
String encodedSet = transmitter.signSet(set);
// POST encodedSet to /ssf/push, or transmitter.queueSet(encodedSet) for a polling receiver
}transmitter.setAvailable(false) simulates an outage, transmitter.acknowledgedSets() and
transmitter.reportedErrors() show what a polling receiver acknowledged, transmitter.streams() the
streams a receiver registered. The subject helpers are the static methods of SsfSubjectIdentifiers.
- A transmitter.
easyssf-coreis written to be shared with one.
./mvnw installThe build checks the formatting of the Java sources with Spotless
(the Spring Java Format conventions, see
etc/eclipse-formatter.prefs). To format them:
./mvnw spotless:applyThe tests against the OpenID conformance suite are not part of the normal build; they need Docker and take a few minutes per plan:
./mvnw -pl easyssf-receiver-spring-boot-conformance-tests -Pconformance verifySee easyssf-receiver-spring-boot-conformance-tests for the details, the test plans and how to run
them against a newer suite than the released one. The CI builds are described in
CONTRIBUTING.md.