mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
172 lines
11 KiB
Markdown
172 lines
11 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 with the
|
|
same email, and only when the IdP asserts `email_verified: true` (an
|
|
IdP that does not assert verification is rejected). An existing
|
|
account is never linked on the email alone: HealthLog never verified
|
|
that address, so anyone could have registered it or typed it into a
|
|
profile. The browser is sent to the sign-in page instead, and the link
|
|
is made when the person signs in to that account once with its own
|
|
password (and second factor) or passkey, within ten minutes. Under
|
|
`OIDC_ONLY` that password sign-in is allowed for exactly that account.
|
|
The iOS app gets `oidc_link_required` and the person confirms once on
|
|
the web. 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.
|
|
- **Claiming a managed profile binds a fresh identity only.** When a
|
|
person claims a managed profile through SSO, their `(issuer, sub)` is
|
|
bound to that record, and only if no account on the instance already
|
|
holds it, and the provider's address is used only when it is verified
|
|
and not already taken. A claim can therefore never attach somebody's
|
|
existing sign-in to a second record or take over an existing account.
|
|
- **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.
|
|
- **Managed profiles are still handed over** (v1.43). The person claims the
|
|
record through the identity provider instead of setting a password; the
|
|
password claim is refused. See
|
|
[Handing a record over to its person](account-sharing.md#handing-a-record-over-to-its-person).
|
|
|
|
### 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. |
|
|
| "An account with this email address already exists here. Sign in to it once …" | The IdP email matches a local account that is not linked yet. Sign in to that account with its password or passkey within ten minutes and the link is made. If nobody knows that account's password, an operator decides which account the identity belongs to. |
|
|
| "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. |
|