mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
- geolite2: quote the real commented compose mount (./geolite2 to /opt/geolite2, the GEOLITE2_DIR default, so no env var is needed for the default path) - getting-started: healthcheck probes /api/health, not /api/version; the environment module reaches archive-api and geocoding-api .open-meteo.com, relevant for egress allowlists - mcp: the module is opt-in and off by default, but no longer the only such module - reverse-proxy: the Coach stream is SSE over plain HTTP, not WebSockets; note that TRUST_PROXY_HOPS is not on the bundled compose whitelist and must be added by hand - scaling: /api/health healthcheck; worker liveness is in-memory and invisible across a web/worker split; pool sizing now goes through getConnectionBudget with DB_CONNECTION_LIMIT as the compose knob and a 2-connection pg-boss share - sso: the native SSO handoff shipped, OIDC_ONLY works with app builds that carry it - unraid: geo databases are baked in only when the build had a licence key, or mounted; replaceable either way
153 lines
9.0 KiB
Markdown
153 lines
9.0 KiB
Markdown
# 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 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 <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. |
|