Authentication & authorization
Status: Provider-agnostic. Tickets #359 (JWT enforcement), #370/#376 (provider-agnostic configuration), and #372 (rename interceptor + docs to make the OIDC genericity explicit). This document defines the enforcement layer inside HAPI itself (JWT validation + SMART scope mapping to HAPI auth rules) and points at IdP-specific recipes for the identity side.
Overview
subscription-service authenticates FHIR API callers via OIDC bearer tokens.
Any OpenID Connect provider that exposes a JWKS endpoint works — including
Keycloak, Auth0, Okta, Azure AD, AWS Cognito, Authentik, etc. The HAPI
interceptor (OidcJwtAuthenticationInterceptor) consumes only the standard
OIDC artifacts (issuer URL, JWKS, the scope claim); it has no
provider-specific code paths.
Pick whichever IdP you already operate. The realm/tenant provisioning is
on you (this repo ships a turn-key Keycloak realm export under
idp/keycloak/ because that’s the IdP the maintainer runs locally), but
the FHIR API itself doesn’t care which IdP signed the token — it just
validates the JWS against the JWKS at the configured issuer and reads the
scope claim.
Everything below uses placeholder hostnames (your-idp.example.com,
your-subscription-service.example.com). Substitute your own. The
reference deployment that this project’s maintainer runs is documented in
a single callout at the bottom of this page.
Configuration
The HAPI auth layer is configured by two environment variables (Spring Boot
relaxed binding maps SUBSCRIPTION_SERVICE_AUTH_* env vars onto the
subscription-service.auth.* property tree):
| Property | Env var | Default |
|---|---|---|
subscription-service.auth.enabled | SUBSCRIPTION_SERVICE_AUTH_ENABLED | true |
subscription-service.auth.issuer | SUBSCRIPTION_SERVICE_AUTH_ISSUER | none — required when auth is enabled (ticket #370). Container fails fast at startup. |
subscription-service.auth.jwks-url | SUBSCRIPTION_SERVICE_AUTH_JWKS_URL | derived from issuer: ${issuer}/protocol/openid-connect/certs (Keycloak shape). |
subscription-service.auth.allow-anonymous-paths | (yaml list only) | [/metadata, /.well-known/smart-configuration] |
For IdPs whose JWKS URL isn’t ${issuer}/protocol/openid-connect/certs
(everything except Keycloak), set SUBSCRIPTION_SERVICE_AUTH_JWKS_URL
explicitly to the value advertised under the jwks_uri key in the
provider’s .well-known/openid-configuration document. The recipes below
spell that out per IdP.
When enabled=true and issuer is empty, the Spring context refresh
fails fast with:
subscription-service.auth.issuer is required when auth is enabled.Set SUBSCRIPTION_SERVICE_AUTH_ISSUER to your OIDC provider's issuer URL(e.g., https://your-idp.example.com/realms/<realm> for Keycloak,https://<tenant>.us.auth0.com/ for Auth0,https://<org>.okta.com/oauth2/default for Okta) or setSUBSCRIPTION_SERVICE_AUTH_ENABLED=false for local dev.The HAPI container exits non-zero and restarts in a loop — the
docker-compose default for restart: is unless-stopped, so look for the
message in docker logs subscription-service-hapi.
Disabling for local development
Set SUBSCRIPTION_SERVICE_AUTH_ENABLED=false in .env. The whole
auto-configuration is gated by @ConditionalOnProperty, so disabling it
makes HAPI behave exactly like the upstream image — useful when running
the docker-compose stack without any IdP available.
# In deploy/docker/.envSUBSCRIPTION_SERVICE_AUTH_ENABLED=falseProvider recipes
Each recipe gives the operator the 3–5 lines of config needed to point subscription-service at the named IdP. The HAPI side is the same for all of them; only the env-var values change.
Verify any recipe by GETing the IdP’s discovery document and confirming
that the issuer and jwks_uri values match what you’re setting:
curl -sS https://your-idp.example.com/path-to-discovery/.well-known/openid-configuration \ | jq '{issuer, jwks_uri, token_endpoint}'The issuer value the IdP advertises is exactly what
SUBSCRIPTION_SERVICE_AUTH_ISSUER must equal (string compare, no trailing
slash gymnastics — Nimbus is strict).
Keycloak (modern, 17+)
Quarkus-based Keycloak, released April 2022, dropped the /auth/ path
prefix that all earlier releases had. Most new Keycloak deployments are on
this shape.
SUBSCRIPTION_SERVICE_AUTH_ENABLED=trueSUBSCRIPTION_SERVICE_AUTH_ISSUER=https://your-keycloak.example.com/realms/subscription-service# JWKS URL is derived automatically:# https://your-keycloak.example.com/realms/subscription-service/protocol/openid-connect/certsRealm provisioning: turn-key script at scripts/idp/keycloak/provision-realm.sh
plus the JSON realm export at idp/keycloak/realms/subscription-service.json.
See docs/idp/keycloak.md for the full setup walkthrough.
Keycloak (legacy WildFly, <17)
Keycloak releases before 17 (the WildFly-based distribution) mount under
/auth/. The realm shape is identical; only the URL prefix differs.
SUBSCRIPTION_SERVICE_AUTH_ENABLED=trueSUBSCRIPTION_SERVICE_AUTH_ISSUER=https://your-keycloak.example.com/auth/realms/subscription-service# JWKS URL is derived automatically:# https://your-keycloak.example.com/auth/realms/subscription-service/protocol/openid-connect/certsThe provisioning script accepts KEYCLOAK_PATH_PREFIX=/auth for this
case — see docs/idp/keycloak.md.
Auth0
Auth0 issuers always end in a trailing slash (a long-standing Auth0
quirk). Match it exactly. The JWKS path is .well-known/jwks.json, not
protocol/openid-connect/certs, so you must set it explicitly:
SUBSCRIPTION_SERVICE_AUTH_ENABLED=trueSUBSCRIPTION_SERVICE_AUTH_ISSUER=https://your-tenant.us.auth0.com/SUBSCRIPTION_SERVICE_AUTH_JWKS_URL=https://your-tenant.us.auth0.com/.well-known/jwks.jsonProvisioning (out of band): in the Auth0 dashboard, create an “API” with
an identifier (audience) of your choice and “Machine to Machine”
applications with the system/Subscription.crus etc. scopes added as
permissions on that API. Auth0 puts custom scopes in the scope claim of
the issued access token, which is exactly what
OidcJwtAuthenticationInterceptor reads.
Okta
Okta defaults to a “default” authorization server under the path
/oauth2/default. Custom auth servers are at /oauth2/<server-id>. The
JWKS endpoint is under the auth server, not the org root:
SUBSCRIPTION_SERVICE_AUTH_ENABLED=trueSUBSCRIPTION_SERVICE_AUTH_ISSUER=https://your-org.okta.com/oauth2/defaultSUBSCRIPTION_SERVICE_AUTH_JWKS_URL=https://your-org.okta.com/oauth2/default/v1/keysProvisioning (out of band): in the Okta admin console, add the SMART
scope catalog (system/Subscription.crus etc.) to the authorization
server’s scopes, create a service-account-style “API Services
Integration” app, and grant it those scopes. Okta surfaces them in the
scope claim on client_credentials access tokens.
Authentik
Authentik exposes each OAuth2/OIDC provider under
/application/o/<app-slug>/. The issuer is the application’s base URL;
the JWKS endpoint is under the same path:
SUBSCRIPTION_SERVICE_AUTH_ENABLED=trueSUBSCRIPTION_SERVICE_AUTH_ISSUER=https://your-authentik.example.com/application/o/subscription-service/SUBSCRIPTION_SERVICE_AUTH_JWKS_URL=https://your-authentik.example.com/application/o/subscription-service/jwks/Provisioning (out of band): create an OAuth2/OIDC provider in Authentik
named e.g. subscription-service, set the application slug to match the
issuer URL above, add the SMART scopes (system/Subscription.crus,
system/Patient.r, etc.) as scope mappings, then bind one or more
service-account applications to the provider.
Other IdPs
Azure AD, AWS Cognito, Google Identity, Ping Identity, ForgeRock, and any
other OIDC-conformant provider follow the same shape. Fetch the
discovery document at
https://<your-idp>/<path-to-issuer>/.well-known/openid-configuration,
copy the issuer value into SUBSCRIPTION_SERVICE_AUTH_ISSUER, and the
jwks_uri value into SUBSCRIPTION_SERVICE_AUTH_JWKS_URL. The HAPI side
does not need to know which IdP is on the other end.
Operator UI (ticket #423)
The operator UI (ui/) is a separate Next.js application that uses
NextAuth v5 to authenticate users via the same OIDC layer. It is NOT
the same as the HAPI auth interceptor above — the UI’s OIDC env vars
are independent.
The UI reads four env vars at request time (NOT build time — see the note at the bottom of this section about why):
| Env var | Purpose |
|---|---|
OIDC_ISSUER | The IdP’s issuer URL (same form as the HAPI setting). |
OIDC_CLIENT_ID | The OAuth client ID registered for the UI. |
OIDC_CLIENT_SECRET | The OAuth client secret. Keep out of source control. |
NEXTAUTH_URL | The public URL the browser sees (e.g. https://...). |
AUTH_SECRET | 32-byte random secret for cookie encryption (generate via openssl rand -hex 32). |
Keycloak client recipe
Create a confidential client in your Keycloak realm with these properties:
| Field | Value |
|---|---|
| Client ID | subscription-service-ui |
| Access Type | confidential |
| Standard Flow Enabled | ON (Authorization Code + PKCE + state) |
| Valid Redirect URIs | http://localhost:3000/api/auth/callback/oidc (local dev) https://<your-public-host>/api/auth/callback/oidc (public) |
| Web Origins | http://localhost:3000 and your public origin |
After creating the client, copy the generated client secret into
OIDC_CLIENT_SECRET. Rotate by clicking “Regenerate Secret” in the
Keycloak admin UI (or via kcadm.sh create clients/$ID/client-secret).
Build-time vs request-time env (the bug fixed in #423)
Next.js’s standalone production build inlines module-level
process.env.X reads at BUILD time. That means a generic UI image
built in CI (without OIDC_* set, which is the only sane way to
build it) would bake in empty strings for the OIDC env, and the
runtime container’s env would be ignored.
The UI works around this in two layers:
ui/src/lib/oidc-env.tsexposesisOidcConfigured()andreadOidcEnv()as FUNCTIONS that readprocess.envon every call (Next.js does not inline env reads inside function bodies).- NextAuth is configured with the v5 lazy-function form:
NextAuth(() => buildConfig()). The provider list is rebuilt per request from the current env. - Pages that branch on
isOidcConfigured()(/,/signin, all protected routes) declareexport const dynamic = "force-dynamic"so Next.js skips static prerendering and always runs the page on every request.
Regression-tested in
ui/src/__tests__/auth-runtime-env.test.ts. Do NOT cache the result
of readOidcEnv() at module scope — that would re-introduce the bug.
Scope catalog
Scopes follow the SMART on FHIR system/<Resource>.<crud-flags> naming
convention. They are the same regardless of which IdP issues the token;
each IdP just needs to be configured to put them in the scope claim.
| Scope | What it grants |
|---|---|
system/Subscription.crus | Create, Read, Update, Search FHIR Subscription resources. The base scope an external system needs to register webhook subscriptions. |
system/Subscription.r | Read-only access to Subscription resources (for monitoring/audit clients that should not create or modify subscriptions). |
system/Patient.r | Read FHIR Patient resources. Required for any subscriber that reads patient context after a notification fires. |
system/Patient.cruds | Full lifecycle (Create, Read, Update, Delete, Search) of Patient. Used by trusted ingestion-side services, not typical external subscribers. |
system/Observation.r | Read FHIR Observation resources. Used by subscribers that consume lab/vitals data delivered through subscription notifications. |
New clients onboarded for external systems should follow least-privilege: assign only the scopes the integration actually needs.
Obtaining a token (client_credentials)
Every OIDC IdP supports the OAuth2 client_credentials grant for M2M
flows. The exact endpoint is in the IdP’s discovery document under
token_endpoint. Generic shape:
TOKEN=$(curl -sS -X POST \ "${TOKEN_ENDPOINT}" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'grant_type=client_credentials' \ -d "client_id=${CLIENT_ID}" \ -d "client_secret=${CLIENT_SECRET}" \ -d 'scope=system/Subscription.crus system/Patient.r' \ | jq -r .access_token)
curl -sS -H "Authorization: Bearer ${TOKEN}" \ https://your-subscription-service.example.com/fhir/metadataAuth0 requires an additional
audienceparameter on the token request (the identifier of the API you registered). Okta and Keycloak do not. See your IdP’s docs for grant-specific knobs.
You can inspect the token at https://jwt.io or with jq -R 'split(".")[1] | @base64d | fromjson' to see the iss, azp, scope, and exp claims.
Onboarding an external system
External integrators do not self-register. The operator process is:
- Integrator opens an onboarding ticket describing:
- The integrating system (name, owner, contact for security incidents).
- The intended use (which FHIR resources will they read/write, do they need to register Subscriptions, what callback URL will they use).
- Their expected request volume and traffic pattern.
- Operator reviews the request and chooses the minimum scope set required.
- Operator creates a new confidential / M2M client in the configured
IdP:
- Client ID:
subscription-service-<integrator-slug> - Client authenticator: client_id + secret (or mTLS, or private_key_jwt if the IdP supports it and the integrator can hold a key).
- Service accounts / client_credentials grant: enabled.
- Authorization-code / interactive flows: disabled (M2M only).
- Scopes: the approved set from step 2.
- Client ID:
- Operator regenerates the client secret and shares the
client_id+client_secret+ the assigned scopes with the integrator out-of-band (encrypted email, password manager share, etc. — never in a ticket comment or chat). - Operator records the onboarding in the integrator inventory (location TBD — for now, keep an entry in the onboarding ticket).
Rotation: client secrets are rotated annually, or immediately if a compromise is suspected. The integrator is given 14 days notice of a planned rotation and a new secret is provisioned in parallel before the old one is revoked.
Webhook callbacks (subscriber side)
The IdP we trust only controls inbound access to our FHIR API. When a
Subscription fires and we POST to the subscriber’s REST-hook endpoint, we
include the Subscription.header values verbatim. The subscriber is
responsible for verifying that header (usually a bearer token they generated
and registered with the Subscription resource at creation time). See
“Subscription channel security” in docs/architecture.md for the policy
controlling what we require on the subscriber side.
How the FHIR API enforces tokens (ticket #359)
HAPI itself doesn’t know anything about the IdP. The enforcement layer is
a small Spring Boot auto-configuration JAR built from hapi/auth/ and
layered onto the upstream HAPI image (see hapi/Dockerfile). At runtime
the auto-configuration registers two HAPI server interceptors:
-
OidcJwtAuthenticationInterceptor—@Hook(SERVER_INCOMING_REQUEST_POST_PROCESSED). For every request:- If the path is on the anonymous allow-list (
/metadata,/.well-known/smart-configurationby default), pass through. - Otherwise, require an
Authorization: Bearer <jwt>header. - Parse and verify the JWS signature against the IdP’s JWKS using Nimbus JOSE+JWT (already shipped inside the HAPI image — no new transitive deps). Only RS256 / RS384 / RS512 are accepted; HS* (“none” + symmetric) tokens are refused.
- Verify
issmatches the configured issuer; verifyexpis in the future andnbf(if present) is in the past. - On success, stash the verified
JWTClaimsSetand the parsedSet<SmartScope>onRequestDetails.userDataso downstream interceptors can read them without re-parsing. - On any failure, throw
AuthenticationException→ HTTP 401 +OperationOutcome. The message describes the failure (Token rejected: Expired JWTetc.) but never leaks the token contents.
- If the path is on the anonymous allow-list (
-
ScopeAuthorizationInterceptor— extends HAPI’sAuthorizationInterceptorwith default policyDENY.buildRuleListreads the stashed scopes and produces a HAPIIAuthRulelist. The catalog above maps to rules as follows:SMART scope HAPI rules produced system/Subscription.cruscreate,read,write(update),searchonSubscriptionsystem/Subscription.rread,searchonSubscriptionsystem/Patient.rread,searchonPatientsystem/Patient.crudscreate,read,write(update),delete,searchonPatientsystem/Observation.rread,searchonObservationPlus an always-allow rule for
/metadataand a terminating deny-all (“operation not permitted by SMART scopes”) that turns any unrecognized request into a 403.
Source layout
hapi/├── Dockerfile ← multi-stage; builds auth JAR + layers it onto upstream├── auth/ ← Maven project; produces the JAR│ ├── pom.xml│ └── src/│ ├── main/java/com/bzonfhir/subscriptionservice/auth/│ │ ├── AuthAutoConfiguration.java ← Spring Boot @AutoConfiguration entry│ │ ├── AuthProperties.java ← @ConfigurationProperties bound from yaml/env│ │ ├── JwtValidator.java ← Nimbus-backed JWT validation│ │ ├── OidcJwtAuthenticationInterceptor.java│ │ ├── ScopeAuthorizationInterceptor.java│ │ └── SmartScope.java ← SMART scope parser│ ├── main/resources/META-INF/spring/│ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports│ └── test/... ← JUnit 5 tests; mock JWKS via WiremockWhat ticket #359 does NOT do (deferred)
- Multi-tenancy partition mapping — HAPI’s partition context is set
from the
tenantclaim, but tenant claim → partition wiring is its own ticket (#369, merged). - Audit logging of authentication failures beyond Spring INFO logs.
- SMART user/ and patient/ scopes — only
system/is recognized in v1 (matches the realm catalog above). - JWT introspection as a fallback for opaque tokens — every modern IdP issues JWTs natively so no introspection round-trip is needed.
Legacy WildFly-based Keycloak
If you point this service at a legacy WildFly-based Keycloak distribution (versions before the Quarkus rewrite), set
KEYCLOAK_PATH_PREFIX=/authwhen running the provisioning script — older Keycloak serves under/authby default while the modern distribution serves at root. The rest of this document is provider-agnostic and works with any OIDC IdP that exposes JWKS.