Skip to main content

Choosing the authentication server

The platform authenticates in one of two modes, selected by a single setting.

uhp:
auth:
provider: local # or: oidc

Environment variable: UHP_AUTH_PROVIDER. Helm: env.UHP_AUTH_PROVIDER.

valuewho mints tokenswho verifies them
local (default)this platform, ES256, JWKS published at /.well-known/jwks.jsonthis platform
oidcan external OAuth2/OIDC authorization serverthis platform, as a resource server

⚠⚠ local is the default, and that is deliberate

Only local implements the full set of account controls end to end:

controllocaloidc
MFA (TOTP) and step-up❌ AUTH-002
account lockout❌ AUTH-002
authentication rate limiting❌ AUTH-002
session tenant/facility scope❌ AUTH-003
password reset reaches the credential store❌ SEC-032

Do not set oidc until AUTH-001, AUTH-002, AUTH-003 and SEC-032 land. It is reachable, and it is less protected.

⚠ Until AUTH-004 the default was the external mode: keycloak.enabled defaulted to true in fourteen module configs, with matchIfMissing = true on the beans. Nothing failed and nothing logged — a deployment that set nothing simply ran with the fewest controls. Every chart set the flag explicitly, so the exposure was a new install, or one file losing a line.

Migrating from keycloak.enabled

keycloak.enabled is deprecated but still honoured, so an upgrade changes nothing on its own.

you haveit now meansdo
KEYCLOAK_ENABLED=falselocalreplace with UHP_AUTH_PROVIDER=local
KEYCLOAK_ENABLED=trueoidcreplace with UHP_AUTH_PROVIDER=oidc
neitherlocalnothing

Setting both is safe: uhp.auth.provider is the more specific statement and wins. Whenever the legacy flag is what decided the mode, a deprecation warning is printed on startup.

⚠ The warning goes to stderr, not the log. Resolution happens in an EnvironmentPostProcessor, before logging is initialised, so a log call there would be discarded.

Using a server other than Keycloak

oidc is not Keycloak-specific. The standard, portable parts work against any conformant server:

settingenvironment variablemeaning
uhp.auth.oidc.issuer-uriUHP_AUTH_OIDC_ISSUER_URIthe issuer tokens are minted under, validated on every request
uhp.auth.oidc.jwk-set-uriUHP_AUTH_OIDC_JWK_SET_URIwhere to fetch the signing keys, separate because it may take a different route
uhp.auth.oidc.audienceUHP_AUTH_OIDC_AUDIENCEthe audience every token must name

All three are standard OIDC (RFC 8414 / OIDC Discovery), identical for Keycloak, Okta, Auth0, Entra or Authentik. Point them at any conformant server.

⚠ The retired names still work (AUTH-009)

keycloak.issuer-uri, KEYCLOAK_JWK_SET_URI and keycloak.audience are carried onto the names above by AuthProviderEnvironmentPostProcessor, which logs a deprecation line naming the replacement. A deployment chart that has not been updated keeps working untouched. That is deliberate, because the values live in ehr/deployment rather than in this repo, and a rename requiring both to merge in lockstep would leave a window in which one of them is wrong.

⚠ When both are set, the new name wins. Otherwise migrating a chart would mean deleting the old variable in the very change that adds the new one, and a chart carrying both would silently keep serving the retired value.

⚠ Blank counts as unset: edition-base.yml declares issuer-uri: ${KEYCLOAK_ISSUER_URI:}, so an unconfigured deployment resolves it to the empty string, not to null.

⚠⚠ One part is genuinely vendor-specific and always will be: user provisioning. Its settings keep the keycloak.* prefix on purpose, because that name is accurate. Creating accounts, setting passwords and enabling or disabling users is not covered by OIDC, so it lives behind the IdentityProviderUserService seam, and keycloak.admin.* configures the Keycloak adapter of it. Supporting a different server means writing one adapter of that interface, not touching the authentication path.

⚠ A standard does exist for this half, just not in OIDC: SCIM 2.0 (RFC 7643 schema, RFC 7644 protocol) treats password as a writable, never-returned attribute changed by PATCH. That is the portable version of setPassword, and a SCIM adapter is the right shape behind the seam if a second provider is ever added.

What reaches the provider, and when (AUTH-001)

Every password and enablement write goes through IdentityProviderUserService, and callers do not branch on the provider first — that branching is what let the two stores drift apart.

operationprovider calllocal row
create accountprovisionUserhash written in local mode
password reset redeemedsetPasswordhash written in local mode
password changedsetPasswordhash written in local mode
administrator sets a passwordsetPasswordhash written in local mode
account deactivatedsetEnabled(false)active = false
account reactivated / active toggledsetEnabled(...)active = ...

⚠⚠ The provider is written first, the local row second, always. The reverse leaves the local row changed and the provider stale when the remote call fails, which is a silent divergence — the exact shape of SEC-032. A provider failure aborts the whole operation and rolls the transaction back.

⚠⚠ Ending sessions is part of changing a credential, not a separate step (AUTH-007). Revoking rows in user_refresh_token invalidates nothing under an external provider: the bearer token is minted and validated by the provider, so a token issued before the change kept working until it expired — and an attacker whose stolen token prompted the reset kept it. Every credential change now calls logoutUser as well.

⚠ In local mode every one of these calls is a no-op, so behaviour is unchanged from before.

Self-service password change under an external provider (AUTH-007)

POST /api/v1/auth/password-reset/require-update — the signed-in user asks the provider to make them set a new password at their next sign-in. Every session ends, here and at the provider, and they return through the provider's own screen.

⚠⚠ Why not simply change it in place? Writing the new password is solved (setPassword). Proving the caller knows the current one is not: the local hash is not the credential in this mode, so passwordEncoder.matches cannot verify it. Delegating hands the whole exchange to the party that owns the credential, its history rules, its policy and its second factor.

No email. This is the provider's required action at next sign-in, not its "send the user a link" variant. The user is already authenticated and present; an email round-trip would be worse than the local flow it replaces.

⚠⚠ The subject comes from the session and is never accepted from the request. Taking one from the payload would turn a self-service button into "force any user to change their password" — an administrative act wearing a user's clothes.

⚠ In local mode this endpoint refuses, and says to change the password directly. That is not plumbing: local mode holds the credential and has a real in-place change, so deferring it and signing the user out would be strictly worse.

Rejected: verifying the current password with a password grant

Cheap, and the machinery exists. Not built, because ROPC is removed in OAuth 2.1 and disabled by default on many servers; because it collides with MFA — a direct grant either fails outright for the accounts most worth protecting, or bypasses the second factor; and because a mistyped current password would count as a failed login at the provider, locking the user out there, with the symptom surfacing far from the cause.

Forgot-password works in both modes

⚠⚠ It previously did nothing under an external provider. requestPasswordReset returned early — and silently, because the endpoint is deliberately identical on every branch so an anonymous caller cannot enumerate usernames. A user clicked "forgot password", read "if that account exists we have sent a link", and no link was ever sent.

The flow is capable in both modes now: this platform mints and emails the token, and redemption writes the new password to the provider. A mailbox is the only proof an anonymous caller can offer, so it is the right proof whoever stores the credential.

Decide who owns the reset email, exclusively. If the provider is also configured to send its own, users receive two links from two systems.

How a session works under oidc (AUTH-003)

The provider proves who the caller is. This platform still issues the session, because session scope (sessionTenantId, sessionFacilityId) is its own concept and no external provider can mint a claim for a tenant or facility it has never heard of.

Browser -> provider OIDC login
-> POST /api/v1/auth/session/exchange (bearer: the PROVIDER's token)
resource server verifies it against the provider JWKS, issuer, audience
sub -> user_account via idp_subject
platform mints its own UNSCOPED session token
-> /auth/session/tenants -> /facilities -> /scope (bearer: the PLATFORM token)
the scoped token is signed here, exactly as in local mode

⚠ From the exchange onwards the client carries the platform token, not the provider's. The provider's token is presented once, to that one endpoint.

⚠⚠ Before AUTH-003 there was no scope at all under oidc: only RequestContextFilter ran, binding (requestId, null, null), so every requireCurrentTenantId() threw and nothing tenant-scoped worked.

⚠ A signing key is required under oidc

LOCAL_JWT_PRIVATE_KEY and LOCAL_JWT_PUBLIC_KEY are needed in both modes, and the deployment refuses to start without them under oidc. That is deliberate: the exchange is the only route to a session there, so a service that booted healthy and then refused every sign-in would be the worse failure. The names still say LOCAL_ because they are the platform's own key in either mode.

⚠ Accounts are never created by signing in

A provider subject with no idp_subject match is refused. Provisioning stays an administrative act with its own permission and audit trail. Unknown subject, disabled account and service account all answer the same way, so the endpoint cannot be used to discover which subjects have accounts.

Checking which mode is live

The setting drives bean registration, so the presence of the local endpoints is the answer:

curl -fsS http://localhost:8085/.well-known/jwks.json >/dev/null && echo local || echo oidc

JwksController exists only under local.