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.
| value | who mints tokens | who verifies them |
|---|---|---|
local (default) | this platform, ES256, JWKS published at /.well-known/jwks.json | this platform |
oidc | an external OAuth2/OIDC authorization server | this 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:
| control | local | oidc |
|---|---|---|
| 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 have | it now means | do |
|---|---|---|
KEYCLOAK_ENABLED=false | local | replace with UHP_AUTH_PROVIDER=local |
KEYCLOAK_ENABLED=true | oidc | replace with UHP_AUTH_PROVIDER=oidc |
| neither | local | nothing |
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:
| setting | environment variable | meaning |
|---|---|---|
uhp.auth.oidc.issuer-uri | UHP_AUTH_OIDC_ISSUER_URI | the issuer tokens are minted under, validated on every request |
uhp.auth.oidc.jwk-set-uri | UHP_AUTH_OIDC_JWK_SET_URI | where to fetch the signing keys, separate because it may take a different route |
uhp.auth.oidc.audience | UHP_AUTH_OIDC_AUDIENCE | the 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.
| operation | provider call | local row |
|---|---|---|
| create account | provisionUser | hash written in local mode |
| password reset redeemed | setPassword | hash written in local mode |
| password changed | setPassword | hash written in local mode |
| administrator sets a password | setPassword | hash written in local mode |
| account deactivated | setEnabled(false) | active = false |
account reactivated / active toggled | setEnabled(...) | 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.