diff --git a/AGENTS.md b/AGENTS.md
index 482e062..63b720a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -106,9 +106,10 @@ pins a released version. Consequences for a change here:
## Documentation that changes with the code
-- `README.md` is the single user document. Its configuration table lists every Spring Boot
- property with its default; a new property, alias, subject format or handler is documented there
- in the same change.
+- `README.md` is the user document of the library and of the integrations as a whole; the Spring
+ Boot specifics live in `easyssf-receiver-spring-boot-starter/README.md`, whose configuration table
+ lists every property with its default. A new property, alias, subject format or handler is
+ documented in the right one of the two in the same change.
- `CHANGELOG.md` follows Keep a Changelog. Add to the `Unreleased` section in the voice of the
existing entries; the release workflow publishes that section as the release notes.
- `CONTRIBUTING.md` for build structure, CI and the release procedure. Do not change versions or
diff --git a/README.md b/README.md
index 2c01d3d..e853bfc 100644
--- a/README.md
+++ b/README.md
@@ -2,36 +2,26 @@
Building blocks for the
[OpenID Shared Signals Framework (SSF)](https://openid.net/specs/openid-sharedsignals-framework-1_0.html)
-in Java: a framework independent **receiver** library and a **Spring Boot starter** that turns a
-Spring Boot application into an SSF receiver. Inspired by, and now the foundation of, the Quarkus
-extension [quarkus-openid-ssf](https://github.com/quarkiverse/quarkus-openid-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](https://github.com/quarkiverse/quarkus-openid-ssf).
-**Turn an ordinary Java or Spring application into an SSF receiver without implementing SET
-validation, metadata discovery, PUSH and POLL delivery, stream management and replay protection
-yourself.** With the starter, reacting to a security event is a bean and one property:
+**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:
```java
-@Bean
-SsfEventHandler securityEvents() {
- return (event) -> {
- if (event.hasEvent("CaepSessionRevoked")) {
- SsfSubject subject = event.subjectFor("CaepSessionRevoked");
- // end what you hold for subject.sessionId() or subject.subject()
- }
- };
-}
-```
-
-```yaml
-easyssf:
- receiver:
- transmitter-issuer: https://idp.example/realms/demo
+SsfEventHandler handler = (event) -> {
+ if (event.hasEvent("CaepSessionRevoked")) {
+ SsfSubject subject = event.subjectFor("CaepSessionRevoked");
+ // end what you hold for subject.sessionId() or subject.subject()
+ }
+};
```
-Resource servers and OIDC clients do not even need the bean: with Spring Security on the classpath,
-access tokens of a revoked session are rejected and local sessions are ended as the events arrive.
-The receiver is tested against the OpenID conformance suite's SSF and CAEP receiver test plans, with
-PUSH and POLL delivery.
+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](#getting-started). The receiver is tested
+against the OpenID conformance suite's SSF and CAEP receiver test plans, with PUSH and POLL delivery.
| | |
|---|---|
@@ -39,41 +29,53 @@ PUSH and POLL delivery.
| **Java** | 21+ |
| **Group id** | `org.easyssf` |
+## Integrations
+
+| | 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](#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](easyssf-receiver-spring-boot-starter/README.md). |
+| **Quarkus** | `io.quarkiverse.openid-ssf:quarkus-openid-ssf-receiver` | The Quarkiverse extension, built on `easyssf-receiver` since its 0.2.0: configuration, CDI, a Vert.x push route, Dev UI, native images. Maintained in [its own repository](https://github.com/quarkiverse/quarkus-openid-ssf), documented at [docs.quarkiverse.io](https://docs.quarkiverse.io/quarkus-openid-ssf/dev/index.html). |
+
+The library and the starter are released together under the group id `org.easyssf`; the extension
+follows every easyssf release.
+
| 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-receiver-spring-boot-starter`](easyssf-receiver-spring-boot-starter/README.md) | 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](#testing-your-receiver). | `easyssf-core`, Nimbus JOSE + JWT |
| [`easyssf-receiver-spring-boot-examples`](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`](easyssf-receiver-spring-boot-conformance-tests) | The Spring Boot receiver under test and the four plan tests for it. | |
-Most of this document describes the Spring Boot starter. For other environments see
-[Using the receiver without Spring Boot](#using-the-receiver-without-spring-boot).
-
## What it does
-- Mounts a **push endpoint** (RFC 8935) on a configurable route, `/ssf/push` by default, or
- **polls** the transmitter for events (RFC 8936).
+- 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`, `iat` and `events`. The JWK Set location is discovered from the transmitter's
`.well-known/ssf-configuration`.
- Skips SETs it has already processed (`jti` de-duplication).
-- Hands verified events to your `SsfEventHandler` beans.
+- Hands verified events to your `SsfEventHandler`s.
- **Manages its stream** at the transmitter if you want: looks it up on startup and creates or updates
it, and offers an `SsfStreamClient` for 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.
-- **Resource server**: rejects access tokens once their session (or user) was revoked by a CAEP
- `session-revoked` event.
-- **OIDC client**: terminates local sessions on CAEP `session-revoked` (by `sid` or user) and
- `credential-change` (all sessions of the user).
- **SCIM Events** (RFC 9967): the event types and the `scim` subject, and an `SsfScimEventHandler`
that hands you typed create, patch, put, delete, activate and deactivate events.
## Getting started
+The releases are on Maven Central. Snapshots of `main` are on the
+[Central snapshot repository](https://central.sonatype.com/repository/maven-snapshots/org/easyssf/),
+which a build has to enable with `true`.
+
+### Spring Boot
+
```xml
org.easyssf
@@ -82,10 +84,6 @@ Most of this document describes the Spring Boot starter. For other environments
```
-The releases are on Maven Central. Snapshots of `main` are on the
-[Central snapshot repository](https://central.sonatype.com/repository/maven-snapshots/org/easyssf/),
-which a build has to enable with `true`.
-
```yaml
easyssf:
receiver:
@@ -95,83 +93,54 @@ easyssf:
expected-auth-header: Bearer ${SSF_PUSH_SECRET} # recommended
```
-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](#stream-management).
-
-The application starts even if the transmitter is not reachable: metadata and keys are fetched when
-the first SET arrives.
-
-The [examples](easyssf-receiver-spring-boot-examples) contain a resource server and an OIDC client that run against a Keycloak
-with a pre-configured realm.
-
-## Use case: resource server
+Every `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](easyssf-receiver-spring-boot-starter/README.md) describes all of it, the
+[examples](easyssf-receiver-spring-boot-examples) run against a Keycloak with a pre-configured realm.
-With `spring-boot-starter-security-oauth2-resource-server` on the classpath, nothing else is needed:
+### Quarkus
-| Event | Subject of the event | Effect |
-|---|---|---|
-| `session-revoked` | names a session (`complex` subject with a `session` member) | access tokens with that `sid` claim are rejected |
-| `session-revoked` | names only a user (`iss_sub`) | access tokens with that `sub` claim issued up to the time of the event are rejected |
-
-Rejected tokens get the usual `401` with `WWW-Authenticate: Bearer error="invalid_token"`.
+```xml
+
+ io.quarkiverse.openid-ssf
+ quarkus-openid-ssf-receiver
+ 0.2.0
+
+```
-How it works: `SsfTokenRevocationEventHandler` records revocations in the `SsfTokenRevocationStore`,
-and `SsfRevokedTokenValidator` (an `OAuth2TokenValidator`) consults it for every request. Spring
-Boot adds that validator to the `JwtDecoder` it auto-configures.
+```properties
+quarkus.openid-ssf.receiver.transmitter-issuer=https://idp.example/realms/demo
+```
-Things to know:
+Every `SsfEventHandler` CDI bean receives the events. The extension is maintained in the
+[quarkiverse/quarkus-openid-ssf](https://github.com/quarkiverse/quarkus-openid-ssf) repository; its
+README has a quick start against the public caep.dev transmitter, and
+[docs.quarkiverse.io](https://docs.quarkiverse.io/quarkus-openid-ssf/dev/index.html) the
+configuration reference.
-- **Custom `JwtDecoder`**: if you define your own `JwtDecoder` bean, add the `SsfRevokedTokenValidator`
- bean to its validators yourself.
-- **Multiple instances**: a SET is pushed to one instance only. Without a database the revocations
- are kept in memory of that instance. If the application has a database they are kept there, see
- [Keeping state in the database](#keeping-state-in-the-database). For any other shared store
- (Redis, ...) provide a `SsfTokenRevocationStore` bean.
-- **`easyssf.receiver.resource-server.revocation-ttl`** (default `1h`) must be at least the maximum
- lifetime of your access tokens. Revocations are forgotten after that time.
-- **Issuers**: a revocation applies to the tokens of one issuer: the `iss` of the event's `iss_sub`
- identifier, for a session the issuer of the user it belongs to, else the transmitter. It is matched
- against the `iss` claim of the access token, so the transmitter must be the issuer of the tokens, or
- name it in its events.
-- Opaque tokens are not covered, introspection already asks the authorization server.
+### Plain Java
-## Use case: OIDC client
+```xml
+
+ org.easyssf
+ easyssf-receiver
+ 0.1.0
+
+```
-With `spring-boot-starter-security-oauth2-client` on the classpath and users logging in with
-`oauth2Login()`, nothing else is needed:
-
-| Event | Subject of the event | Effect |
-|---|---|---|
-| `session-revoked` | names a session | the local session whose ID token has that `sid` is invalidated |
-| `session-revoked` | names only a user (`iss_sub` or `email`) | all local sessions of that user are invalidated |
-| `credential-change` | names a user | all local sessions of that user are invalidated |
-
-How it works: `HttpSessionSsfSessionTerminator` tracks the live `HttpSession`s of the servlet container
-and invalidates those whose authenticated `OidcUser` matches the subject of the event (`sid`, `sub` +
-`iss`, or `email`).
-
-Things to know:
-
-- **Spring Session / multiple instances**: the default only sees the container sessions of the
- instance that received the SET. Provide your own `SsfSessionTerminator` bean for an external session
- store.
-- **Matching**: a session by its `sid`, and by the issuer of its user if the event names one; a user by
- `iss` and `sub` of an `iss_sub` identifier (both have to match), by `email` (case-insensitive) or by
- `phone_number`; `aliases` match if any of their identifiers does; a `scim` resource by the attributes
- and claims of `easyssf.receiver.oidc-client.scim-attribute-claims`, by default `externalId` and `id`
- against `sub` and `userName` against `preferred_username`. Matching can be customized with a
- `SsfSessionMatcher` bean.
-- `credential-change` terminates sessions for every kind of change (including a newly added
- credential). To be more selective, set `easyssf.receiver.oidc-client.user-event-types` to an empty list
- and call `SsfSessionTerminator` from your own handler.
+Create the verifier, the processor with your handlers, and the push handler or the poller, see
+[Assembling the receiver in plain Java](#assembling-the-receiver-in-plain-java). Every other framework integrates the library the same way.
## Handling events yourself
-Every `SsfEventHandler` bean is invoked for every verified SET:
+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`.
```java
-@Component
class StepUpHandler implements SsfEventHandler {
@Override
@@ -202,7 +171,8 @@ cases.
Event types are identified by their URIs; the aliases (`CaepSessionRevoked`, ...) are a convenience
of easyssf, use the URIs where you persist event types. Your own aliases, for vendor specific event
-types, go into `easyssf.receiver.event-aliases` (or `SsfEventTypes.registerAlias`) and work wherever
+types, are registered with `SsfEventTypes.registerAlias` (the starter has the property
+`easyssf.receiver.event-aliases` for it) and work wherever
an event type is named; they cannot redefine a built-in alias. `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.
@@ -211,8 +181,9 @@ accepted as well, as some transmitters send that.
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, list them in
-`easyssf.receiver.understood-subject-members` (or `SsfSetProcessor.setUnderstoodSubjectMembers`).
+`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`).
## SCIM Events
@@ -260,26 +231,16 @@ class ProvisioningHandler extends SsfScimEventHandler {
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. To end their local sessions
-with the OIDC client support, add the events to the user events. A `scim` subject is matched with the
-logged-in user by comparing attributes of the resource with claims: 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,
-configure the pairs; a multi-valued attribute such as `emails` matches if any of its values does, and
-values compared with the `email` claim match ignoring case:
-
-```yaml
-easyssf:
- receiver:
- oidc-client:
- user-event-types: CaepCredentialChange, ScimProvDeactivate, ScimProvDelete
- scim-attribute-claims:
- externalId: sub
- emails: email
-```
-
-Without Spring Boot, pass the pairs to `SsfSubjectClaimsMatcher.matches(subject, claims, name, pairs)`;
-`SsfSubjectClaimsMatcher.DEFAULT_SCIM_ATTRIBUTE_CLAIMS` is the default.
+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](easyssf-receiver-spring-boot-starter/README.md#use-case-oidc-client).
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.
@@ -287,345 +248,7 @@ The [SCIM provisioning example](easyssf-receiver-spring-boot-examples/README.md#
users into a local directory this way, with a demo transmitter because Keycloak does not emit SCIM
Events.
-## Push endpoint
-
-```yaml
-easyssf.receiver.push.endpoint-path: /ssf/push # relative to the DispatcherServlet
-```
-
-A SET is verified and handled *before* it is acknowledged:
-
-| Condition | Response |
-|---|---|
-| SET verified and handled, or a duplicate | `202 Accepted` |
-| `expected-auth-header` configured and `Authorization` header does not match | `401` with `{"err":"authentication_failed"}` |
-| SET malformed, badly signed, not typed `secevent+jwt`, missing claims | `400` with `{"err":"invalid_request"}` |
-| wrong `iss` / `aud` | `400` with `{"err":"invalid_issuer"}` / `{"err":"invalid_audience"}` |
-| transmitter metadata or keys cannot be retrieved | `503`, the transmitter should retry |
-| an `SsfEventHandler` failed | `500`, the transmitter should retry |
-| not a `POST` | `405` |
-
-### Spring Security
-
-The transmitter has no session, CSRF token or access token for your application. The starter
-therefore registers a dedicated `SecurityFilterChain` for the push endpoint path only (stateless, no
-CSRF, `permitAll`); it takes precedence over your own filter chains and does not replace Spring Boot's
-default security. The SET itself is authenticated by its signature and, if configured, the
-`Authorization` header.
-
-Set `easyssf.receiver.push.security.enabled=false` to secure the endpoint in your own filter chain
-instead.
-
-## POLL delivery
-
-With `easyssf.receiver.delivery-method=poll` the application has no push endpoint. Instead the `SsfPoller`
-fetches SETs from the poll endpoint of the stream (RFC 8936):
-
-```yaml
-easyssf:
- receiver:
- delivery-method: poll
- stream:
- id: 7d9a4e13-… # a stream created at the transmitter, or management: receiver
- poll:
- interval: 30s
- oauth2: … # see "Calling the transmitter"
-```
-
-- The poll endpoint is taken from the stream (`stream.id` or a stream managed by the receiver), or set
- it with `easyssf.receiver.poll.endpoint-url`.
-- A SET is acknowledged after it was handled. An invalid SET is reported to the transmitter (`setErrs`).
- A SET that could not be handled is neither acknowledged nor reported and is delivered again.
-- If more SETs are available than `poll.max-events`, they are fetched right away.
-- `429` / `503` responses with a `Retry-After` header (in seconds) pause the polling, for at most
- `easyssf.receiver.poll.rate-limit.max-backoff` (5 minutes). A `429` without the header pauses for
- `easyssf.receiver.poll.rate-limit.fallback-backoff`, if set.
-- With `easyssf.receiver.poll.auto-startup=false` nothing is polled until you call `SsfPoller.pollNow()`.
-- Long polling is not used, requests always ask the transmitter to return immediately.
-
-## Stream management
-
-By default the stream is created at the transmitter (`easyssf.receiver.stream.management=transmitter`).
-If you configure its identifier with `easyssf.receiver.stream.id`, the application looks the stream up on
-startup to learn its audience and poll endpoint.
-
-With `easyssf.receiver.stream.management=receiver` the application manages the stream itself:
-
-```yaml
-easyssf:
- receiver:
- stream:
- management: receiver
- events-requested: CaepSessionRevoked, CaepCredentialChange
- push:
- # how the transmitter reaches the push endpoint (not needed for delivery-method: poll)
- delivery-endpoint-url: https://my-app.example/ssf/push
- expected-auth-header: Bearer ${SSF_PUSH_SECRET} # the transmitter is told to send it
- oauth2: … # see "Calling the transmitter"
-```
-
-On startup the `SsfStreamRegistrar`
-
-1. reuses the stream of the receiver that has the desired delivery (same method, for PUSH the same
- endpoint), and updates its requested events if they differ,
-2. otherwise creates the stream,
-3. and if the transmitter refuses a second stream (`409`, Keycloak allows one per receiver), changes
- the existing one to the desired delivery.
-
-This happens in the background and is retried with a growing delay (up to 30s), a transmitter that
-is down does not keep the application from starting. As long as `easyssf.receiver.expected-audience` is
-not set, the audience of the stream is used to validate SETs once the stream is known.
-`easyssf.receiver.stream.delete-on-shutdown=true` deletes the stream when the application stops.
-
-The `SsfStreamClient` bean gives access to the whole stream management API (SSF 1.0, section 8.1):
-
-```java
-streamClient.getStreams();
-streamClient.updateStatus(streamId, SsfStreamStatus.PAUSED, "maintenance");
-streamClient.addSubject(streamId, Map.of("format", "email", "email", "user@example.com"), true);
-streamClient.requestVerification(streamId, "my-state");
-```
-
-The identifier of the current stream is available from the `SsfReceiverStream` bean. To verify the
-stream, request the verification through the `SsfStreamVerification` bean: it generates the `state`
-and rejects a verification event that echoes another state, or names another stream, with
-`invalid_state` / `invalid_request`:
-
-```java
-streamVerification.requestVerification(streamClient, receiverStream.getStreamId());
-```
-
-## Several transmitters
-
-The settings at `easyssf.receiver.*` implicitly configure one transmitter, named `default`. An application that
-receives events from several transmitters, several identity providers, or OAuth authorizationsServers for instance,
-configures the others under `easyssf.receiver.transmitters..*`, each with the same settings a transmitter has
-(issuer, metadata and JWK Set URLs, audience, delivery method, push header, poll, stream, token
-credentials, SET validation) and each complete on its own, nothing is inherited from the default one:
-
-```yaml
-easyssf:
- receiver:
- transmitter-issuer: https://idp.example/realms/employees # the default transmitter, push
- push:
- expected-auth-header: Bearer ${EMPLOYEES_PUSH_SECRET}
- transmitters:
- customers: # a second one, polled
- transmitter-issuer: https://idp.example/realms/customers
- delivery-method: poll
- stream:
- management: receiver
- oauth2:
- token-uri: https://idp.example/realms/customers/protocol/openid-connect/token
- client-id: my-receiver
- client-secret: ${CUSTOMERS_RECEIVER_CLIENT_SECRET}
-```
-
-- The `iss` claim of a SET selects the transmitter that verifies it, so one push endpoint serves all
- of them; each transmitter authenticates with its own `expected-auth-header`. Polled transmitters
- are polled independently.
-- The beans (`SsfStreamClient`, `SsfReceiverStream`, `SsfPoller`, ...) belong to the default
- transmitter, so replacing them works as before. The `SsfTransmitters` bean holds every transmitter
- by name and by issuer, with its stream client, stream, registrar and poller; `SsfTransmitterCustomizer`
- beans customize transmitters before they are built. Only named transmitters, without a default one,
- are fine as well.
-- Handlers see the issuer in `eventContext.eventToken().iss()`. Revocations and sessions are scoped
- to the issuer anyway. The metrics carry a `transmitter` tag with the name of the transmitter, and
- the health indicator lists every transmitter under its name.
-- Dedup, the database, metrics, HTTP timeouts, the push endpoint path and the resource server and
- OIDC client integrations are shared by all transmitters.
-
-## Keeping state in the database
-
-By default the receiver remembers in memory which SETs it processed and which sessions and subjects
-were revoked. That state is lost on restart and not shared between instances.
-
-If the application has a `JdbcTemplate` (with `spring-boot-starter-jdbc`, Spring Data JDBC or JPA),
-the state is kept in its database instead, without further configuration:
-
-| Table | Store | Content |
-|---|---|---|
-| `EASYSSF_PROCESSED_SET` | `JdbcSsfJtiDedupStore` | the SETs that were processed, forgotten after `easyssf.receiver.dedup.retention` (7 days) |
-| `EASYSSF_REVOCATION` | `JdbcSsfTokenRevocationStore` | revoked sessions and subjects, per issuer, removed after `easyssf.receiver.resource-server.revocation-ttl` |
-
-- **Tables**: in an embedded database (H2, HSQLDB, Derby) the tables are created on startup. For any
- other database create them with your migration tool, the statements are in
- [`schema.sql`](easyssf-receiver-jdbc/src/main/resources/org/easyssf/receiver/jdbc/schema.sql)
- (`classpath:org/easyssf/receiver/jdbc/schema.sql`), or set
- `easyssf.receiver.jdbc.initialize-schema=always`. If a table is missing the application fails on
- startup and says so, rather than on the first event. The stores use plain SQL (`VARCHAR`,
- `BIGINT`, no vendor syntax) and are tested on H2 and PostgreSQL.
-- **Opting out**: `easyssf.receiver.jdbc.enabled=false` keeps the state in memory although the
- application has a database. Your own `SsfJtiDedupStore` or `SsfTokenRevocationStore` bean takes
- precedence in any case.
-- **Cleanup**: expired rows are deleted when a store is written to (the SET table at most once a
- minute) and every `easyssf.receiver.jdbc.cleanup-interval` (15 minutes) by `JdbcSsfStoreCleanup`
- on a thread of its own; `easyssf.receiver.jdbc.cleanup-interval=0` turns the periodic cleanup off.
- Both stores offer `purgeExpired()` to do it from your own scheduler.
-- **Cost**: a resource server checks every access token with one query on the primary key of
- `EASYSSF_REVOCATION`.
-- The stores live in `easyssf-receiver-jdbc` and have no Spring dependency: `SsfJdbcOperations` runs
- their SQL, over a `DataSource` (`DataSourceSsfJdbcOperations`) or, in Spring Boot, the `JdbcTemplate`
- of the application, so its transactions and exception translation apply.
-- The stores use plain SQL (timestamps are stored as milliseconds since the epoch) and were tested
- with H2 and PostgreSQL. The revocation table is only used by resource servers. Sessions of an
- OIDC client are not affected, see `SsfSessionTerminator`.
-
-## Calling the transmitter
-
-Stream management and polling call the transmitter and usually need an access token:
-
-```yaml
-easyssf:
- receiver:
- oauth2: # client credentials grant
- token-uri: https://idp.example/realms/demo/protocol/openid-connect/token
- client-id: my-receiver
- client-secret: ${SSF_RECEIVER_CLIENT_SECRET}
- scopes: ssf.read, ssf.manage
- # or, for transmitters that hand out long-lived tokens:
- # transmitter-access-token: ${SSF_TRANSMITTER_TOKEN}
-```
-
-For anything else provide a `SsfTransmitterTokenProvider` bean. Metadata and JWK Set are fetched
-without authentication.
-
-All calls to the transmitter go through the `SsfHttpClient` bean:
-
-- If the application has a `RestClient.Builder` (`spring-boot-starter-restclient`), the calls are
- made with a `RestClient` built from it. They then use the HTTP client library, the
- `spring.http.clients.*` settings (SSL bundle, redirects) and the `RestClientCustomizer`s of the
- application, and are observed like its other HTTP calls (`http.client.requests` metric, tracing).
- `easyssf.receiver.http.use-rest-client=false` switches this off.
-- Otherwise the HTTP client of the JDK is used.
-- Timeouts: `easyssf.receiver.http.connect-timeout` / `read-timeout` if set, else
- `spring.http.clients.connect-timeout` / `read-timeout` (for the `RestClient`), else 5 seconds.
-- Provide your own `SsfHttpClient` bean for anything else.
-
-## Metrics
-
-If the application has a Micrometer `MeterRegistry` (for example with
-`spring-boot-starter-actuator`), the receiver records:
-
-| Meter | Type | Tags |
-|---|---|---|
-| `easyssf.receiver.sets` | counter | `transmitter` (the name of the transmitter, `default` for the one at `easyssf.receiver.*`), `delivery` (`push`, `poll`), `outcome` (`handled`, `duplicate`, `invalid`, `unauthenticated`, `unavailable`, `failed`) |
-| `easyssf.receiver.events` | counter | `transmitter`, `delivery`, `event` (for example `CaepSessionRevoked`) |
-| `easyssf.receiver.poll` | timer | `transmitter`, `outcome` (`success`, `failure`) |
-
-Switch it off with `easyssf.receiver.metrics.enabled=false`, or provide your own `SsfReceiverMetrics` bean.
-
-## Health
-
-With Spring Boot Actuator the receiver contributes the health indicator `easyssf`
-(`/actuator/health/easyssf`), from what it already knows; it never calls the transmitter for it:
-
-| Status | When |
-|---|---|
-| `UP` | The transmitter was reached: its metadata was retrieved (PUSH) or the last poll succeeded (POLL), and the stream, if it is looked up or managed, is registered. |
-| `DOWN` | The last poll failed, or the stream cannot be used (it was created with another issuer). |
-| `UNKNOWN` | No contact with the transmitter yet. The application starts without it. |
-
-The details name the transmitter, the delivery method, whether the metadata was retrieved, the stream and
-its registration state, and for POLL the last poll, the last successful poll and the error of the last
-poll. With [several transmitters](#several-transmitters) the details of each are listed under its
-name, with its own status. Switch it off with `management.health.easyssf.enabled=false`.
-
-## Configuration
-
-| Property | Default | |
-|---|---|---|
-| `easyssf.receiver.enabled` | `true` | Switches the receiver off entirely when `false`. |
-| `easyssf.receiver.transmitter-issuer` | | Issuer of the transmitter, must match `iss` of every SET. An `https` URL without query or fragment; `http` only on loopback addresses or with `allow-insecure-http`. Required unless transmitters are configured by name. |
-| `easyssf.receiver.transmitters..*` | | Further transmitters, see [Several transmitters](#several-transmitters): every property of a transmitter, from `transmitter-issuer` to `set-validation.*`, under its name. |
-| `easyssf.receiver.transmitter-metadata-url` | derived | By default `/.well-known/ssf-configuration` (SSF 1.0, section 7.2), falling back to `/.well-known/ssf-configuration`. |
-| `easyssf.receiver.transmitter-jwks-url` | from metadata | Skips metadata discovery when set. |
-| `easyssf.receiver.allow-insecure-http` | `false` | Accepts `http` for the transmitter issuer and the endpoints it publishes on any host, with a warning on startup. For development only. |
-| `easyssf.receiver.expected-audience` | | When set, every SET must contain it in `aud`. |
-| `easyssf.receiver.delivery-method` | `push` | `push` or `poll`. |
-| `easyssf.receiver.understood-subject-members` | `user, session, device, application, tenant, org_unit, group` | The members of a complex subject the application interprets; a SET with a member the transmitter declared critical that is not listed is rejected. |
-| `easyssf.receiver.event-aliases.*` | | Aliases for event type URIs, e.g. `AcmeLogin: https://events.acme.example/login`, usable wherever an event type is named. |
-| `easyssf.receiver.http.use-rest-client` | `true` | Call the transmitter with the `RestClient` of the application if it has a `RestClient.Builder`. |
-| `easyssf.receiver.http.connect-timeout` / `read-timeout` | `5s` | Calls to the transmitter. Unset, `spring.http.clients.*` applies to the `RestClient` before the 5 seconds do. |
-| `easyssf.receiver.http.user-agent` | default of the HTTP client | `User-Agent` header for calls to the transmitter. |
-| `easyssf.receiver.transmitter-access-token` | | Static access token for stream management and polling. |
-| `easyssf.receiver.oauth2.token-uri` | | Token endpoint for the client credentials grant. |
-| `easyssf.receiver.oauth2.client-id` / `client-secret` | | |
-| `easyssf.receiver.oauth2.scopes` | | |
-| `easyssf.receiver.oauth2.client-authentication-method` | `basic` | `basic` or `post`. |
-| `easyssf.receiver.oauth2.expiry-safety-window` | `30s` | How long before its expiry an access token is renewed, at most a quarter of its lifetime. |
-| `easyssf.receiver.oauth2.additional-parameters.*` | | Form parameters sent with the token request in addition to the grant. |
-| `easyssf.receiver.stream.management` | `transmitter` | `receiver` makes the application create or update its stream on startup. |
-| `easyssf.receiver.stream.id` | | Stream created at the transmitter, looked up on startup. |
-| `easyssf.receiver.stream.events-requested` | `CaepSessionRevoked`, `CaepCredentialChange` | For a stream managed by the receiver. |
-| `easyssf.receiver.stream.description` | | For a stream managed by the receiver. |
-| `easyssf.receiver.stream.delete-on-shutdown` | `false` | For a stream managed by the receiver. |
-| `easyssf.receiver.poll.endpoint-url` | from the stream | |
-| `easyssf.receiver.poll.auto-startup` | `true` | |
-| `easyssf.receiver.poll.interval` | `30s` | |
-| `easyssf.receiver.poll.initial-delay` | `1s` | |
-| `easyssf.receiver.poll.max-events` | `100` | SETs per request. |
-| `easyssf.receiver.poll.rate-limit.fallback-backoff` | | Pause after a `429` without `Retry-After`; unset, the next poll comes at the regular interval. |
-| `easyssf.receiver.poll.rate-limit.max-backoff` | `5m` | Longest pause a `Retry-After` header or the fallback can cause. |
-| `easyssf.receiver.metrics.enabled` | `true` | |
-| `easyssf.receiver.set-validation.accepted-algorithms` | `RS256` | JWS algorithms accepted for SETs. |
-| `easyssf.receiver.set-validation.min-rsa-key-size` | `2048` | `0` disables the check. |
-| `easyssf.receiver.set-validation.require-type-header` | `true` | Requires `typ: secevent+jwt`. |
-| `easyssf.receiver.set-validation.subject-compatibility` | `strict-ssf-1-0` | Requires the top-level `sub_id` claim of SSF 1.0. `legacy` accepts SETs of transmitters following earlier drafts, which put the subject into the event. |
-| `easyssf.receiver.set-validation.clock-skew` | `60s` | Tolerance for `iat` in the future. |
-| `easyssf.receiver.push.enabled` | `true` | |
-| `easyssf.receiver.push.endpoint-path` | `/ssf/push` | |
-| `easyssf.receiver.push.expected-auth-header` | | Exact `Authorization` header value the transmitter must send. |
-| `easyssf.receiver.push.delivery-endpoint-url` | | How the transmitter reaches the push endpoint, for a stream managed by the receiver. |
-| `easyssf.receiver.push.security.enabled` | `true` | Dedicated `SecurityFilterChain` for the push endpoint. |
-| `easyssf.receiver.dedup.enabled` | `true` | |
-| `easyssf.receiver.dedup.capacity` | `10000` | Size of the in-memory `jti` store. |
-| `easyssf.receiver.dedup.retention` | `7d` | How long the JDBC store remembers a processed SET. |
-| `easyssf.receiver.jdbc.enabled` | `true` | Keep state in the database if the application has a `JdbcTemplate`. |
-| `easyssf.receiver.jdbc.initialize-schema` | `embedded` | When to create missing tables: `embedded`, `always` or `never`. |
-| `easyssf.receiver.jdbc.table-prefix` | `EASYSSF_` | Prefix of the table names. |
-| `easyssf.receiver.jdbc.cleanup-interval` | `15m` | How often expired rows are purged; `0` turns it off. |
-| `easyssf.receiver.resource-server.enabled` | `true` | |
-| `easyssf.receiver.resource-server.event-types` | `CaepSessionRevoked` | Events that revoke access tokens. |
-| `easyssf.receiver.resource-server.revocation-ttl` | `1h` | |
-| `easyssf.receiver.oidc-client.enabled` | `true` | |
-| `easyssf.receiver.oidc-client.session-event-types` | `CaepSessionRevoked` | Events that terminate the session (or user sessions) of their subject. |
-| `easyssf.receiver.oidc-client.user-event-types` | `CaepCredentialChange` | Events that terminate all sessions of their subject's user. |
-| `easyssf.receiver.oidc-client.scim-attribute-claims.*` | `externalId: sub`, `id: sub`, `userName: preferred_username` | Attributes of the `scim` subject of a SCIM Event and the claim of the logged-in user each is compared with. |
-
-Beans you can replace: `SsfSetVerifier`, `SsfTransmitterMetadataResolver`, `SsfJtiDedupStore`,
-`SsfTokenRevocationStore`, `SsfRevokedTokenValidator`, `SsfSessionTerminator`, `SsfSessionMatcher`,
-`SsfTransmitterTokenProvider`, `SsfStreamClient`, `SsfStreamRegistrar`, `SsfPoller`, `SsfReceiverMetrics`,
-`SsfHttpClient`, `SsfPushHandler`.
-
-## Keycloak
-
-Tested with the SSF transmitter of Keycloak 26.8 (`--features=ssf`), see the [examples](easyssf-receiver-spring-boot-examples).
-
-- Keycloak publishes its metadata at both locations the starter looks at, no
- `transmitter-metadata-url` is needed.
-- A receiver is a client in Keycloak. The audience of its SETs is `/` 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.read` and `ssf.manage` (optional client scopes of the client). Keycloak allows one
- stream per receiver.
-- The client attribute `ssf.defaultSubjects` decides which users a stream covers by default: `ALL` for
- every user, `NONE` for none. With `NONE` the receiver is still notified about a user once the
- subject was added to the stream, with `SsfStreamClient.addSubject(...)` or in Keycloak's
- administration console.
-- When an admin signs a user out of all sessions, Keycloak sends a `session-revoked` event whose
- subject has a `session` member with the identifier `ALL`. The starter treats this like a subject
- that names only the user (`SsfSubject.sessionId()` is `null`).
-- Keycloak delivers events with its outbox drainer, by default every 30 seconds
- (`--spi-ssf-transmitter--default--outbox-drainer-interval`).
-
-## Not included (yet)
-
-- WebFlux applications
-- Long polling, and a durable store for acknowledgements that were not sent yet (a SET whose
- acknowledgement is lost is delivered again and skipped as a duplicate)
-
-## Using the receiver without Spring Boot
+## Assembling the receiver in plain Java
`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
@@ -683,7 +306,29 @@ Ready-made pieces for the common reactions:
`ClientCredentialsSsfTransmitterTokenProvider` for the access token, `MicrometerSsfReceiverMetrics`
for metrics.
-The Spring Boot starter is such an integration, see `easyssf-receiver-spring-boot-autoconfigure`.
+The Spring Boot starter (`easyssf-receiver-spring-boot-autoconfigure`) and the Quarkus extension are
+such integrations; read either to see the parts assembled.
+
+## Keycloak
+
+Tested with the SSF transmitter of Keycloak 26.8 (`--features=ssf`), see the [examples](easyssf-receiver-spring-boot-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 `/` 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.read` and `ssf.manage` (optional client scopes of the client). Keycloak allows one
+ stream per receiver.
+- The client attribute `ssf.defaultSubjects` decides which users a stream covers by default: `ALL` for
+ every user, `NONE` for none. With `NONE` the receiver is still notified about a user once the
+ subject was added to the stream, with `SsfStreamClient.addSubject(...)` or in Keycloak's
+ administration console.
+- When an admin signs a user out of all sessions, Keycloak sends a `session-revoked` event whose
+ subject has a `session` member with the identifier `ALL`. The receiver treats this like a subject
+ that names only the user (`SsfSubject.sessionId()` is `null`).
+- Keycloak delivers events with its outbox drainer, by default every 30 seconds
+ (`--spi-ssf-transmitter--default--outbox-drainer-interval`).
## Testing your receiver
@@ -701,6 +346,7 @@ stream management and poll endpoints. The tests of easyssf itself use it.
```
```java
+// a Spring Boot test; in plain Java, pass transmitter.issuer() and transmitter.jwksUri() to the verifier
static final TestTransmitter transmitter = new TestTransmitter();
@DynamicPropertySource
@@ -723,6 +369,12 @@ void revokedSessionIsRejected() {
`transmitter.reportedErrors()` show what a polling receiver acknowledged, `transmitter.streams()` the
streams a receiver registered. The subject helpers are the static methods of `SsfSubjectIdentifiers`.
+## Not included (yet)
+
+- A transmitter. `easyssf-core` is written to be shared with one.
+- Long polling, and a durable store for acknowledgements that were not sent yet (a SET whose
+ acknowledgement is lost is delivered again and skipped as a duplicate).
+
## Build
```sh
diff --git a/easyssf-receiver-spring-boot-autoconfigure/AGENTS.md b/easyssf-receiver-spring-boot-autoconfigure/AGENTS.md
index e6d37d1..235c855 100644
--- a/easyssf-receiver-spring-boot-autoconfigure/AGENTS.md
+++ b/easyssf-receiver-spring-boot-autoconfigure/AGENTS.md
@@ -7,7 +7,7 @@ OIDC client support. The starter module only depends on this one.
`SsfTransmitterProperties` (per transmitter, repeated under
`easyssf.receiver.transmitters..*`). A new property gets a javadoc sentence (it becomes
the configuration metadata description), a default that matches the receiver's, and a row in
- the README configuration table.
+ the configuration table of the starter's README (`../easyssf-receiver-spring-boot-starter/README.md`).
- One auto-configuration class per concern in `autoconfigure`, registered in
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. Every bean
is `@ConditionalOnMissingBean` so applications can replace it; optional integrations are
diff --git a/easyssf-receiver-spring-boot-starter/README.md b/easyssf-receiver-spring-boot-starter/README.md
new file mode 100644
index 0000000..85a8f5a
--- /dev/null
+++ b/easyssf-receiver-spring-boot-starter/README.md
@@ -0,0 +1,452 @@
+# easyssf Spring Boot starter
+
+The receiver of [easyssf](../README.md) for Spring Boot 4.1 (Spring Security 7.1, servlet stack):
+configuration properties, auto-configuration, the push endpoint, and resource server and OIDC client
+integrations that need no code of yours. This README is the reference of the starter; the
+[root README](../README.md) describes what every integration shares: the events, the subjects, SCIM
+Events, the test support.
+
+```xml
+
+ org.easyssf
+ easyssf-receiver-spring-boot-starter
+ 0.1.0
+
+```
+
+Reacting to a security event is a bean and one property:
+
+```java
+@Bean
+SsfEventHandler securityEvents() {
+ return (event) -> {
+ if (event.hasEvent("CaepSessionRevoked")) {
+ SsfSubject subject = event.subjectFor("CaepSessionRevoked");
+ // end what you hold for subject.sessionId() or subject.subject()
+ }
+ };
+}
+```
+
+```yaml
+easyssf:
+ receiver:
+ transmitter-issuer: https://idp.example/realms/demo # required
+ expected-audience: https://my-app.example # recommended
+ push:
+ expected-auth-header: Bearer ${SSF_PUSH_SECRET} # recommended
+```
+
+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](#stream-management).
+
+The application starts even if the transmitter is not reachable: metadata and keys are fetched when
+the first SET arrives.
+
+Resource servers and OIDC clients do not even need the bean: with Spring Security on the classpath,
+access tokens of a revoked session are rejected and local sessions are ended as the events arrive.
+
+- **Resource server**: rejects access tokens once their session (or user) was revoked by a CAEP
+ `session-revoked` event.
+- **OIDC client**: terminates local sessions on CAEP `session-revoked` (by `sid` or user) and
+ `credential-change` (all sessions of the user).
+
+The [examples](../easyssf-receiver-spring-boot-examples) contain a resource server, an OIDC client
+and a SCIM provisioning application; the first two run against a Keycloak with a pre-configured
+realm.
+
+## Use case: resource server
+
+With `spring-boot-starter-security-oauth2-resource-server` on the classpath, nothing else is needed:
+
+| Event | Subject of the event | Effect |
+|---|---|---|
+| `session-revoked` | names a session (`complex` subject with a `session` member) | access tokens with that `sid` claim are rejected |
+| `session-revoked` | names only a user (`iss_sub`) | access tokens with that `sub` claim issued up to the time of the event are rejected |
+
+Rejected tokens get the usual `401` with `WWW-Authenticate: Bearer error="invalid_token"`.
+
+How it works: `SsfTokenRevocationEventHandler` records revocations in the `SsfTokenRevocationStore`,
+and `SsfRevokedTokenValidator` (an `OAuth2TokenValidator`) consults it for every request. Spring
+Boot adds that validator to the `JwtDecoder` it auto-configures.
+
+Things to know:
+
+- **Custom `JwtDecoder`**: if you define your own `JwtDecoder` bean, add the `SsfRevokedTokenValidator`
+ bean to its validators yourself.
+- **Multiple instances**: a SET is pushed to one instance only. Without a database the revocations
+ are kept in memory of that instance. If the application has a database they are kept there, see
+ [Keeping state in the database](#keeping-state-in-the-database). For any other shared store
+ (Redis, ...) provide a `SsfTokenRevocationStore` bean.
+- **`easyssf.receiver.resource-server.revocation-ttl`** (default `1h`) must be at least the maximum
+ lifetime of your access tokens. Revocations are forgotten after that time.
+- **Issuers**: a revocation applies to the tokens of one issuer: the `iss` of the event's `iss_sub`
+ identifier, for a session the issuer of the user it belongs to, else the transmitter. It is matched
+ against the `iss` claim of the access token, so the transmitter must be the issuer of the tokens, or
+ name it in its events.
+- Opaque tokens are not covered, introspection already asks the authorization server.
+
+## Use case: OIDC client
+
+With `spring-boot-starter-security-oauth2-client` on the classpath and users logging in with
+`oauth2Login()`, nothing else is needed:
+
+| Event | Subject of the event | Effect |
+|---|---|---|
+| `session-revoked` | names a session | the local session whose ID token has that `sid` is invalidated |
+| `session-revoked` | names only a user (`iss_sub` or `email`) | all local sessions of that user are invalidated |
+| `credential-change` | names a user | all local sessions of that user are invalidated |
+
+How it works: `HttpSessionSsfSessionTerminator` tracks the live `HttpSession`s of the servlet container
+and invalidates those whose authenticated `OidcUser` matches the subject of the event (`sid`, `sub` +
+`iss`, or `email`).
+
+Things to know:
+
+- **Spring Session / multiple instances**: the default only sees the container sessions of the
+ instance that received the SET. Provide your own `SsfSessionTerminator` bean for an external session
+ store.
+- **Matching**: a session by its `sid`, and by the issuer of its user if the event names one; a user by
+ `iss` and `sub` of an `iss_sub` identifier (both have to match), by `email` (case-insensitive) or by
+ `phone_number`; `aliases` match if any of their identifiers does; a `scim` resource by the attributes
+ and claims of `easyssf.receiver.oidc-client.scim-attribute-claims`, by default `externalId` and `id`
+ against `sub` and `userName` against `preferred_username`. Matching can be customized with a
+ `SsfSessionMatcher` bean.
+- `credential-change` terminates sessions for every kind of change (including a newly added
+ credential). To be more selective, set `easyssf.receiver.oidc-client.user-event-types` to an empty list
+ and call `SsfSessionTerminator` from your own handler.
+
+**SCIM Events** (RFC 9967, see the [root README](../README.md#scim-events)): the deactivation and
+the deletion of a SCIM user usually mean the user may no longer be logged in. To end their local
+sessions, add the events to the user events. A `scim` subject is matched with the logged-in user by
+comparing attributes of the resource with claims: 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, configure the pairs; a
+multi-valued attribute such as `emails` matches if any of its values does, and values compared with
+the `email` claim match ignoring case:
+
+```yaml
+easyssf:
+ receiver:
+ oidc-client:
+ user-event-types: CaepCredentialChange, ScimProvDeactivate, ScimProvDelete
+ scim-attribute-claims:
+ externalId: sub
+ emails: email
+```
+
+## Push endpoint
+
+```yaml
+easyssf.receiver.push.endpoint-path: /ssf/push # relative to the DispatcherServlet
+```
+
+A SET is verified and handled *before* it is acknowledged:
+
+| Condition | Response |
+|---|---|
+| SET verified and handled, or a duplicate | `202 Accepted` |
+| `expected-auth-header` configured and `Authorization` header does not match | `401` with `{"err":"authentication_failed"}` |
+| SET malformed, badly signed, not typed `secevent+jwt`, missing claims | `400` with `{"err":"invalid_request"}` |
+| wrong `iss` / `aud` | `400` with `{"err":"invalid_issuer"}` / `{"err":"invalid_audience"}` |
+| transmitter metadata or keys cannot be retrieved | `503`, the transmitter should retry |
+| an `SsfEventHandler` failed | `500`, the transmitter should retry |
+| not a `POST` | `405` |
+
+### Spring Security
+
+The transmitter has no session, CSRF token or access token for your application. The starter
+therefore registers a dedicated `SecurityFilterChain` for the push endpoint path only (stateless, no
+CSRF, `permitAll`); it takes precedence over your own filter chains and does not replace Spring Boot's
+default security. The SET itself is authenticated by its signature and, if configured, the
+`Authorization` header.
+
+Set `easyssf.receiver.push.security.enabled=false` to secure the endpoint in your own filter chain
+instead.
+
+## POLL delivery
+
+With `easyssf.receiver.delivery-method=poll` the application has no push endpoint. Instead the `SsfPoller`
+fetches SETs from the poll endpoint of the stream (RFC 8936):
+
+```yaml
+easyssf:
+ receiver:
+ delivery-method: poll
+ stream:
+ id: 7d9a4e13-… # a stream created at the transmitter, or management: receiver
+ poll:
+ interval: 30s
+ oauth2: … # see "Calling the transmitter"
+```
+
+- The poll endpoint is taken from the stream (`stream.id` or a stream managed by the receiver), or set
+ it with `easyssf.receiver.poll.endpoint-url`.
+- A SET is acknowledged after it was handled. An invalid SET is reported to the transmitter (`setErrs`).
+ A SET that could not be handled is neither acknowledged nor reported and is delivered again.
+- If more SETs are available than `poll.max-events`, they are fetched right away.
+- `429` / `503` responses with a `Retry-After` header (in seconds) pause the polling, for at most
+ `easyssf.receiver.poll.rate-limit.max-backoff` (5 minutes). A `429` without the header pauses for
+ `easyssf.receiver.poll.rate-limit.fallback-backoff`, if set.
+- With `easyssf.receiver.poll.auto-startup=false` nothing is polled until you call `SsfPoller.pollNow()`.
+- Long polling is not used, requests always ask the transmitter to return immediately.
+
+## Stream management
+
+By default the stream is created at the transmitter (`easyssf.receiver.stream.management=transmitter`).
+If you configure its identifier with `easyssf.receiver.stream.id`, the application looks the stream up on
+startup to learn its audience and poll endpoint.
+
+With `easyssf.receiver.stream.management=receiver` the application manages the stream itself:
+
+```yaml
+easyssf:
+ receiver:
+ stream:
+ management: receiver
+ events-requested: CaepSessionRevoked, CaepCredentialChange
+ push:
+ # how the transmitter reaches the push endpoint (not needed for delivery-method: poll)
+ delivery-endpoint-url: https://my-app.example/ssf/push
+ expected-auth-header: Bearer ${SSF_PUSH_SECRET} # the transmitter is told to send it
+ oauth2: … # see "Calling the transmitter"
+```
+
+On startup the `SsfStreamRegistrar`
+
+1. reuses the stream of the receiver that has the desired delivery (same method, for PUSH the same
+ endpoint), and updates its requested events if they differ,
+2. otherwise creates the stream,
+3. and if the transmitter refuses a second stream (`409`, Keycloak allows one per receiver), changes
+ the existing one to the desired delivery.
+
+This happens in the background and is retried with a growing delay (up to 30s), a transmitter that
+is down does not keep the application from starting. As long as `easyssf.receiver.expected-audience` is
+not set, the audience of the stream is used to validate SETs once the stream is known.
+`easyssf.receiver.stream.delete-on-shutdown=true` deletes the stream when the application stops.
+
+The `SsfStreamClient` bean gives access to the whole stream management API (SSF 1.0, section 8.1):
+
+```java
+streamClient.getStreams();
+streamClient.updateStatus(streamId, SsfStreamStatus.PAUSED, "maintenance");
+streamClient.addSubject(streamId, Map.of("format", "email", "email", "user@example.com"), true);
+streamClient.requestVerification(streamId, "my-state");
+```
+
+The identifier of the current stream is available from the `SsfReceiverStream` bean. To verify the
+stream, request the verification through the `SsfStreamVerification` bean: it generates the `state`
+and rejects a verification event that echoes another state, or names another stream, with
+`invalid_state` / `invalid_request`:
+
+```java
+streamVerification.requestVerification(streamClient, receiverStream.getStreamId());
+```
+
+## Several transmitters
+
+The settings at `easyssf.receiver.*` implicitly configure one transmitter, named `default`. An application that
+receives events from several transmitters, several identity providers, or OAuth authorizationsServers for instance,
+configures the others under `easyssf.receiver.transmitters..*`, each with the same settings a transmitter has
+(issuer, metadata and JWK Set URLs, audience, delivery method, push header, poll, stream, token
+credentials, SET validation) and each complete on its own, nothing is inherited from the default one:
+
+```yaml
+easyssf:
+ receiver:
+ transmitter-issuer: https://idp.example/realms/employees # the default transmitter, push
+ push:
+ expected-auth-header: Bearer ${EMPLOYEES_PUSH_SECRET}
+ transmitters:
+ customers: # a second one, polled
+ transmitter-issuer: https://idp.example/realms/customers
+ delivery-method: poll
+ stream:
+ management: receiver
+ oauth2:
+ token-uri: https://idp.example/realms/customers/protocol/openid-connect/token
+ client-id: my-receiver
+ client-secret: ${CUSTOMERS_RECEIVER_CLIENT_SECRET}
+```
+
+- The `iss` claim of a SET selects the transmitter that verifies it, so one push endpoint serves all
+ of them; each transmitter authenticates with its own `expected-auth-header`. Polled transmitters
+ are polled independently.
+- The beans (`SsfStreamClient`, `SsfReceiverStream`, `SsfPoller`, ...) belong to the default
+ transmitter, so replacing them works as before. The `SsfTransmitters` bean holds every transmitter
+ by name and by issuer, with its stream client, stream, registrar and poller; `SsfTransmitterCustomizer`
+ beans customize transmitters before they are built. Only named transmitters, without a default one,
+ are fine as well.
+- Handlers see the issuer in `eventContext.eventToken().iss()`. Revocations and sessions are scoped
+ to the issuer anyway. The metrics carry a `transmitter` tag with the name of the transmitter, and
+ the health indicator lists every transmitter under its name.
+- Dedup, the database, metrics, HTTP timeouts, the push endpoint path and the resource server and
+ OIDC client integrations are shared by all transmitters.
+
+## Keeping state in the database
+
+By default the receiver remembers in memory which SETs it processed and which sessions and subjects
+were revoked. That state is lost on restart and not shared between instances.
+
+If the application has a `JdbcTemplate` (with `spring-boot-starter-jdbc`, Spring Data JDBC or JPA),
+the state is kept in its database instead, without further configuration:
+
+| Table | Store | Content |
+|---|---|---|
+| `EASYSSF_PROCESSED_SET` | `JdbcSsfJtiDedupStore` | the SETs that were processed, forgotten after `easyssf.receiver.dedup.retention` (7 days) |
+| `EASYSSF_REVOCATION` | `JdbcSsfTokenRevocationStore` | revoked sessions and subjects, per issuer, removed after `easyssf.receiver.resource-server.revocation-ttl` |
+
+- **Tables**: in an embedded database (H2, HSQLDB, Derby) the tables are created on startup. For any
+ other database create them with your migration tool, the statements are in
+ [`schema.sql`](../easyssf-receiver-jdbc/src/main/resources/org/easyssf/receiver/jdbc/schema.sql)
+ (`classpath:org/easyssf/receiver/jdbc/schema.sql`), or set
+ `easyssf.receiver.jdbc.initialize-schema=always`. If a table is missing the application fails on
+ startup and says so, rather than on the first event. The stores use plain SQL (`VARCHAR`,
+ `BIGINT`, no vendor syntax) and are tested on H2 and PostgreSQL.
+- **Opting out**: `easyssf.receiver.jdbc.enabled=false` keeps the state in memory although the
+ application has a database. Your own `SsfJtiDedupStore` or `SsfTokenRevocationStore` bean takes
+ precedence in any case.
+- **Cleanup**: expired rows are deleted when a store is written to (the SET table at most once a
+ minute) and every `easyssf.receiver.jdbc.cleanup-interval` (15 minutes) by `JdbcSsfStoreCleanup`
+ on a thread of its own; `easyssf.receiver.jdbc.cleanup-interval=0` turns the periodic cleanup off.
+ Both stores offer `purgeExpired()` to do it from your own scheduler.
+- **Cost**: a resource server checks every access token with one query on the primary key of
+ `EASYSSF_REVOCATION`.
+- The stores live in `easyssf-receiver-jdbc` and have no Spring dependency: `SsfJdbcOperations` runs
+ their SQL, over a `DataSource` (`DataSourceSsfJdbcOperations`) or, in Spring Boot, the `JdbcTemplate`
+ of the application, so its transactions and exception translation apply.
+- The stores use plain SQL (timestamps are stored as milliseconds since the epoch) and were tested
+ with H2 and PostgreSQL. The revocation table is only used by resource servers. Sessions of an
+ OIDC client are not affected, see `SsfSessionTerminator`.
+
+## Calling the transmitter
+
+Stream management and polling call the transmitter and usually need an access token:
+
+```yaml
+easyssf:
+ receiver:
+ oauth2: # client credentials grant
+ token-uri: https://idp.example/realms/demo/protocol/openid-connect/token
+ client-id: my-receiver
+ client-secret: ${SSF_RECEIVER_CLIENT_SECRET}
+ scopes: ssf.read, ssf.manage
+ # or, for transmitters that hand out long-lived tokens:
+ # transmitter-access-token: ${SSF_TRANSMITTER_TOKEN}
+```
+
+For anything else provide a `SsfTransmitterTokenProvider` bean. Metadata and JWK Set are fetched
+without authentication.
+
+All calls to the transmitter go through the `SsfHttpClient` bean:
+
+- If the application has a `RestClient.Builder` (`spring-boot-starter-restclient`), the calls are
+ made with a `RestClient` built from it. They then use the HTTP client library, the
+ `spring.http.clients.*` settings (SSL bundle, redirects) and the `RestClientCustomizer`s of the
+ application, and are observed like its other HTTP calls (`http.client.requests` metric, tracing).
+ `easyssf.receiver.http.use-rest-client=false` switches this off.
+- Otherwise the HTTP client of the JDK is used.
+- Timeouts: `easyssf.receiver.http.connect-timeout` / `read-timeout` if set, else
+ `spring.http.clients.connect-timeout` / `read-timeout` (for the `RestClient`), else 5 seconds.
+- Provide your own `SsfHttpClient` bean for anything else.
+
+## Metrics
+
+If the application has a Micrometer `MeterRegistry` (for example with
+`spring-boot-starter-actuator`), the receiver records:
+
+| Meter | Type | Tags |
+|---|---|---|
+| `easyssf.receiver.sets` | counter | `transmitter` (the name of the transmitter, `default` for the one at `easyssf.receiver.*`), `delivery` (`push`, `poll`), `outcome` (`handled`, `duplicate`, `invalid`, `unauthenticated`, `unavailable`, `failed`) |
+| `easyssf.receiver.events` | counter | `transmitter`, `delivery`, `event` (for example `CaepSessionRevoked`) |
+| `easyssf.receiver.poll` | timer | `transmitter`, `outcome` (`success`, `failure`) |
+
+Switch it off with `easyssf.receiver.metrics.enabled=false`, or provide your own `SsfReceiverMetrics` bean.
+
+## Health
+
+With Spring Boot Actuator the receiver contributes the health indicator `easyssf`
+(`/actuator/health/easyssf`), from what it already knows; it never calls the transmitter for it:
+
+| Status | When |
+|---|---|
+| `UP` | The transmitter was reached: its metadata was retrieved (PUSH) or the last poll succeeded (POLL), and the stream, if it is looked up or managed, is registered. |
+| `DOWN` | The last poll failed, or the stream cannot be used (it was created with another issuer). |
+| `UNKNOWN` | No contact with the transmitter yet. The application starts without it. |
+
+The details name the transmitter, the delivery method, whether the metadata was retrieved, the stream and
+its registration state, and for POLL the last poll, the last successful poll and the error of the last
+poll. With [several transmitters](#several-transmitters) the details of each are listed under its
+name, with its own status. Switch it off with `management.health.easyssf.enabled=false`.
+
+## Configuration
+
+| Property | Default | |
+|---|---|---|
+| `easyssf.receiver.enabled` | `true` | Switches the receiver off entirely when `false`. |
+| `easyssf.receiver.transmitter-issuer` | | Issuer of the transmitter, must match `iss` of every SET. An `https` URL without query or fragment; `http` only on loopback addresses or with `allow-insecure-http`. Required unless transmitters are configured by name. |
+| `easyssf.receiver.transmitters..*` | | Further transmitters, see [Several transmitters](#several-transmitters): every property of a transmitter, from `transmitter-issuer` to `set-validation.*`, under its name. |
+| `easyssf.receiver.transmitter-metadata-url` | derived | By default `/.well-known/ssf-configuration` (SSF 1.0, section 7.2), falling back to `/.well-known/ssf-configuration`. |
+| `easyssf.receiver.transmitter-jwks-url` | from metadata | Skips metadata discovery when set. |
+| `easyssf.receiver.allow-insecure-http` | `false` | Accepts `http` for the transmitter issuer and the endpoints it publishes on any host, with a warning on startup. For development only. |
+| `easyssf.receiver.expected-audience` | | When set, every SET must contain it in `aud`. |
+| `easyssf.receiver.delivery-method` | `push` | `push` or `poll`. |
+| `easyssf.receiver.understood-subject-members` | `user, session, device, application, tenant, org_unit, group` | The members of a complex subject the application interprets; a SET with a member the transmitter declared critical that is not listed is rejected. |
+| `easyssf.receiver.event-aliases.*` | | Aliases for event type URIs, e.g. `AcmeLogin: https://events.acme.example/login`, usable wherever an event type is named. |
+| `easyssf.receiver.http.use-rest-client` | `true` | Call the transmitter with the `RestClient` of the application if it has a `RestClient.Builder`. |
+| `easyssf.receiver.http.connect-timeout` / `read-timeout` | `5s` | Calls to the transmitter. Unset, `spring.http.clients.*` applies to the `RestClient` before the 5 seconds do. |
+| `easyssf.receiver.http.user-agent` | default of the HTTP client | `User-Agent` header for calls to the transmitter. |
+| `easyssf.receiver.transmitter-access-token` | | Static access token for stream management and polling. |
+| `easyssf.receiver.oauth2.token-uri` | | Token endpoint for the client credentials grant. |
+| `easyssf.receiver.oauth2.client-id` / `client-secret` | | |
+| `easyssf.receiver.oauth2.scopes` | | |
+| `easyssf.receiver.oauth2.client-authentication-method` | `basic` | `basic` or `post`. |
+| `easyssf.receiver.oauth2.expiry-safety-window` | `30s` | How long before its expiry an access token is renewed, at most a quarter of its lifetime. |
+| `easyssf.receiver.oauth2.additional-parameters.*` | | Form parameters sent with the token request in addition to the grant. |
+| `easyssf.receiver.stream.management` | `transmitter` | `receiver` makes the application create or update its stream on startup. |
+| `easyssf.receiver.stream.id` | | Stream created at the transmitter, looked up on startup. |
+| `easyssf.receiver.stream.events-requested` | `CaepSessionRevoked`, `CaepCredentialChange` | For a stream managed by the receiver. |
+| `easyssf.receiver.stream.description` | | For a stream managed by the receiver. |
+| `easyssf.receiver.stream.delete-on-shutdown` | `false` | For a stream managed by the receiver. |
+| `easyssf.receiver.poll.endpoint-url` | from the stream | |
+| `easyssf.receiver.poll.auto-startup` | `true` | |
+| `easyssf.receiver.poll.interval` | `30s` | |
+| `easyssf.receiver.poll.initial-delay` | `1s` | |
+| `easyssf.receiver.poll.max-events` | `100` | SETs per request. |
+| `easyssf.receiver.poll.rate-limit.fallback-backoff` | | Pause after a `429` without `Retry-After`; unset, the next poll comes at the regular interval. |
+| `easyssf.receiver.poll.rate-limit.max-backoff` | `5m` | Longest pause a `Retry-After` header or the fallback can cause. |
+| `easyssf.receiver.metrics.enabled` | `true` | |
+| `easyssf.receiver.set-validation.accepted-algorithms` | `RS256` | JWS algorithms accepted for SETs. |
+| `easyssf.receiver.set-validation.min-rsa-key-size` | `2048` | `0` disables the check. |
+| `easyssf.receiver.set-validation.require-type-header` | `true` | Requires `typ: secevent+jwt`. |
+| `easyssf.receiver.set-validation.subject-compatibility` | `strict-ssf-1-0` | Requires the top-level `sub_id` claim of SSF 1.0. `legacy` accepts SETs of transmitters following earlier drafts, which put the subject into the event. |
+| `easyssf.receiver.set-validation.clock-skew` | `60s` | Tolerance for `iat` in the future. |
+| `easyssf.receiver.push.enabled` | `true` | |
+| `easyssf.receiver.push.endpoint-path` | `/ssf/push` | |
+| `easyssf.receiver.push.expected-auth-header` | | Exact `Authorization` header value the transmitter must send. |
+| `easyssf.receiver.push.delivery-endpoint-url` | | How the transmitter reaches the push endpoint, for a stream managed by the receiver. |
+| `easyssf.receiver.push.security.enabled` | `true` | Dedicated `SecurityFilterChain` for the push endpoint. |
+| `easyssf.receiver.dedup.enabled` | `true` | |
+| `easyssf.receiver.dedup.capacity` | `10000` | Size of the in-memory `jti` store. |
+| `easyssf.receiver.dedup.retention` | `7d` | How long the JDBC store remembers a processed SET. |
+| `easyssf.receiver.jdbc.enabled` | `true` | Keep state in the database if the application has a `JdbcTemplate`. |
+| `easyssf.receiver.jdbc.initialize-schema` | `embedded` | When to create missing tables: `embedded`, `always` or `never`. |
+| `easyssf.receiver.jdbc.table-prefix` | `EASYSSF_` | Prefix of the table names. |
+| `easyssf.receiver.jdbc.cleanup-interval` | `15m` | How often expired rows are purged; `0` turns it off. |
+| `easyssf.receiver.resource-server.enabled` | `true` | |
+| `easyssf.receiver.resource-server.event-types` | `CaepSessionRevoked` | Events that revoke access tokens. |
+| `easyssf.receiver.resource-server.revocation-ttl` | `1h` | |
+| `easyssf.receiver.oidc-client.enabled` | `true` | |
+| `easyssf.receiver.oidc-client.session-event-types` | `CaepSessionRevoked` | Events that terminate the session (or user sessions) of their subject. |
+| `easyssf.receiver.oidc-client.user-event-types` | `CaepCredentialChange` | Events that terminate all sessions of their subject's user. |
+| `easyssf.receiver.oidc-client.scim-attribute-claims.*` | `externalId: sub`, `id: sub`, `userName: preferred_username` | Attributes of the `scim` subject of a SCIM Event and the claim of the logged-in user each is compared with. |
+
+Beans you can replace: `SsfSetVerifier`, `SsfTransmitterMetadataResolver`, `SsfJtiDedupStore`,
+`SsfTokenRevocationStore`, `SsfRevokedTokenValidator`, `SsfSessionTerminator`, `SsfSessionMatcher`,
+`SsfTransmitterTokenProvider`, `SsfStreamClient`, `SsfStreamRegistrar`, `SsfPoller`, `SsfReceiverMetrics`,
+`SsfHttpClient`, `SsfPushHandler`.
+
+## Not included (yet)
+
+- WebFlux applications.