Files
HealthLog/docs/self-hosting/sso.md
T
Marc-André Bombeck b57c517929 docs: self-hosting SSO guide
Setup walk (byte-exact redirect URI), client-registration essentials
for Authentik / Keycloak / Authelia / Pocket-ID, the security model
(identity pinned to (issuer, sub) after a one-time verified-email link;
SSO sessions never satisfy step-up; native-2FA accounts still get their
second-factor step; MFA policy belongs at the IdP), the OIDC_ONLY
consequences incl. the iOS lockout and the break-glass runbook, and a
troubleshooting table for the recurring failure shapes (issuer
trailing slash, unfollowed discovery redirects, clock skew,
IdP-side redirect_uri mismatch).
2026-07-14 11:55:47 +02:00

8.8 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

  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):

    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.

  3. Optional tuning:

    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/<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 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 cannot sign in. It authenticates via password/passkey only; there is no native SSO flow yet. Do not enable OIDC_ONLY on a deployment with active iOS users.
  • 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:

    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.
"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.