Quarkus OpenID SSF

Today the project ships a single extension — the SSF receiver.

Receiver

Lets a Quarkus app act as an SSF receiver against any spec-compliant transmitter, to integrate with public providers like caep.dev, on-prem / self-hosted IdPs, or custom implementations.

What it does

The protocol work is done by easyssf (org.easyssf:easyssf-receiver), a framework independent SSF receiver library shared with its Spring Boot starter. The extension adds configuration, CDI wiring, the Vert.x push route, the poll scheduler, the token providers, Micrometer, a health check, JDBC, the Dev UI and native image support.

  • Accepts inbound SETs via PUSH (RFC 8935) on a Vert.x route at /ssf/push, answering with the RFC 8935 error documents; handlers run before the 202.

  • Pulls SETs via POLL (RFC 8936) with a poller per transmitter on a virtual thread, periodic or long polling, acknowledgements that wait in a store (optionally the database) until a request carried them, error reports, Retry-After handling and a manual pollNow().

  • Verifies every SET: JWS signature against the transmitter’s JWK Set, typ header, iss / iat / jti / aud / events per RFC 8417. RS256-only with a 2048-bit minimum RSA key by default (CAEP Interop Profile), tunable.

  • Manages the stream of the receiver: RECEIVER (default) looks it up at the transmitter on startup and creates or updates it, with retries in the background; TRANSMITTER uses a stream an operator created.

  • Receives from several transmitters (quarkus.openid-ssf.receiver.<name>.*), selected by the issuer of a SET.

  • Exposes the stream management API (SSF section 8.1) through SsfReceiverStreamClient and easyssf’s SsfStreamClient.

  • Optional Micrometer metrics (easyssf.receiver.*), a SmallRye Health wellness check, JDBC stores over Agroal (processed SETs, pending poll acknowledgements) shared by all instances, a Dev UI.

  • Outbound auth: static bearer token, OAuth2 client_credentials (no extra dependency), quarkus-oidc-client, or none.

Installation

Add the extension to your Quarkus application:

<dependency>
    <groupId>io.quarkiverse.openid-ssf</groupId>
    <artifactId>quarkus-openid-ssf-receiver</artifactId>
    <version>0.6.0</version>
</dependency>

Quick start

The fastest end-to-end smoke test is against the public caep.dev transmitter — no Keycloak setup, no public tunnel, and no admin server of your own.

  1. Sign in at https://ssf.caep.dev and copy the access token.

  2. Open https://caep.dev/transmitter/events, set the audience and start the transmitter session.

  3. Run the receiver-managed example in POLL mode:

    export SSF_RECEIVER_TRANSMITTER_ACCESS_TOKEN=<your-caep-dev-token>
    export SSF_RECEIVER_EXPECTED_AUDIENCE=https://my-receiver.example/ssf
    
    mvn -pl receiver/examples/example-receiver-managed-stream quarkus:dev \
        -Dquarkus.profile=caepdev,poll \
        -Dquarkus.openid-ssf.receiver.poll.interval=5s
  4. Fire an event from the caep.dev transmitter tab.

  5. Inspect curl -s localhost:28080/events/recent-events | jq.

See the repository README for the full walkthrough, and the examples for a complete setup with Keycloak as identity provider and transmitter.

Consumer SPI

Provide a CDI bean implementing easyssf’s SsfEventHandler; every handler bean is invoked for every verified SET, before the transmitter gets its answer:

import org.easyssf.receiver.event.SsfEventContext;
import org.easyssf.receiver.event.SsfEventHandler;

@ApplicationScoped
public class MyHandler implements SsfEventHandler {
    @Override
    public void handle(SsfEventContext eventContext) {
        if (eventContext.hasEvent("CaepSessionRevoked")) {
            SsfSubject subject = eventContext.subjectFor("CaepSessionRevoked");
            // … invalidate the session subject.sessionId() of subject.subject() …
        }
    }
}

A handler that throws makes the transmitter deliver the SET again, so handlers have to be idempotent. Without an explicit handler, the default LoggingSsfEventHandler logs jti / iss / event-type aliases at INFO.

Testing

org.easyssf:easyssf-test has TestTransmitter, an in-process SSF transmitter and identity provider for the tests of an application: metadata, JWK Set, signed SETs and access tokens, a token endpoint, the stream API and a poll endpoint. The examples show a QuarkusTestResourceLifecycleManager around it.

Architecture

For the "why is the code shaped this way" view — module layout, startup observer priorities, hot-path flow, build-time decisions — see design/receiver.md.

Configuration Reference

Configuration property fixed at build time - All other configuration properties are overridable at runtime

Configuration property

Type

Default

Master kill switch for the SSF receiver. When false, no push endpoint is registered, no stream is looked up or registered and no transmitter is polled. The beans of the extension stay injectable, but need a configured transmitter to do anything.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_ENABLED

boolean

true

The transmitters this receiver gets events from

Type

Default

quarkus.openid-ssf.receiver."transmitter-name".transmitter-issuer

Issuer of the SSF transmitter, for example a Keycloak realm URL. Every inbound SET must carry this value as its iss claim. It must use https, or http on a loopback address, see allow-insecure-http().

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_TRANSMITTER_ISSUER

string

quarkus.openid-ssf.receiver."transmitter-name".transmitter-metadata-url

Location of the transmitter’s SSF configuration metadata.

If unset, the URL is derived from transmitter-issuer() per SSF 1.0 section 7.2: /.well-known/ssf-configuration is inserted between the host and the path of the issuer, so https://tr.example.com/realms/r1 is looked up at https://tr.example.com/.well-known/ssf-configuration/realms/r1. As transmitters that grew out of OpenID providers tend to append it instead (https://tr.example.com/realms/r1/.well-known/ssf-configuration), that location is tried as a fallback.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_TRANSMITTER_METADATA_URL

URI

quarkus.openid-ssf.receiver."transmitter-name".transmitter-jwks-url

Location of the JWK Set used to verify SET signatures. Discovered from the transmitter metadata (jwks_uri) when not set.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_TRANSMITTER_JWKS_URL

URI

quarkus.openid-ssf.receiver."transmitter-name".allow-insecure-http

Whether the transmitter issuer and the endpoints it publishes may use plain http on hosts other than loopback addresses. For test setups only, SSF requires https: the metadata of the transmitter decides where keys come from, and without TLS anyone on the path can change it.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_ALLOW_INSECURE_HTTP

boolean

false

quarkus.openid-ssf.receiver."transmitter-name".expected-audience

Audience this receiver is known as at the transmitter. When set, every inbound SET must contain it in its aud claim. When not set, the audience of the stream the receiver registers or looks up on startup (stream-management=RECEIVER, stream-id) is required instead, once the stream is known; until then a pushed SET is answered with 503 and a polled one left unacknowledged, so that the transmitter delivers it again. One of the two, or set-validation.allow-any-audience, is required: a receiver that cannot know the audience of its SETs does not start, as it would accept SETs the transmitter issued for other receivers.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_EXPECTED_AUDIENCE

string

quarkus.openid-ssf.receiver."transmitter-name".stream-management

Who manages the stream. RECEIVER (the default): the extension looks the stream of this receiver up on startup and creates or updates it if necessary. TRANSMITTER: the stream is created at the transmitter and its identifier is pinned with stream-id(); without a stream id the receiver does not call the stream management API on startup at all.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_STREAM_MANAGEMENT

transmitter, receiver

receiver

quarkus.openid-ssf.receiver."transmitter-name".stream-id

Identifier of a stream that was created at the transmitter. When set, the stream is looked up on startup to learn its audience and poll endpoint.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_STREAM_ID

string

quarkus.openid-ssf.receiver."transmitter-name".delivery-method

How SETs are delivered. PUSH (the default, RFC 8935): the transmitter posts each SET to the push endpoint of this application. POLL (RFC 8936): the extension fetches SETs from the poll endpoint of the transmitter, see poll().

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_DELIVERY_METHOD

push, poll

push

quarkus.openid-ssf.receiver."transmitter-name".events-requested

Event types this receiver wants to subscribe to, as a full URI or an alias (the built-in aliases of the SSF, CAEP, RISC and SCIM event types, CaepSessionRevoked, RiscAccountDisabled, ScimProvCreateFull, …​ or one of event-aliases). Required for a stream managed by the receiver, where it becomes events_requested; informational for a transmitter-managed one.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_EVENTS_REQUESTED

list of string

quarkus.openid-ssf.receiver."transmitter-name".transmitter-access-token

Static bearer access token to send on calls to the transmitter, for transmitters such as caep.dev that hand out long-lived tokens out-of-band. Takes precedence over oauth2() and the OIDC client.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_TRANSMITTER_ACCESS_TOKEN

string

quarkus.openid-ssf.receiver."transmitter-name".push.expected-auth-header

Exact value the transmitter must send in the Authorization header, for example Bearer s3cr3t. No header check is performed when not set.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_PUSH_EXPECTED_AUTH_HEADER

string

quarkus.openid-ssf.receiver."transmitter-name".push.delivery-endpoint-url

URL under which the transmitter reaches the push endpoint of this application, advertised as delivery.endpoint_url of a stream managed by the receiver. Required with stream-management=RECEIVER and PUSH delivery.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_PUSH_DELIVERY_ENDPOINT_URL

URI

quarkus.openid-ssf.receiver."transmitter-name".poll.endpoint-url

Poll endpoint of the transmitter. Taken from the configuration of the stream when not set, which needs the stream to be looked up or managed on startup.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_POLL_ENDPOINT_URL

URI

quarkus.openid-ssf.receiver."transmitter-name".poll.auto-start

Whether the transmitter is polled by the extension, on a virtual thread of its own per transmitter: every interval, or with one request outstanding when long-polling is on. Set to false to drive polling from application code with SsfPollScheduler.pollNow().

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_POLL_AUTO_START

boolean

true

quarkus.openid-ssf.receiver."transmitter-name".poll.start-delay

Time to wait before the first poll after startup.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_POLL_START_DELAY

Duration 

0S

quarkus.openid-ssf.receiver."transmitter-name".poll.interval

Time between two polls. With long-polling, the pause after a failed request, or after a transmitter that does not hold requests answered an empty one at once. The first poll follows the registration of the stream at once.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_POLL_INTERVAL

Duration 

30S

quarkus.openid-ssf.receiver."transmitter-name".poll.long-polling

Long polling (RFC 8936, section 2.5): the poller keeps one request outstanding that the transmitter holds until SETs are available or long-polling-hold elapses, instead of asking every interval whether there is something. A SET is then fetched as soon as the transmitter has it. The transmitter has to support long polling; one that answers at once is polled every interval as with false.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_POLL_LONG_POLLING

boolean

false

quarkus.openid-ssf.receiver."transmitter-name".poll.long-polling-hold

How long the transmitter holds a long poll request, part of the agreement with it (RFC 8936, section 2.2). The request waits that long plus a margin for the response, http.read-timeout does not apply to it.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_POLL_LONG_POLLING_HOLD

Duration 

30S

quarkus.openid-ssf.receiver."transmitter-name".poll.max-events

Maximum number of SETs to fetch with one request (maxEvents, RFC 8936).

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_POLL_MAX_EVENTS

int

100

quarkus.openid-ssf.receiver."transmitter-name".poll.rate-limit.fallback-backoff

Pause after a 429 without a Retry-After header. Not set: poll again at the regular interval.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_POLL_RATE_LIMIT_FALLBACK_BACKOFF

Duration 

quarkus.openid-ssf.receiver."transmitter-name".poll.rate-limit.max-backoff

Longest pause a Retry-After header or fallback-backoff can cause. Protects the receiver from a transmitter that asks it to stay away for hours.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_POLL_RATE_LIMIT_MAX_BACKOFF

Duration 

5M

quarkus.openid-ssf.receiver."transmitter-name".receiver-managed.delete-on-shutdown

Whether the stream managed by the receiver is deleted at the transmitter when the application stops, so that short-lived receivers (dev mode, tests) do not leave streams behind. false keeps the stream across restarts.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_RECEIVER_MANAGED_DELETE_ON_SHUTDOWN

boolean

false

quarkus.openid-ssf.receiver."transmitter-name".receiver-managed.description

Description of the stream managed by the receiver.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_RECEIVER_MANAGED_DESCRIPTION

string

quarkus.openid-ssf.receiver."transmitter-name".set-validation.accepted-algorithms

JWS algorithms accepted for SET signatures. Only asymmetric signature algorithms are allowed. A SET signed with another algorithm is rejected.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_SET_VALIDATION_ACCEPTED_ALGORITHMS

list of string

RS256

quarkus.openid-ssf.receiver."transmitter-name".set-validation.min-rsa-key-size

Minimum RSA key size in bits for SET signing keys. 0 disables the check.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_SET_VALIDATION_MIN_RSA_KEY_SIZE

int

2048

quarkus.openid-ssf.receiver."transmitter-name".set-validation.require-type-header

Whether a SET must be explicitly typed with a typ header of secevent+jwt.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_SET_VALIDATION_REQUIRE_TYPE_HEADER

boolean

true

quarkus.openid-ssf.receiver."transmitter-name".set-validation.subject-compatibility

How strictly the subject of a SET is validated: 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 payload.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_SET_VALIDATION_SUBJECT_COMPATIBILITY

strict-ssf-1-0, legacy

strict-ssf-1-0

quarkus.openid-ssf.receiver."transmitter-name".set-validation.clock-skew

Tolerated clock skew when checking that a SET was not issued in the future.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_SET_VALIDATION_CLOCK_SKEW

Duration 

60S

quarkus.openid-ssf.receiver."transmitter-name".set-validation.allow-any-audience

Accepts SETs whatever audience they carry when neither expected-audience is set nor a stream is registered or looked up (stream-management, stream-id) that tells the audience, with a warning on startup. Off by default: a receiver without a known audience fails to start, because it would accept SETs the transmitter issued for other receivers. Only for a transmitter that sends no aud.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_SET_VALIDATION_ALLOW_ANY_AUDIENCE

boolean

false

quarkus.openid-ssf.receiver."transmitter-name".oauth2.token-endpoint

Token endpoint to obtain access tokens from with the client credentials grant. Setting it activates this provider.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_OAUTH2_TOKEN_ENDPOINT

URI

quarkus.openid-ssf.receiver."transmitter-name".oauth2.client-id

Client id of this receiver.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_OAUTH2_CLIENT_ID

string

quarkus.openid-ssf.receiver."transmitter-name".oauth2.client-secret

Client secret of this receiver.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_OAUTH2_CLIENT_SECRET

string

quarkus.openid-ssf.receiver."transmitter-name".oauth2.scopes

Scopes to request, sent space-separated in the scope parameter.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_OAUTH2_SCOPES

list of string

quarkus.openid-ssf.receiver."transmitter-name".oauth2.client-auth-method

How the client authenticates at the token endpoint: basic (client_secret_basic, the default) or post (client_secret_post, credentials in the form body).

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_OAUTH2_CLIENT_AUTH_METHOD

basic, post

basic

quarkus.openid-ssf.receiver."transmitter-name".oauth2.additional-params."parameter"

Form parameters to send with the token request in addition to the grant, for extensions of the token endpoint.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_OAUTH2_ADDITIONAL_PARAMS__PARAMETER_

Map<String,String>

quarkus.openid-ssf.receiver."transmitter-name".oauth2.expiry-safety-window

How long before its expiry an access token is renewed; never more than a quarter of the token’s lifetime.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_OAUTH2_EXPIRY_SAFETY_WINDOW

Duration 

30S

quarkus.openid-ssf.receiver."transmitter-name".oidc.client-name

Name of the OIDC client (quarkus.oidc-client.<name>.*) to obtain access tokens from. The default client (quarkus.oidc-client.*) when not set.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_OIDC_CLIENT_NAME

string

quarkus.openid-ssf.receiver."transmitter-name".oidc.token-timeout

Maximum time to wait for an access token from the OIDC client.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_OIDC_TOKEN_TIMEOUT

Duration 

2S

Aliases for event type URIs, usable wherever an event type is named: in events-requested, in handlers (eventContext.hasEvent("AcmeLogin")) and as the event tag of the metrics. Keyed by alias, valued by URI:

quarkus.openid-ssf.receiver.event-aliases.AcmeLogin=https://events.acme.example/login

They add to the built-in aliases of the SSF, CAEP, RISC and SCIM event types (for example CaepSessionRevoked, RiscAccountDisabled, ScimProvCreateFull) and cannot redefine one; a conflicting alias fails the start.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_EVENT_ALIASES__ALIAS_

Map<String,String>

The members of a complex subject the application interprets. A transmitter can declare members critical (critical_subject_members in its metadata); a SET whose subject has such a member that is not listed here is rejected as invalid_request, as the SSF specification requires critical members to be interpreted by the receiver. Defaults to the members SsfSubject gives access to.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_UNDERSTOOD_SUBJECT_MEMBERS

list of string

user, session, device, application, tenant, org_unit, group

Path of the push endpoint, relative to quarkus.http.root-path. The transmitters with delivery-method=PUSH post their SETs there; which transmitter sent a SET is told by its issuer.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_PUSH_ENDPOINT_PATH

string

/ssf/push

Connect timeout for calls to the transmitters.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_HTTP_CONNECT_TIMEOUT

Duration 

5S

Read timeout for calls to the transmitters: how long to wait for the response to a metadata, JWK Set, stream management, token or poll request.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_HTTP_READ_TIMEOUT

Duration 

5S

User-Agent header for calls to the transmitters. The default of the JDK HTTP client (Java-http-client/<version>) is sent when not set.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_HTTP_USER_AGENT

string

Whether SETs that were already processed are skipped. A SET that was processed before is acknowledged (202 on PUSH, acknowledged on POLL) without invoking the handlers again. Set to false when the handlers are idempotent anyway.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_DEDUP_ENABLED

boolean

true

Number of SET identifiers the in-memory store remembers. Overflow evicts the oldest. Ignored by a JDBC store or a custom SsfJtiDedupStore bean.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_DEDUP_CAPACITY

int

10000

How long the JDBC store remembers a processed SET. Has to cover the time the transmitter keeps trying to deliver a SET.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_DEDUP_RETENTION

Duration 

7D

How long a SET counts as being handled by the instance that claimed it without renewing the claim, which it does every third of this time while its handlers run. A SET is claimed in the store before the handlers run and marked processed afterwards; another instance that receives the same SET meanwhile neither handles nor acknowledges it, the transmitter delivers it again. A claim that was not renewed for the lease counts as abandoned by an instance that crashed, and the SET is handled again.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_DEDUP_LEASE

Duration 

60S

Whether the state of the receiver is kept in the default datasource of the application, if it has one (quarkus-agroal), so that all instances of the application share it and it survives a restart: the processed SETs (<prefix>PROCESSED_SET) and, with POLL delivery, the acknowledgements a poller owes its transmitter (<prefix>POLL_ACK). Otherwise both are kept in memory.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_JDBC_ENABLED

boolean

true

Whether the tables are created at startup if they do not exist, and columns a release added are added to an existing table. With false the tables have to be created with the statements in classpath:org/easyssf/receiver/jdbc/schema.sql (a fresh installation) or the versioned scripts in classpath:org/easyssf/receiver/jdbc/migration/ (Flyway naming, plain SQL), for example by a schema migration, and the start fails naming the statements to run if a table or a column is missing.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_JDBC_INITIALIZE_SCHEMA

boolean

true

Prefix of the names of the tables.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_JDBC_TABLE_PREFIX

string

EASYSSF_

How often expired rows (processed SETs past dedup.retention, acknowledgements past jdbc.ack-retention) are purged. The stores also purge when they are written to; 0 turns the periodic cleanup off.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_JDBC_CLEANUP_INTERVAL

Duration 

15M

How long an acknowledgement or error report the transmitter never accepted is kept in the <prefix>POLL_ACK table.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_JDBC_ACK_RETENTION

Duration 

7D

How many acknowledgements one DELETE statement removes from the <prefix>POLL_ACK table (JTI IN (…​)). Databases limit the number of parameters of a statement (SQL Server to 2100, Oracle to 1000 list members); a smaller batch means more statements per poll.

Environment variable: QUARKUS_OPENID_SSF_RECEIVER_JDBC_ACK_DELETE_BATCH_SIZE

int

100

About the Duration format

To write duration values, use the standard java.time.Duration format. See the Duration#parse() Java API documentation for more information.

You can also use a simplified format, starting with a number:

  • If the value is only a number, it represents time in seconds.

  • If the value is a number followed by ms, it represents time in milliseconds.

In other cases, the simplified format is translated to the java.time.Duration format for parsing:

  • If the value is a number followed by h, m, or s, it is prefixed with PT.

  • If the value is a number followed by d, it is prefixed with P.