11 KiB
SSO login (OIDC)
HealthLog can delegate sign-in to a self-hosted OpenID Connect identity provider — Authentik, Keycloak, Authelia, Pocket-ID, or anything else that serves a standard discovery document. The flow is the OIDC authorization-code flow with PKCE against a single, env-configured provider; by default it is additive (the SSO button appears next to password and passkey login), with an explicit opt-in to make it the only way in.
Setup
-
Register a confidential OIDC client at your IdP with the redirect URI
${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callbackThe comparison at the IdP is byte-exact: scheme, host, port, and path all matter.
https://healthlog.example.com/api/auth/oidc/callbackandhttp://healthlog.example.com/api/auth/oidc/callbackare different URIs, and a trailing slash breaks the match. -
Set the three provider vars (all three together enable the login-page button; anything less keeps SSO fully disabled):
OIDC_ISSUER_URL="https://idp.example.com" OIDC_CLIENT_ID="healthlog" OIDC_CLIENT_SECRET="..."The issuer must serve
<issuer>/.well-known/openid-configuration. Prefer HTTPS — the authorization code and the token response carrying the ID token cross this connection. Plain HTTP is defensible only on a fully trusted private network (IdP and app on the same box, a Tailscale mesh, a VPN); a LAN-only issuer is explicitly supported. -
Optional tuning:
OIDC_SCOPES="openid email profile" # default shown OIDC_BUTTON_LABEL="Single Sign-On" # default shown OIDC_ONLY="false" # see below before changing -
Remember the compose env whitelist: every
OIDC_*var must appear underenvironment:indocker-compose.yml(the shipped file lists them) — vars not on the whitelist never reach the container even when.envhas them.
pnpm check-env knows all six vars and flags a partially configured
provider group.
Per-IdP notes
Client registration essentials only — the IdP's own docs cover the rest.
- Authentik. Create a Provider of type OAuth2/OpenID (confidential
client), bind it to an Application, and use the provider's issuer URL
(shaped like
https://authentik.example.com/application/o/<app-slug>/— the trailing slash is part of Authentik's issuer; the discovery check tolerates the difference). Signing key: any RSA/EC key; the default RS256 works. - Keycloak. Create a client with Client authentication ON and the
Standard flow enabled. The issuer is
https://keycloak.example.com/realms/<realm>. Keep the default RS256 signature algorithm; do not switch the client toHS256— HealthLog rejects HMAC-signed ID tokens. - Authelia. Define a client under
identity_providers.oidc.clientswithtoken_endpoint_auth_method: client_secret_post(HealthLog sends the secret in the POST body) and the redirect URI above. The issuer is Authelia's root URL. - Pocket-ID. Add an OIDC client, copy the generated ID and secret, and use the Pocket-ID base URL as the issuer. Pocket-ID authenticates users by passkey at the IdP; to HealthLog it is a provider like any other.
Security model
- Identity is pinned, not guessed. The first OIDC sign-in either
provisions a fresh account or links to an existing account with the
same email, and only when the IdP asserts
email_verified: true(an IdP that does not assert verification is rejected). An existing account is never linked on the email alone: HealthLog never verified that address, so anyone could have registered it or typed it into a profile. The browser is sent to the sign-in page instead, and the link is made when the person signs in to that account once with its own password (and second factor) or passkey, within ten minutes. UnderOIDC_ONLYthat password sign-in is allowed for exactly that account. The iOS app getsoidc_link_requiredand the person confirms once on the web. Either way the account is stamped with the provider's(issuer, sub)pair, and every later login matches on that pair alone. An email change at the IdP updates the displayed email; it can never re-point the login at a different account. An email that matches an account already pinned to a different identity is rejected and audited. - SSO sessions never satisfy step-up. Destructive actions (disabling MFA, rotating encryption keys, encrypted export, account deletion) require a fresh second factor confirmed by HealthLog itself. An OIDC login is a delegated factor — whatever the IdP checked, this app did not see it — so an SSO session always has to confirm a native factor (or the password path) when it hits one of those gates.
- Accounts with native 2FA keep it. If the account has a confirmed TOTP secret or a registered security key, an OIDC login does not complete on its own: after the IdP round-trip the user lands in the same second-factor step password login uses. SSO never downgrades an account's own MFA.
- MFA belongs at the IdP. For SSO-provisioned accounts, enforce MFA in the IdP's policy engine — that is the point of delegating authentication. HealthLog-side 2FA remains available on top for the step-up gates above.
- Claiming a managed profile binds a fresh identity only. When a
person claims a managed profile through SSO, their
(issuer, sub)is bound to that record, and only if no account on the instance already holds it, and the provider's address is used only when it is verified and not already taken. A claim can therefore never attach somebody's existing sign-in to a second record or take over an existing account. - Linking is once. There is no unlink/relink flow in this cut; the
pin is deliberately sticky. Moving an account to a new IdP identity is
an operator decision (a manual
users.oidc_issuer/users.oidc_subupdate), not something a login can do.
OIDC_ONLY
OIDC_ONLY="true" disables password and passkey login server-side
(the routes return 403; hiding the buttons is not the boundary).
Consequences to accept before turning it on:
- The native iOS app needs a build that ships the SSO flow. The app
signs in via the native OIDC handoff (
GET /api/auth/oidc/login?client=nativewith PKCE and the fixedhealthlog://callback) or via password/passkey.OIDC_ONLYworks with app builds that carry the handoff; older password/passkey-only builds are locked out. - Existing sessions survive. The flag gates new sign-ins, not live sessions — nobody is thrown out at flip time, including password-authenticated sessions.
- Self-disabling on half-config. The flag only takes effect while the three provider vars are fully set, so a typo in the provider group can never lock every user out.
- Managed profiles are still handed over (v1.43). The person claims the record through the identity provider instead of setting a password; the password claim is refused. See Handing a record over to its person.
Break-glass (IdP down or misconfigured)
-
Unset
OIDC_ONLY(or set it to"false") in.env, thendocker compose up -d. Password and passkey login are live again immediately. -
Accounts that were provisioned through SSO have no password. Set one from the host, per docs/ops/password-reset.md:
docker compose exec app node scripts/reset-password.mjs <username-or-email>
Troubleshooting
| Symptom | Cause / fix |
|---|---|
Login page shows "Single sign-on failed", log says discovery issuer mismatch |
OIDC_ISSUER_URL must equal the issuer inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, //) is a hard reject. |
discovery request failed: 3xx |
Discovery redirects are not followed (egress policy pins redirect: "manual"). Point OIDC_ISSUER_URL at the final URL — e.g. include the realm/application path, match http vs https, add/remove www. |
| IdP shows its own "invalid redirect URI" page | The mismatch happens at the IdP, before HealthLog is involved. Compare the registered URI against ${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback byte for byte — scheme and port included. |
Sign-in loops back with "failed" and the log shows an exp/nbf claim error |
Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
| "Your identity provider did not mark your email address as verified" | The IdP omits email_verified or sends false. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
| "An account with this email address already exists here. Sign in to it once …" | The IdP email matches a local account that is not linked yet. Sign in to that account with its password or passkey within ten minutes and the link is made. If nobody knows that account's password, an operator decides which account the identity belongs to. |
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. pnpm check-env shows which. |