mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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).
This commit is contained in:
1 parent
7592c73947
commit
b57c517929
2 files changed
+153
-1
No files matched your search
+3
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
`<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:
|
||||
|
||||
```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/<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](../ops/password-reset.md):
|
||||
|
||||
```sh
|
||||
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. |
|
||||
Reference in new issue
Block a user