From b57c5179293801580d9808c0fc798b7f3612131e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 14 Jul 2026 11:55:47 +0200 Subject: [PATCH] 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). --- docs/README.md | 4 +- docs/self-hosting/sso.md | 150 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 docs/self-hosting/sso.md diff --git a/docs/README.md b/docs/README.md index de19fcba3..296b7b3a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,9 @@ notes, and machine-readable specs that ship alongside the source: recipe. Routine install steps stay on `docs.healthlog.dev`. Includes [`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the off-by-default MCP connector and pointing Claude.ai / ChatGPT / - Claude Desktop at it. + Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md) + — OIDC SSO login: IdP setup, the identity-pinning security model, + `OIDC_ONLY` consequences, and the break-glass runbook. - [`migration/`](./migration/) — release-by-release migration notes. Read the entry for the version you're upgrading from. - [`audit/`](./audit/) — per-release audit summaries archived for diff --git a/docs/self-hosting/sso.md b/docs/self-hosting/sso.md new file mode 100644 index 000000000..853446fe6 --- /dev/null +++ b/docs/self-hosting/sso.md @@ -0,0 +1,150 @@ +# 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 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](../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. |