From dacae70721b791dc0574a8b77dccd68ab035df5d Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Sun, 4 Oct 2026 14:46:25 +0200 Subject: [PATCH] Describe the three integrations, move the starter's docs to its module The root README introduces plain Java, Spring Boot and Quarkus side by side, with a getting-started per integration, and keeps what every integration shares. The Spring Boot specifics, from the use cases to the configuration table, move to the README of the starter. Closes #19 Signed-off-by: Thomas Darimont --- AGENTS.md | 7 +- README.md | 588 ++++-------------- .../AGENTS.md | 2 +- .../README.md | 452 ++++++++++++++ 4 files changed, 577 insertions(+), 472 deletions(-) create mode 100644 easyssf-receiver-spring-boot-starter/README.md 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.