{"id":50564196,"url":"https://github.com/quarkiverse/quarkus-openid-ssf","last_synced_at":"2026-06-04T13:01:41.772Z","repository":{"id":357325687,"uuid":"1235723680","full_name":"quarkiverse/quarkus-openid-ssf","owner":"quarkiverse","description":"Quarkus Extensions supporting the Shared Signals Framework (SSF)","archived":false,"fork":false,"pushed_at":"2026-05-12T08:44:58.000Z","size":281,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-12T10:37:35.761Z","etag":null,"topics":["caep","keycloak","oidc","quarkus-extension","rfc8417","rfc8935","rfc8936","risc","secevent","security","set","shared-signals","ssf"],"latest_commit_sha":null,"homepage":"https://docs.quarkiverse.io/quarkus-openid-ssf/dev","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/quarkiverse.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":"NOTICE","maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-11T15:38:13.000Z","updated_at":"2026-05-12T08:45:01.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/quarkiverse/quarkus-openid-ssf","commit_stats":null,"previous_names":["quarkiverse/quarkus-openid-ssf"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/quarkiverse/quarkus-openid-ssf","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/quarkiverse%2Fquarkus-openid-ssf","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/quarkiverse%2Fquarkus-openid-ssf/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/quarkiverse%2Fquarkus-openid-ssf/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/quarkiverse%2Fquarkus-openid-ssf/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/quarkiverse","download_url":"https://codeload.github.com/quarkiverse/quarkus-openid-ssf/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/quarkiverse%2Fquarkus-openid-ssf/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33905359,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-04T02:00:06.755Z","response_time":64,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["caep","keycloak","oidc","quarkus-extension","rfc8417","rfc8935","rfc8936","risc","secevent","security","set","shared-signals","ssf"],"created_at":"2026-06-04T13:01:41.692Z","updated_at":"2026-06-04T13:01:41.754Z","avatar_url":"https://github.com/quarkiverse.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"# quarkus-openid-ssf\n\n[![Build (JVM)](https://github.com/quarkiverse/quarkus-openid-ssf/actions/workflows/build.yml/badge.svg)](.github/workflows/build.yml)\n[![Native build](https://github.com/quarkiverse/quarkus-openid-ssf/actions/workflows/native.yml/badge.svg)](.github/workflows/native.yml)\n[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)\n\nA Quarkus extension that lets a Quarkus app act as a [Shared Signals Framework\n(SSF)](https://openid.net/specs/openid-sharedsignals-framework-1_0.html)\nreceiver against any spec-compliant SSF transmitter — public providers like\n[caep.dev](https://ssf.caep.dev), on-prem / self-hosted IdPs, or custom\nimplementations. Outbound auth is OAuth2-based (`client_credentials` via\n`quarkus-oidc-client`, or a long-lived bearer token), so anything that issues\nOAuth2 access tokens fits.\n\n| | |\n|---|---|\n| **Status** | Experimental — APIs may change before 1.0. |\n| **License** | [Apache-2.0](LICENSE) |\n| **Java** | 21+ (extension and examples both compile under `--release 21`) |\n| **Quarkus** | 3.35.x (floor — see [Compatibility](#compatibility)) |\n| **Group ID** | `io.quarkiverse.openid-ssf` |\n| **Receiver artifact** | `quarkus-openid-ssf-receiver` |\n\n## What it does\n\n- Accepts inbound SETs via **PUSH** (RFC 8935) — registers a Vert.x route at `/ssf/push`.\n- Pulls SETs via **POLL** (RFC 8936) — periodic Vert.x timer; manual `pollNow()` trigger.\n- Verifies every SET: JWS signature against the transmitter's JWKS, plus\n  `iss` / `iat` / `jti` / `aud` checks per RFC 8417.\n- Manages stream lifecycle in two modes:\n    - **`RECEIVER`** (default) — extension calls the transmitter's\n      `configuration_endpoint` on startup to discover an existing stream or\n      create a new one.\n    - **`TRANSMITTER`** — operator pre-creates the stream; receiver is given a `stream-id`.\n- Exposes `SsfStreamClient` for the full §8.1.x management surface\n  (read / create / patch / replace / delete config, status read+update,\n  add/remove subjects, request verification, POLL).\n- Optional Micrometer metrics + per-event-type counters.\n- Outbound auth: static bearer token, self-contained OAuth2\n  `client_credentials` (no extra dep), `quarkus-oidc-client`, or no-op.\n\n## Layout\n\n| Module | Role |\n|---|---|\n| [`receiver/runtime/`](receiver/runtime/) | Extension runtime: config mapping, `SsfEventHandler` SPI, JWKS resolver, SET verifier, push route, POLL scheduler, stream client, alias resolver, metrics SPI. |\n| [`receiver/deployment/`](receiver/deployment/) | Build-time processor — registers beans + REST clients, picks the right `TransmitterTokenProvider` at build time, wires Micrometer when present. Also contains the smoke test (signed SET round-trip with a stub JWKS). |\n| [`receiver/examples/example-transmitter-managed-stream/`](receiver/examples/example-transmitter-managed-stream/) | Runnable Quarkus app — TRANSMITTER mode + PUSH delivery, env-var-driven, OIDC `client_credentials` for outbound auth. |\n| [`receiver/examples/example-receiver-managed-stream/`](receiver/examples/example-receiver-managed-stream/) | Runnable Quarkus app — RECEIVER mode. Default config is neutral; the `caepdev` profile overlays caep.dev (PUSH + static token), the `keycloak` profile overlays an OIDC + POLL setup. |\n\n## Consumer SPI\n\nProvide a CDI bean implementing `SsfEventHandler`:\n\n```java\n@ApplicationScoped\npublic class MyHandler implements SsfEventHandler {\n    @Override\n    public void handle(SsfEventContext eventContext) {\n        SsfEventToken eventToken = eventContext.eventToken();\n        // Raw RFC 8417 + SSF profile fields on the token:\n        //   eventToken.jti(), eventToken.iss(), eventToken.iat()\n        //   eventToken.aud()                 — always List\u003cString\u003e, even single-aud SETs\n        //   eventToken.events()              — Map\u003ceventTypeURI, payload\u003e (RFC 8417 §2.2)\n        //   eventToken.subjectId()           — sub_id object\n        //   eventToken.txn()                 — transaction id, may be null\n        //   eventToken.additionalProperties()— any unmodelled / future claims\n\n        // Alias-aware convenience on the context — accepts either an alias\n        // or the full URI, so handler code stays correct regardless of\n        // whether aliases are configured. Built-in aliases cover SSF, CAEP,\n        // and RISC spec event types out of the box (CaepSessionRevoked,\n        // RiscAccountDisabled, …).\n        if (eventContext.hasEvent(\"CaepSessionRevoked\")) { /* … */ }\n        Map\u003cString, Object\u003e payload = eventContext.eventFor(\"CaepCredentialChange\");\n        String issAlias = eventContext.issAlias();\n    }\n}\n```\n\nIf you don't, `LoggingSsfEventHandler` is the default — logs `jti` / `iss` /\nevent-type aliases at INFO.\n\n## Quick start\n\nThe fastest way to see the extension light up end-to-end is against the\npublic [caep.dev](https://ssf.caep.dev) transmitter — no Keycloak setup,\nno public tunnel, and no admin server of your own. POLL delivery means\ncaep.dev never has to reach back to your machine, so localhost is enough.\n\n### 1. Register at caep.dev and grab an access token\n\nSign in at \u003chttps://ssf.caep.dev\u003e and copy the access token from the\ndashboard. caep.dev hands these out long-lived and out-of-band rather than\nvia an OAuth grant — they go in `Authorization: Bearer …` on every outbound\ncall the receiver makes.\n\n### 2. Start a transmitter session at caep.dev\n\nOpen \u003chttps://caep.dev/transmitter/events\u003e, set the **audience** to a value\nof your choice (e.g. `https://my-receiver.example/ssf`), and start the\ntransmitter. Leave the tab open — you'll fire events from here in step 5.\n\n### 3. Run the receiver-managed example in POLL mode\n\n```sh\nexport SSF_RECEIVER_TRANSMITTER_ACCESS_TOKEN=\u003cyour-caep-dev-token\u003e\nexport SSF_RECEIVER_EXPECTED_AUDIENCE=https://my-receiver.example/ssf\n\nmvn -pl receiver/examples/example-receiver-managed-stream quarkus:dev \\\n    -Dquarkus.profile=caepdev \\\n    -Dquarkus.openid-ssf.receiver.delivery-method=POLL \\\n    -Dquarkus.openid-ssf.receiver.poll.interval=5s\n```\n\nThe `caepdev` profile points the receiver at `https://ssf.caep.dev` and\nselects the static-token outbound auth provider. The two `-D` overrides\nflip the example from PUSH (its profile default) to POLL — no inbound\nendpoint to expose. By default the receiver subscribes to\n`session-revoked` and `credential-change`; edit\n`quarkus.openid-ssf.receiver.events-requested` in `application.properties` to widen.\n\n### 4. Confirm the receiver registered with caep.dev\n\n```sh\ncurl -s localhost:28080/transmitter/registration | jq\n# → the stream_id caep.dev assigned to this receiver\n```\n\n### 5. Trigger an event from caep.dev\n\nIn the transmitter tab from step 2, fire a `session-revoked` (or any\nconfigured event type) for a subject of your choice.\n\n### 6. See it arrive\n\n```sh\ncurl -s localhost:28080/events/recent-events | jq\n# → the most recent SET, with verified jti / iss / events / payload\n```\n\nThat's the full receiver loop — discover-or-create stream, poll, verify\nsignature \u0026 claims, dispatch to your handler, ack — running against a\nreal transmitter in under a minute.\n\n### Minimum configuration cheatsheet\n\nFor pointing at your own transmitter (Keycloak, on-prem IdP, custom),\neither of these snippets is enough:\n\n```properties\n# Transmitter-managed (operator owns the stream)\nquarkus.openid-ssf.receiver.transmitter-issuer=https://transmitter.example\nquarkus.openid-ssf.receiver.stream-management=TRANSMITTER\nquarkus.openid-ssf.receiver.stream-id=\u003cfrom the transmitter's admin / onboarding flow\u003e\nquarkus.openid-ssf.receiver.delivery-method=PUSH\nquarkus.openid-ssf.receiver.push.expected-auth-header=Bearer \u003cshared-secret\u003e   # optional\n```\n\n```properties\n# Receiver-managed (extension creates / rediscovers the stream)\nquarkus.openid-ssf.receiver.transmitter-issuer=https://transmitter.example\nquarkus.openid-ssf.receiver.stream-management=RECEIVER\nquarkus.openid-ssf.receiver.delivery-method=PUSH                               # or POLL\nquarkus.openid-ssf.receiver.push.delivery-endpoint-url=https://my-app.example/ssf/push\nquarkus.openid-ssf.receiver.expected-audience=https://my-app.example\n# Built-in CAEP / RISC aliases work directly; full URIs are also accepted.\nquarkus.openid-ssf.receiver.events-requested=CaepSessionRevoked,CaepCredentialChange\n# Optional — defaults shown.\nquarkus.openid-ssf.receiver.receiver-managed.register-stream=true\nquarkus.openid-ssf.receiver.receiver-managed.delete-on-shutdown=false\n```\n\nIn RECEIVER mode the registrar lists this receiver's existing streams on the\ntransmitter and reuses one whose `delivery.endpoint_url` (or `aud`) matches\nbefore creating a new one — so a normal restart doesn't accumulate duplicates.\n\n## Usage patterns\n\nThree concrete shapes that cover most real-world receivers. Each is\nstand-alone — pick whichever matches your use case and copy.\n\n### 1 · Session / cache invalidator\n\n\u003e \"I'm a backend that authenticates users via an OIDC IdP. When the IdP\n\u003e revokes a session or rotates credentials, I need to drop my local sessions\n\u003e and tokens so the user can't continue with stale state.\"\n\n```java\n@ApplicationScoped\npublic class SessionRevocationHandler implements SsfEventHandler {\n\n    @Inject SessionStore sessions;\n    @Inject TokenCache tokens;\n\n    @Override\n    public void handle(SsfEventContext eventContext) {\n        String subjectId = (String) eventContext.eventToken().subjectId().get(\"sub\");\n        if (subjectId == null) return;\n\n        // CAEP aliases are built-in — no constants, no configuration needed.\n        if (eventContext.hasEvent(\"CaepSessionRevoked\")) {\n            sessions.invalidateAllFor(subjectId);\n        }\n        if (eventContext.hasEvent(\"CaepCredentialChange\")) {\n            tokens.invalidateAllFor(subjectId);\n            sessions.requireReauthFor(subjectId);\n        }\n    }\n}\n```\n\n```properties\nquarkus.openid-ssf.receiver.transmitter-issuer=https://transmitter.example\nquarkus.openid-ssf.receiver.stream-management=TRANSMITTER\nquarkus.openid-ssf.receiver.stream-id=\u003cfrom the transmitter's admin / onboarding flow\u003e\nquarkus.openid-ssf.receiver.delivery-method=PUSH\nquarkus.openid-ssf.receiver.expected-audience=https://my-app.example\nquarkus.openid-ssf.receiver.push.expected-auth-header=Bearer ${PUSH_SHARED_SECRET}\n```\n\n**Key points:**\n- TRANSMITTER-managed: simplest setup, operator creates the stream in the transmitter's admin console.\n- PUSH delivery: lowest latency, requires the receiver to be reachable from the transmitter.\n- The handler is **idempotent** by construction — invalidating an already-invalid session is a no-op. That's important because the extension's built-in `jti` dedup is best-effort across restarts (it's in-memory by default).\n\n### 2 · Event forwarder / fan-out\n\n\u003e \"I'm an audit hub or SIEM connector. I need to receive SSF events from\n\u003e several transmitters and republish them to Kafka / Elasticsearch /\n\u003e Datadog without losing any.\"\n\n```java\n@ApplicationScoped\npublic class KafkaForwarder implements SsfEventHandler {\n\n    @Inject @Channel(\"ssf-events\") Emitter\u003cString\u003e producer;\n    @Inject ObjectMapper json;\n\n    @Override\n    public void handle(SsfEventContext eventContext) {\n        // Forward the verified token + the alias-resolved issuer so downstream\n        // consumers don't have to reproduce the alias lookup.\n        SsfEventToken eventToken = eventContext.eventToken();\n        Map\u003cString, Object\u003e payload = Map.of(\n                \"jti\", eventToken.jti(),\n                \"iss\", eventToken.iss(),\n                \"issAlias\", eventContext.issAlias(),\n                \"iat\", eventToken.iat().toString(),\n                \"events\", eventToken.events(),\n                \"eventsByAlias\", eventContext.eventsByAlias(),\n                \"subjectId\", eventToken.subjectId(),\n                \"additionalProperties\", eventToken.additionalProperties());\n        try {\n            producer.send(json.writeValueAsString(payload));\n        } catch (JsonProcessingException e) {\n            throw new RuntimeException(\"could not serialise SSF event \" + eventToken.jti(), e);\n        }\n    }\n}\n```\n\n```properties\nquarkus.openid-ssf.receiver.transmitter-issuer=https://transmitter.example\nquarkus.openid-ssf.receiver.stream-management=RECEIVER\nquarkus.openid-ssf.receiver.delivery-method=POLL\nquarkus.openid-ssf.receiver.alias=audit-hub-prod-eu\nquarkus.openid-ssf.receiver.poll.interval=5s\nquarkus.openid-ssf.receiver.poll.return-immediately=false   # long-poll for low latency\nquarkus.openid-ssf.receiver.events-requested=\\\n    https://schemas.openid.net/secevent/caep/event-type/session-revoked,\\\n    https://schemas.openid.net/secevent/caep/event-type/credential-change,\\\n    https://schemas.openid.net/secevent/caep/event-type/assurance-level-change\n```\n\nPlus a **persistent ack store** so unacked events survive receiver restarts:\n\n```java\n@ApplicationScoped\npublic class JdbcSsfPollAckStore implements SsfPollAckStore {\n    @Inject DataSource ds;\n    // implementation that INSERTs jtis on enqueue,\n    // SELECTs+DELETEs FROM ssf_pending_acks on drain,\n    // re-INSERTs on requeue.\n    // …\n}\n```\n\n**Key points:**\n- RECEIVER-managed: forwarder creates its own subscription on each transmitter.\n- POLL delivery: forwarder can sit anywhere reachable to the transmitter (no inbound HTTP needed).\n- `quarkus.openid-ssf.receiver.alias` distinguishes multiple forwarder instances scraped into the same monitoring store (`receiver` tag on `ssf.receiver.events.processed`).\n- Custom `SsfPollAckStore` (`@ApplicationScoped`, no `@DefaultBean`) replaces the in-memory deque; the extension picks it up automatically.\n- Forwarder MUST be idempotent on the Kafka / SIEM side — the receiver's at-least-once semantics propagate through.\n\n### 3 · Continuous authorization / step-up trigger\n\n\u003e \"When the IdP signals an assurance-level change or device-compliance fail,\n\u003e I need to mark the user's tokens as needing re-auth before the next\n\u003e request — not at the end of the access-token lifetime.\"\n\n```java\n@ApplicationScoped\npublic class StepUpTrigger implements SsfEventHandler {\n\n    @Inject SubjectFlags flags;\n\n    @Override\n    public void handle(SsfEventContext eventContext) {\n        if (eventContext.hasEvent(\"CaepAssuranceLevelChange\")\n                || eventContext.hasEvent(\"CaepDeviceComplianceChange\")) {\n            String sub = (String) eventContext.eventToken().subjectId().get(\"sub\");\n            if (sub != null) {\n                flags.markRequiresStepUp(sub, Duration.ofMinutes(15));\n            }\n        }\n    }\n}\n```\n\n```properties\n# Same minimum config as pattern 1, plus:\nquarkus.openid-ssf.receiver.alias=auth-edge\n\n# Tighter poll cadence if PUSH isn't an option:\nquarkus.openid-ssf.receiver.delivery-method=POLL\nquarkus.openid-ssf.receiver.poll.interval=2s\nquarkus.openid-ssf.receiver.poll.return-immediately=false\n```\n\n**Key points:**\n- Latency-sensitive — PUSH preferred, long-poll second choice.\n- `SubjectFlags` is the application's own state store (Redis / Caffeine); the SSF extension stays out of authorization logic.\n- The `quarkus-oidc` resource-server filter then checks `flags.requiresStepUp(sub)` on each request and 401s if true. SSF and OIDC compose cleanly — neither knows about the other.\n\n## Compatibility\n\n| | Tested | Floor | Notes |\n|---|---|---|---|\n| **Quarkus** | 3.35.x | 3.35.0 | Earlier versions may work but aren't tested. CI matrix in `.github/workflows/build.yml` is the source of truth — extend the `quarkus:` axis to add an LTS pin. |\n| **Java (extension)** | 21, 25 | 21 | The runtime + deployment artifacts compile under `--release 21`. Consumers may run on any 21+. |\n| **Java (examples)** | 21 | 21 | Examples inherit the same Java 21 floor so they're copy-pasteable for consumers. |\n| **Native image** | GraalVM 21 (Mandrel-equivalent) | — | The extension compiles native; the built-in CI workflow validates this on every PR for both example apps. |\n\n## Stream management\n\nIn both modes, the extension fetches and logs the configured stream's\nconfiguration + status at startup so the operator gets an immediate\nconfirmation:\n\n```\nINFO  Transmitter-managed mode: using stream stream_id=… (status=enabled, delivery-method=PUSH, push_endpoint=…, events_delivered=[CaepSessionRevoked, …])\nINFO  Receiver-managed mode: created stream stream_id=… (status=enabled, delivery-method=POLL, poll_endpoint=…, events_delivered=[…])\n```\n\nDisable per mode if outbound credentials aren't available:\n```properties\nquarkus.openid-ssf.receiver.transmitter-managed.probe-on-startup=false\nquarkus.openid-ssf.receiver.receiver-managed.register-stream=false\n```\n\n### Startup resilience (RECEIVER mode)\n\nIn RECEIVER mode, discover-or-create runs on a **background virtual thread**\nwith exponential backoff (1s → 2s → 4s → 8s → 16s → 30s, capped). The JVM\nnever blocks on transmitter availability:\n\n- The push route is registered immediately, so inbound SETs work as soon as\n  the transmitter starts pushing — even if registration is still in flight.\n- Each failed attempt logs a one-line WARN; full stack at DEBUG.\n- The Dev UI's \"Stream Management\" page shows\n  *\"Stream registration in progress…\"* until the background loop succeeds,\n  then auto-loads the rest.\n- The retry loop exits cleanly on `ShutdownEvent`.\n\nThe pinned-`stream-id` path stays synchronous (cheap; failures already\ntolerated as best-effort warnings).\n\n## jti deduplication\n\nInbound SETs run through `SsfJtiDedupStore.seenBefore(event)` after\nverification but before the application's `SsfEventHandler` is invoked, on\nboth PUSH and POLL paths. Duplicates are silently dropped (PUSH still 202s,\nPOLL still acks the jti — the SET *was* received and processed; we just\ndon't redeliver to the handler).\n\n```properties\n# Enabled by default. Disable if your handler is naturally idempotent and\n# the extra lookup is wasted work.\nquarkus.openid-ssf.receiver.dedup.enabled=true\nquarkus.openid-ssf.receiver.dedup.capacity=10000\n```\n\nThe default `InMemorySsfJtiDedupStore` is a bounded `LinkedHashMap` with\nFIFO eviction — at-least-once across receiver restarts. For exactly-once\nacross restarts, ship your own `@ApplicationScoped SsfJtiDedupStore`\nbacked by a database / Redis / etc. (no `@DefaultBean`); the extension\nswaps it in automatically.\n\nThe dedup key is **`iss::jti`**, not just `jti` — RFC 8417 §2.2 scopes\n`jti` uniqueness to the issuer, so a receiver consuming from multiple\ntransmitters that mint colliding jti values stays correct.\n\nMetrics:\n- `ssf.receiver.dedup.skipped` counter, tagged by `delivery` ∈ {`push`, `poll`}.\n- `ssf.receiver.dedup.store.size` gauge.\n\n## Delivery: PUSH\n\nPush endpoint defaults to `POST /ssf/push` (relative to `quarkus.http.root-path`);\noverride via `quarkus.openid-ssf.receiver.push.endpoint-path`.\n\n| Condition | Response |\n|---|---|\n| `expected-auth-header` configured and inbound `Authorization` mismatches | `401` |\n| JWT parse, signature, or claim validation fails (missing `iss`/`iat`/`jti`, `iss != transmitter-issuer`, `aud` does not contain `expected-audience` when configured) | `400` |\n| Verified | `202` — SET is dispatched to the handler asynchronously on a virtual thread; handler exceptions are logged but never become 5xx |\n\nJWKS is fetched lazily (Nimbus `JWKSource`), cached for 5 min, refreshed once\non `kid` miss before failing.\n\n## Delivery: POLL (RFC 8936)\n\n```properties\nquarkus.openid-ssf.receiver.delivery-method=POLL\n\n# Optional — defaults shown.\nquarkus.openid-ssf.receiver.poll.interval=30s\nquarkus.openid-ssf.receiver.poll.start-delay=0s             # delay before the first poll\nquarkus.openid-ssf.receiver.poll.auto-start=true            # false → drive polling manually\nquarkus.openid-ssf.receiver.poll.max-events=100\nquarkus.openid-ssf.receiver.poll.return-immediately=true    # false → long-poll\nquarkus.openid-ssf.receiver.poll.drain-on-poll=true         # if moreAvailable=true, keep polling\nquarkus.openid-ssf.receiver.poll.timeout=30s\n# Override only if the stream's delivery.endpoint_url isn't queryable:\n#quarkus.openid-ssf.receiver.poll.endpoint-url=https://transmitter.example/ssf/poll/\u003cstream\u003e\n```\n\nThe poll endpoint URL is normally **discovered** from the stream's\n`delivery.endpoint_url` (returned by the configuration endpoint). For\nreceiver-managed streams the transmitter assigns it during `createStream`.\n\n### Acknowledgments\n\nAfter a SET has been verified and the handler returns without throwing, its\n`jti` is enqueued via `SsfPollAckStore` and sent in the next poll's `ack`\narray (RFC 8936 §2.1).\n\nThe default `InMemorySsfPollAckStore` is a concurrent deque that's lost on\nrestart — at-least-once delivery, since unacked SETs will be redelivered. For\nexactly-once durability, provide your own `@ApplicationScoped` `SsfPollAckStore`\n(no `@DefaultBean`) backed by a database, Redis, the filesystem, …; the\nextension swaps it in automatically.\n\n### Manual polling\n\n`quarkus.openid-ssf.receiver.poll.auto-start=false` keeps the poller idle. Inject `SsfPoller`\nand drive it from app code:\n\n```java\n@Inject SsfPoller poller;\n// from a REST endpoint, scheduled job, message handler, …:\npoller.pollNow();\n```\n\n## Outbound auth to the transmitter\n\nFour-way selection, made at **build time**. The first match wins:\n\n| Configured | Provider | Notes |\n|---|---|---|\n| `quarkus.openid-ssf.receiver.transmitter-access-token` set | `StaticTransmitterTokenProvider` | Sends the literal value as `Authorization: Bearer …`. For transmitters like caep.dev that issue long-lived tokens out-of-band. |\n| `quarkus.openid-ssf.receiver.oauth2.token-endpoint` set | `Oauth2TransmitterTokenProvider` | Self-contained `client_credentials` grant — no `quarkus-oidc-client` dependency. Caches the token in-process with expiry-safety-window-aware refresh and concurrent-refresh coalescing. Supports `client_secret_basic` (default, RFC 6749 §2.3.1) and `client_secret_post` via `quarkus.openid-ssf.receiver.oauth2.client-auth-method`. |\n| `quarkus-oidc-client` on the classpath | `OidcTransmitterTokenProvider` | Fetches a token via `quarkus.oidc-client.*` (typically `client_credentials`). Useful when the consumer already wires OIDC for inbound auth and wants to share the client config. |\n| Neither | `NoopTransmitterTokenProvider` | No `Authorization` header. Fine for purely local stubs / public metadata. |\n\nThe deployment processor reads the activating properties under the active\nprofile and logs which provider it registered. Dev mode rebuilds on profile\nchange so this is seamless; for a packaged jar, repackage with the desired\nprofile.\n\n```properties\n# Self-contained OAuth2 example — no quarkus-oidc-client needed.\nquarkus.openid-ssf.receiver.oauth2.token-endpoint=https://auth.example/oauth/token\nquarkus.openid-ssf.receiver.oauth2.client-id=my-client\nquarkus.openid-ssf.receiver.oauth2.client-secret=${OAUTH2_CLIENT_SECRET}\n# Optional defaults shown:\n#quarkus.openid-ssf.receiver.oauth2.grant-type=client_credentials\n#quarkus.openid-ssf.receiver.oauth2.client-auth-method=basic       # or 'post'\n#quarkus.openid-ssf.receiver.oauth2.scopes=ssf.read,ssf.manage\n#quarkus.openid-ssf.receiver.oauth2.expiry-safety-window=30s\n#quarkus.openid-ssf.receiver.oauth2.timeout=5s\n```\n\nJWKS, metadata, and the configuration endpoint (when called for stream\ndiscovery) are unauthenticated by SSF/OIDC convention — the extension does\n**not** attach an `Authorization` header on those calls.\n\n## Metrics (optional)\n\nAdd `quarkus-micrometer-registry-prometheus` (or another registry extension)\nand the extension publishes the following meters under `quarkus.openid-ssf.receiver.*`. Without\nit, a no-op SPI is in effect — zero behavior change.\n\n| Meter | Type | Tags |\n|---|---|---|\n| `ssf.receiver.push.accepted` | counter | — |\n| `ssf.receiver.push.rejected` | counter | `reason` ∈ {`auth`, `body`, `verify`} |\n| `ssf.receiver.push.handler.errors` | counter | — |\n| `ssf.receiver.poll.cycles` | timer | `outcome` ∈ {`success`, `failure`} |\n| `ssf.receiver.poll.events.received` | counter | — |\n| `ssf.receiver.poll.events.handled` | counter | — |\n| `ssf.receiver.poll.events.failed` | counter | `reason` ∈ {`verify`, `handler`} |\n| `ssf.receiver.poll.acks.sent` | counter | — |\n| `ssf.receiver.poll.ack.queue.depth` | gauge | — |\n| `ssf.receiver.events.processed` | counter | `event`, `iss`, `receiver`, `delivery` ∈ {`push`, `poll`} |\n| `ssf.receiver.dedup.skipped` | counter | `delivery` ∈ {`push`, `poll`} |\n| `ssf.receiver.dedup.store.size` | gauge | — |\n\n`events.processed` is the per-event-type, per-transmitter, per-delivery-mode\ncounter — the tags run through `SsfAliases` so URIs become readable names.\n\nScrape at `/q/metrics`.\n\n## Aliases (`SsfAliases`)\n\n`SsfAliases` keeps configuration and observability readable: instead of repeating\n80-character event-type URIs across config, log lines, and Micrometer tags,\nthe extension swaps in a short alias name. It's a regular CDI bean, so\napplication code can `@Inject` it for the same naming outside the extension's\nown usage.\n\nThree independent alias domains:\n\n```properties\n# 1. Event-type URI → short alias. Built-ins cover SSF + CAEP + RISC out of\n#    the box (see table below); the lines here are only for vendor-specific\n#    URIs the spec doesn't cover.\nquarkus.openid-ssf.receiver.event-aliases.VendorWidgetReplaced=https://schemas.example.org/vendor/event-type/widget-replaced\n\n# 2. Transmitter issuer URL → short alias. No built-ins.\nquarkus.openid-ssf.receiver.issuer-aliases.CaepDev=https://ssf.caep.dev\nquarkus.openid-ssf.receiver.issuer-aliases.MyTransmitter=https://transmitter.example\n\n# 3. This receiver's own short name — surfaces as the `receiver` tag on\n#    Micrometer meters. Falls back to expected-audience, then \"unknown\".\nquarkus.openid-ssf.receiver.alias=my-receiver\n```\n\n### Where aliases show up\n\n| Surface | Behavior |\n|---|---|\n| **Handler code** (`SsfEventContext`) | `eventContext.hasEvent(\"CaepSessionRevoked\")` and `eventContext.eventFor(...)` accept either an alias or a full URI. `eventContext.issAlias()` and `eventContext.eventsByAlias()` expose the resolved view directly. |\n| **Config** (`quarkus.openid-ssf.receiver.events-requested`) | Each entry can be a URI or an alias. Unknown aliases fail fast at startup with a list of registered names. |\n| **Metrics** (`ssf.receiver.events.processed`) | The `event`, `iss`, and `receiver` tags are alias values, so Prometheus / Grafana don't show URIs. |\n| **Logs** | `LoggingSsfEventHandler` and the registrar / probe startup lines emit alias-resolved values. |\n\n### Built-in event-type aliases\n\nCovered out of the box — no config needed.\n\n| Spec                                                                       | URI suffix (under `…/secevent/\u003cspec\u003e/event-type/`) | Aliases |\n|----------------------------------------------------------------------------|---|---|\n| **OpenID SSF 1.0 (Stream Events)**                                         | `verification`, `stream-updated` | `SsfStreamVerification`, `SsfStreamUpdated` |\n| [**OpenID CAEP 1.0**](https://openid.net/specs/openid-caep-1_0-final.html) | `session-revoked`, `token-claims-change`, `credential-change`, `assurance-level-change`, `device-compliance-change`, `session-established`, `session-presented`, `risk-level-change` | `CaepSessionRevoked`, `CaepTokenClaimsChange`, `CaepCredentialChange`, `CaepAssuranceLevelChange`, `CaepDeviceComplianceChange`, `CaepSessionEstablished`, `CaepSessionPresented`, `CaepRiskLevelChange` |\n| [**OpenID RISC 1.0**](https://openid.net/specs/openid-risc-1_0-final.html) | `account-credential-change-required`, `account-purged`, `account-disabled`, `account-enabled`, `identifier-changed`, `identifier-recycled`, `credential-compromise`, `opt-in`, `opt-out-initiated`, `opt-out-cancelled`, `opt-out-effective`, `recovery-activated`, `recovery-information-changed` | `RiscAccountCredentialChangeRequired`, `RiscAccountPurged`, `RiscAccountDisabled`, `RiscAccountEnabled`, `RiscIdentifierChanged`, `RiscIdentifierRecycled`, `RiscCredentialCompromise`, `RiscOptIn`, `RiscOptOutInitiated`, `RiscOptOutCancelled`, `RiscOptOutEffective`, `RiscRecoveryActivated`, `RiscRecoveryInformationChanged` |\n\nConsumer entries in `quarkus.openid-ssf.receiver.event-aliases.*` **overlay** the built-ins —\na user mapping for a built-in URI replaces the alias name (the URI itself\nstays the same).\n\n### Bean API\n\n```java\n@Inject SsfAliases aliases;\n\naliases.eventTypeAlias(uri);    // URI  → alias (or URI passthrough if unknown)\naliases.issuerAlias(issUrl);    // URL  → alias (or URL passthrough if unknown)\naliases.receiverAlias();        // this receiver's short name; never empty\n\naliases.resolveEventTypeRef(\"CaepSessionRevoked\"); // alias  → URI\naliases.resolveEventTypeRef(\"https://…\");          // URI    → URI (passthrough)\n// Unknown alias → IllegalArgumentException listing the registered set.\n\naliases.eventTypeAliasesByUri();  // read-only view (Dev UI uses this)\naliases.issuerAliasesByUri();\n```\n\nUnknown URIs fall through unchanged from the lookup methods — nothing is\never lost. Only `resolveEventTypeRef` throws, since its caller (the config\nvalidator) needs to surface typos eagerly.\n\n## Disable switch\n\n```properties\nquarkus.openid-ssf.receiver.enabled=false\n```\n\nWhen `false`, every startup observer (validator, push route, probe, registrar,\nPOLL scheduler) becomes a no-op. `SsfStreamClient`, metrics, and `SsfAliases`\nstay wired in CDI — application code that touches them directly still works,\nbut nothing happens automatically. Useful for `%test`, `%dev`-without-transmitter,\nor runtime kill-switching via env var.\n\n## Build\n\n```sh\nmvn -DskipTests install                                                # build + install all artifacts\nmvn -pl receiver/runtime,receiver/deployment test                      # full layer-1 + layer-2 test suite\n\n# Run the examples — see each example's README for the env vars they need.\nmvn -pl receiver/examples/example-transmitter-managed-stream quarkus:dev\nmvn -pl receiver/examples/example-receiver-managed-stream    quarkus:dev\nmvn -pl receiver/examples/example-receiver-managed-stream    quarkus:dev -Dquarkus.profile=caepdev\nmvn -pl receiver/examples/example-receiver-managed-stream    quarkus:dev -Dquarkus.profile=keycloak\n```\n\n## Out of scope (today)\n\n- Built-in persistence — receiver-managed stream id is re-discovered on\n  restart, the dedup window and POLL ack queue are in-memory by default.\n  Both have SPIs (`SsfJtiDedupStore`, `SsfPollAckStore`) that consumers\n  can implement against durable storage.\n- Per-event-type CAEP / RISC parsing — `SsfEventToken` exposes the raw\n  `events` map plus `additionalProperties` for forward-compat claims;\n  consumers parse what they care about.\n- Multi-transmitter receivers — the extension currently subscribes to a\n  single transmitter per app. Multi-transmitter consumers run multiple\n  receiver instances with distinct `quarkus.openid-ssf.receiver.alias` values.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fquarkiverse%2Fquarkus-openid-ssf","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fquarkiverse%2Fquarkus-openid-ssf","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fquarkiverse%2Fquarkus-openid-ssf/lists"}