# 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 1. Register a **confidential** OIDC client at your IdP with the redirect URI ``` ${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback ``` The comparison at the IdP is byte-exact: scheme, host, port, and path all matter. `https://healthlog.example.com/api/auth/oidc/callback` and `http://healthlog.example.com/api/auth/oidc/callback` are different URIs, and a trailing slash breaks the match. 2. Set the three provider vars (all three together enable the login-page button; anything less keeps SSO fully disabled): ```env OIDC_ISSUER_URL="https://idp.example.com" OIDC_CLIENT_ID="healthlog" OIDC_CLIENT_SECRET="..." ``` The issuer must serve `/.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. 3. Optional tuning: ```env OIDC_SCOPES="openid email profile" # default shown OIDC_BUTTON_LABEL="Single Sign-On" # default shown OIDC_ONLY="false" # see below before changing ``` 4. Remember the compose env whitelist: every `OIDC_*` var must appear under `environment:` in `docker-compose.yml` (the shipped file lists them) — vars not on the whitelist never reach the container even when `.env` has 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//` — 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/`. Keep the default RS256 signature algorithm; do not switch the client to `HS256` — HealthLog rejects HMAC-signed ID tokens. - **Authelia.** Define a client under `identity_providers.oidc.clients` with `token_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 matched by **verified** email (`email_verified: true` — an IdP that does not assert verification is rejected). 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. - **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_sub` update), 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=native` with PKCE and the fixed `healthlog://` callback) or via password/passkey. `OIDC_ONLY` works 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. ### Break-glass (IdP down or misconfigured) 1. Unset `OIDC_ONLY` (or set it to `"false"`) in `.env`, then `docker compose up -d`. Password and passkey login are live again immediately. 2. Accounts that were **provisioned** through SSO have no password. Set one from the host, per [docs/ops/password-reset.md](../ops/password-reset.md): ```sh docker compose exec app node scripts/reset-password.mjs ``` ## 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. | | "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. |