Merge pull request #485 from balajiv113/feat/oidc-sso-login

feat(auth): add OIDC SSO login
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-15 21:37:20 +02:00
commit a614542cc3
37 files changed
+2984 -97

No files matched your search

+27
View File
@@ -103,6 +103,33 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# STRAVA_CLIENT_SECRET=""
# STRAVA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# OIDC SSO login -- optional (self-hosted identity provider)
# -----------------------------------------------------------------------------
# Register a confidential OIDC client for your IdP (Authentik, Keycloak,
# Authelia, Google Workspace, etc.) with redirect URI
# `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback`. All three of ISSUER_URL/
# CLIENT_ID/CLIENT_SECRET together enable the "Sign in with SSO" button on
# the login page; set none to leave it disabled. First-time sign-in
# auto-provisions (or links, by verified email) a HealthLog account. OIDC_ONLY
# hides password/passkey login entirely once set to "true" -- but only takes
# effect when the three vars above are also fully set, so a typo can't lock
# every user out. Enforced server-side, not just a hidden button. The native
# iOS client authenticates via password/passkey only -- there is no OIDC
# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in
# entirely until a native SSO flow ships. Do not enable it on a deployment
# with active iOS users.
# Serve the issuer over HTTPS wherever possible: sign-in material (the
# authorization code, the token-endpoint response carrying the ID token)
# crosses 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).
# OIDC_ISSUER_URL=""
# OIDC_CLIENT_ID=""
# OIDC_CLIENT_SECRET=""
# OIDC_SCOPES="openid email profile"
# OIDC_BUTTON_LABEL="Single Sign-On"
# OIDC_ONLY="false"
# -----------------------------------------------------------------------------
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
+1
View File
@@ -165,6 +165,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
## DO-NOTs
+15
View File
@@ -142,6 +142,21 @@ services:
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
# hides password/passkey login entirely, but only takes effect when
# the three above are also fully set — a half-configured group never
# locks every user out. It IS enforced server-side, and there is no
# OIDC flow for the native iOS client yet — enabling it locks iOS
# users out of sign-in entirely. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
OIDC_SCOPES: "${OIDC_SCOPES:-}"
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
OIDC_ONLY: "${OIDC_ONLY:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
+3 -1
View File
@@ -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
+150
View File
@@ -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. |
+9
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
"privacyPolicy": "Datenschutzerklärung",
"passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes.",
"oidc": {
"signInDefault": "Mit SSO anmelden",
"errorDenied": "Die Anmeldung wurde abgebrochen.",
"errorNoEmail": "Dein Identitätsanbieter hat keine bestätigte E-Mail-Adresse übermittelt.",
"errorEmailUnverified": "Dein Identitätsanbieter hat deine E-Mail-Adresse nicht als bestätigt markiert.",
"errorIdentityConflict": "Diese E-Mail-Adresse gehört zu einem Konto, das bereits mit einer anderen SSO-Identität verknüpft ist.",
"errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.",
"errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut."
},
"mfa": {
"title": "Zwei-Faktor-Authentifizierung",
"totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
+9
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "Password must be at least {minLength} characters long.",
"privacyPolicy": "Privacy policy",
"passwordBreached": "This password has appeared in known data breaches. Please choose a different one.",
"oidc": {
"signInDefault": "Sign in with SSO",
"errorDenied": "Sign-in was cancelled.",
"errorNoEmail": "Your identity provider did not share a verified email address.",
"errorEmailUnverified": "Your identity provider did not mark your email address as verified.",
"errorIdentityConflict": "That email address belongs to an account that is already linked to a different SSO identity.",
"errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.",
"errorFailed": "Single sign-on failed. Please try again."
},
"mfa": {
"title": "Two-factor authentication",
"totpHint": "Enter the 6-digit code from your authenticator app.",
+9
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.",
"privacyPolicy": "Política de privacidad",
"passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra.",
"oidc": {
"signInDefault": "Iniciar sesión con SSO",
"errorDenied": "El inicio de sesión se canceló.",
"errorNoEmail": "Tu proveedor de identidad no compartió un correo electrónico verificado.",
"errorEmailUnverified": "Tu proveedor de identidad no marcó tu correo electrónico como verificado.",
"errorIdentityConflict": "Ese correo electrónico pertenece a una cuenta que ya está vinculada a otra identidad SSO.",
"errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.",
"errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo."
},
"mfa": {
"title": "Autenticación de dos factores",
"totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.",
+9
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.",
"privacyPolicy": "Politique de confidentialité",
"passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre.",
"oidc": {
"signInDefault": "Se connecter avec le SSO",
"errorDenied": "La connexion a été annulée.",
"errorNoEmail": "Votre fournisseur d'identité n'a pas transmis d'adresse e-mail vérifiée.",
"errorEmailUnverified": "Votre fournisseur d'identité n'a pas marqué votre adresse e-mail comme vérifiée.",
"errorIdentityConflict": "Cette adresse e-mail appartient à un compte déjà lié à une autre identité SSO.",
"errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.",
"errorFailed": "L'authentification unique a échoué. Veuillez réessayer."
},
"mfa": {
"title": "Authentification à deux facteurs",
"totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
+9
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "La password deve avere almeno {minLength} caratteri.",
"privacyPolicy": "Informativa sulla privacy",
"passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra.",
"oidc": {
"signInDefault": "Accedi con SSO",
"errorDenied": "L'accesso è stato annullato.",
"errorNoEmail": "Il tuo provider di identità non ha condiviso un indirizzo email verificato.",
"errorEmailUnverified": "Il tuo provider di identità non ha contrassegnato il tuo indirizzo email come verificato.",
"errorIdentityConflict": "Questo indirizzo email appartiene a un account già collegato a un'altra identità SSO.",
"errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.",
"errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova."
},
"mfa": {
"title": "Autenticazione a due fattori",
"totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
+9
View File
@@ -286,6 +286,15 @@
"passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.",
"privacyPolicy": "Polityka prywatności",
"passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne.",
"oidc": {
"signInDefault": "Zaloguj się przez SSO",
"errorDenied": "Logowanie zostało anulowane.",
"errorNoEmail": "Twój dostawca tożsamości nie przekazał zweryfikowanego adresu e-mail.",
"errorEmailUnverified": "Twój dostawca tożsamości nie oznaczył Twojego adresu e-mail jako zweryfikowanego.",
"errorIdentityConflict": "Ten adres e-mail należy do konta powiązanego już z inną tożsamością SSO.",
"errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.",
"errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie."
},
"mfa": {
"title": "Uwierzytelnianie dwuskładnikowe",
"totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.",
+1
View File
@@ -74,6 +74,7 @@
"date-fns": "^4.4.0",
"dotenv": "^17.4.2",
"fflate": "^0.8.3",
"jose": "^6.2.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
+3
View File
@@ -75,6 +75,9 @@ importers:
fflate:
specifier: ^0.8.3
version: 0.8.3
jose:
specifier: ^6.2.3
version: 6.2.3
jspdf:
specifier: ^4.2.1
version: 4.2.1
@@ -0,0 +1,13 @@
-- v1.28 — OIDC SSO identity pinning.
--
-- The (oidc_issuer, oidc_sub) pair pins an account to exactly one IdP
-- identity: stamped on first OIDC login (auto-provision or one-time link by
-- verified email), matched on every later OIDC login. Email stays a display
-- field after the stamp — an IdP-side email change can never re-point the
-- login at a different account. Both columns are NULL on accounts that never
-- signed in via SSO; Postgres unique indexes ignore NULL pairs, so the
-- constraint only bites on actual duplicate identities.
ALTER TABLE "users" ADD COLUMN "oidc_issuer" TEXT;
ALTER TABLE "users" ADD COLUMN "oidc_sub" TEXT;
CREATE UNIQUE INDEX "users_oidc_issuer_oidc_sub_key" ON "users"("oidc_issuer", "oidc_sub");
+11
View File
@@ -42,6 +42,13 @@ model User {
email String? @unique
passwordHash String? @map("password_hash") // argon2id, optional fallback
role String @default("USER") // "ADMIN" | "USER"
// OIDC SSO — the (issuer, sub) pair pins the account to exactly one IdP
// identity. Stamped on first OIDC login (auto-provision or one-time link
// by verified email); every later OIDC login matches on this pair, never
// on the email, so an IdP-side email change can update the display email
// but can never re-point the login at a different account.
oidcIssuer String? @map("oidc_issuer")
oidcSub String? @map("oidc_sub")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -871,6 +878,10 @@ model User {
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
// One account per IdP identity — the (issuer, sub) pair is the durable
// OIDC key (both NULL on accounts that never signed in via SSO; Postgres
// unique indexes ignore NULL pairs).
@@unique([oidcIssuer, oidcSub])
@@map("users")
}
+39
View File
@@ -78,6 +78,45 @@
}
]
},
{
"name": "OIDC SSO login",
"description": "Required to enable SSO login against a self-hosted identity provider (Authentik, Keycloak, Authelia, Google Workspace, etc.). App boots without these but the login page's SSO button stays hidden. Register a confidential client with redirect URI ${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "OIDC_ISSUER_URL",
"purpose": "OIDC provider issuer URL (discovery doc served at <issuer>/.well-known/openid-configuration)."
},
{
"name": "OIDC_CLIENT_ID",
"purpose": "OIDC confidential client id."
},
{
"name": "OIDC_CLIENT_SECRET",
"purpose": "OIDC confidential client secret."
}
]
},
{
"name": "OIDC SSO options",
"description": "Optional tuning for an enabled OIDC provider. Each stands alone (deliberately NOT allOrNone — any subset is valid) and each has a safe default when unset.",
"required": false,
"variables": [
{
"name": "OIDC_SCOPES",
"purpose": "Space-separated scopes requested at authorization. Default: \"openid email profile\"."
},
{
"name": "OIDC_BUTTON_LABEL",
"purpose": "Label on the login page's SSO button. Default: \"Single Sign-On\"."
},
{
"name": "OIDC_ONLY",
"purpose": "\"true\" disables password/passkey login server-side (only effective when the OIDC provider group is fully configured). Default: additive login. Locks the native iOS app out of sign-in."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
+46 -1
View File
@@ -5,7 +5,7 @@
* only signal operators need; the identifier itself is PII and stays
* out of the row.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -135,3 +135,48 @@ describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
expect(JSON.stringify(payload)).not.toContain(TYPED_EMAIL);
});
});
describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
it("rejects password login before touching the DB when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(prisma.user.findFirst).not.toHaveBeenCalled();
});
it("still allows password login when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
// Falls through to normal password verification (which fails here
// since verifyPassword is mocked to return false) rather than 403 —
// a half-set OIDC group must never lock everyone out.
expect(res.status).toBe(401);
});
});
+10
View File
@@ -13,8 +13,18 @@ import { finishLogin } from "@/lib/auth/login-response";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
// button — otherwise a pre-existing password (or a leaked/reused one)
// stays a live bypass of the operator's SSO-only policy.
if (isOidcOnly()) {
return apiError("Password login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
// global bucket when the trust chain is misconfigured; otherwise it
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
@@ -0,0 +1,483 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setError: vi.fn() }),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((s: string) => s.replace(/^enc\(/, "").replace(/\)$/, "")),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findFirst: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
appSettings: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn() }));
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
vi.mock("@/lib/auth/mfa/challenge", () => ({
createMfaChallenge: vi.fn(async () => ({
ticket: "mfa-ticket-1",
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
})),
}));
vi.mock("@/lib/auth/mfa-enrollment", () => ({
syncMfaEnrollCookie: vi.fn(),
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => false,
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveServerDefaultTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/auth/oidc", () => ({
getOidcConfig: vi.fn(),
discoverOidcMetadata: vi.fn(),
exchangeCodeForTokens: vi.fn(),
verifyIdToken: vi.fn(),
fetchUserinfoEmail: vi.fn(),
getOidcRedirectUri: () =>
"https://healthlog.example.com/api/auth/oidc/callback",
deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]),
}));
import { GET } from "../route";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import {
getOidcConfig,
discoverOidcMetadata,
exchangeCodeForTokens,
verifyIdToken,
} from "@/lib/auth/oidc";
const CONFIG = {
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
};
const METADATA = {
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
};
/** A minimal user row shaped like what the callback consumes. */
function userRow(overrides: Record<string, unknown> = {}) {
return {
id: "user-1",
email: "existing@example.com",
onboardingCompletedAt: new Date(),
oidcIssuer: null,
oidcSub: null,
totpConfirmedAt: null,
mfaEnforced: false,
...overrides,
};
}
/**
* The callback's `prisma.user.findFirst` calls are distinguishable by
* their `where` shape: the (issuer, sub) identity lookup, the display-
* email collision probe (`NOT`), and the link/provision email match.
*/
function mockUserLookups(opts: {
byIdentity?: ReturnType<typeof userRow> | null;
byEmail?: ReturnType<typeof userRow> | null;
emailTaken?: boolean;
}) {
vi.mocked(prisma.user.findFirst).mockImplementation(((args: {
where: Record<string, unknown>;
}) => {
if (args.where.oidcIssuer !== undefined) {
return Promise.resolve(opts.byIdentity ?? null);
}
if (args.where.NOT !== undefined) {
return Promise.resolve(opts.emailTaken ? { id: "user-other" } : null);
}
return Promise.resolve(opts.byEmail ?? null);
}) as never);
}
function makeRequest(opts: { query?: string; cookie?: string }): NextRequest {
const req = new NextRequest(
`http://localhost/api/auth/oidc/callback${opts.query ?? ""}`,
);
if (opts.cookie) {
req.cookies.set("oidc_auth_state", opts.cookie);
}
return req;
}
function stateCookie(payload: Record<string, unknown>): string {
return `enc(${JSON.stringify(payload)})`;
}
/** A request whose query/state pass the CSRF gate — the default fixture. */
function validRequest(next = "/"): NextRequest {
return makeRequest({
query: "?code=abc&state=STATE",
cookie: stateCookie({
state: "STATE",
nonce: "nonce-1",
codeVerifier: "verifier-1",
next,
}),
});
}
function mockIdentity(overrides: Record<string, unknown> = {}) {
vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" });
vi.mocked(verifyIdToken).mockResolvedValue({
sub: "sub-1",
email: "existing@example.com",
emailVerified: true,
name: null,
...overrides,
} as never);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 20,
reset: 0,
ip: "1.2.3.4",
} as never);
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0);
vi.mocked(createMfaChallenge).mockResolvedValue({
ticket: "mfa-ticket-1",
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
});
describe("GET /api/auth/oidc/callback", () => {
it("redirects with oidc_denied when the IdP reports an error", async () => {
const res = await GET(makeRequest({ query: "?error=access_denied" }));
expect(res.headers.get("location")).toContain("error=oidc_denied");
});
it("redirects with oidc_state when code/state are missing", async () => {
const res = await GET(makeRequest({}));
expect(res.headers.get("location")).toContain("error=oidc_state");
});
it("redirects with oidc_state when the state cookie is missing", async () => {
const res = await GET(makeRequest({ query: "?code=abc&state=xyz" }));
expect(res.headers.get("location")).toContain("error=oidc_state");
});
it("redirects with oidc_state on a state/cookie mismatch (CSRF)", async () => {
const res = await GET(
makeRequest({
query: "?code=abc&state=WRONG",
cookie: stateCookie({
state: "RIGHT",
nonce: "nonce-1",
codeVerifier: "verifier-1",
next: "/",
}),
}),
);
expect(res.headers.get("location")).toContain("error=oidc_state");
});
it("redirects with oidc_no_email when the identity has no email at all", async () => {
mockIdentity({ email: null, emailVerified: undefined });
mockUserLookups({ byIdentity: null });
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain("error=oidc_no_email");
});
it("redirects with oidc_email_unverified when email_verified is absent", async () => {
// An IdP that never asserts verification cannot anchor a link or a
// provision — absent is a reject, not a benefit of the doubt.
mockIdentity({ emailVerified: undefined });
mockUserLookups({ byIdentity: null, byEmail: null });
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain(
"error=oidc_email_unverified",
);
expect(prisma.user.create).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
});
it("redirects with oidc_email_unverified when email_verified is false", async () => {
mockIdentity({ emailVerified: false });
mockUserLookups({ byIdentity: null, byEmail: null });
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain(
"error=oidc_email_unverified",
);
});
it("logs in by (issuer, sub) without consulting the email", async () => {
// Even with a brand-new email at the IdP, the stamped identity wins —
// no second account is provisioned and no re-link happens.
mockIdentity({ email: "renamed@example.com", emailVerified: true });
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
}),
emailTaken: false,
});
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({
email: "renamed@example.com",
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
}) as never,
);
const res = await GET(validRequest("/dashboard"));
expect(prisma.user.create).not.toHaveBeenCalled();
// Display email refresh — the only field the update touches.
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { email: "renamed@example.com" },
});
expect(createSession).toHaveBeenCalled();
expect(res.headers.get("location")).toContain("/dashboard");
});
it("skips the display-email refresh when another account holds the address", async () => {
mockIdentity({ email: "collide@example.com", emailVerified: true });
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
}),
emailTaken: true,
});
const res = await GET(validRequest());
expect(prisma.user.update).not.toHaveBeenCalled();
expect(createSession).toHaveBeenCalled();
expect(res.status).toBe(307);
});
it("links an existing email-matched account ONCE, stamping (issuer, sub)", async () => {
mockIdentity();
mockUserLookups({ byIdentity: null, byEmail: userRow() });
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
);
const res = await GET(validRequest("/dashboard"));
expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { oidcIssuer: METADATA.issuer, oidcSub: "sub-1" },
});
expect(auditLog).toHaveBeenCalledWith(
"auth.oidc.linked",
expect.objectContaining({ userId: "user-1" }),
);
expect(createSession).toHaveBeenCalled();
expect(res.headers.get("location")).toContain("/dashboard");
});
it("rejects a login whose email matches an account bound to a DIFFERENT identity", async () => {
mockIdentity({ sub: "sub-intruder" });
mockUserLookups({
byIdentity: null,
byEmail: userRow({
oidcIssuer: "https://other-idp.example.com",
oidcSub: "sub-original",
}),
});
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain(
"error=oidc_identity_conflict",
);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
expect(auditLog).toHaveBeenCalledWith(
"auth.oidc.link_conflict",
expect.objectContaining({ userId: "user-1" }),
);
});
it("auto-provisions a new user when registration is open", async () => {
mockIdentity({
sub: "sub-2",
email: "New@Example.com",
emailVerified: true,
});
mockUserLookups({ byIdentity: null, byEmail: null });
vi.mocked(prisma.user.count).mockResolvedValue(3);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
registrationEnabled: true,
} as never);
vi.mocked(prisma.user.create).mockResolvedValue(
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
);
const res = await GET(validRequest());
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
// Lowercased at provision time; stamped with the durable identity
// and the same server-default timezone the register route resolves.
email: "new@example.com",
passwordHash: null,
role: "USER",
timezone: "Europe/Berlin",
oidcIssuer: METADATA.issuer,
oidcSub: "sub-2",
}),
}),
);
expect(createSession).toHaveBeenCalled();
expect(res.status).toBe(307);
});
it("refuses to auto-provision when registration is closed", async () => {
mockIdentity({ sub: "sub-3", email: "blocked@example.com" });
mockUserLookups({ byIdentity: null, byEmail: null });
vi.mocked(prisma.user.count).mockResolvedValue(5);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
registrationEnabled: false,
} as never);
const res = await GET(validRequest());
expect(prisma.user.create).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
expect(res.headers.get("location")).toContain(
"error=oidc_registration_disabled",
);
});
it("never stamps the session step-up fresh — the 5th createSession arg is null", async () => {
mockIdentity();
mockUserLookups({ byIdentity: null, byEmail: userRow() });
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
);
await GET(validRequest());
expect(createSession).toHaveBeenCalledWith(
"user-1",
false,
"1.2.3.4",
null,
null,
);
});
it("routes a TOTP-enrolled account through the MFA challenge instead of logging in", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
totpConfirmedAt: new Date(),
}),
});
const res = await GET(validRequest("/dashboard"));
// No session — the partial state lives in the single-use ticket.
expect(createSession).not.toHaveBeenCalled();
expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login");
const location = res.headers.get("location")!;
expect(location).toContain("/auth/login");
expect(location).not.toContain("error=");
// `next` continuation rides the query string into the login page.
expect(location).toContain(`next=${encodeURIComponent("/dashboard")}`);
// The ticket + methods ride the login-page-scoped handoff cookie.
const setCookie = res.headers.getSetCookie().join("\n");
expect(setCookie).toContain("oidc_mfa_handoff=");
expect(setCookie).toContain(encodeURIComponent("mfa-ticket-1"));
expect(setCookie.toLowerCase()).toContain("path=/auth/login");
expect(decodeURIComponent(setCookie)).toContain('"totp"');
});
it("offers only the security-key method for a WebAuthn-only account", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
totpConfirmedAt: null,
}),
});
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1);
const res = await GET(validRequest());
expect(createSession).not.toHaveBeenCalled();
const setCookie = decodeURIComponent(res.headers.getSetCookie().join("\n"));
expect(setCookie).toContain('"webauthn"');
expect(setCookie).not.toContain('"totp"');
});
it("redirects with oidc_failed when the token exchange throws", async () => {
vi.mocked(exchangeCodeForTokens).mockRejectedValue(new Error("network"));
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain("error=oidc_failed");
});
it("deletes the state cookie with the exact path it was set under", async () => {
// RFC 6265 keys cookies by (name, domain, path) — a deletion without
// `Path=/api/auth/oidc` would target a `/`-scoped cookie that does not
// exist and leave the real single-use blob alive.
const errorRes = await GET(makeRequest({ query: "?error=access_denied" }));
const errorCookie = errorRes.headers
.getSetCookie()
.find((c) => c.startsWith("oidc_auth_state="))!;
expect(errorCookie.toLowerCase()).toContain("path=/api/auth/oidc");
mockIdentity();
mockUserLookups({
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
});
const successRes = await GET(validRequest());
const successCookie = successRes.headers
.getSetCookie()
.find((c) => c.startsWith("oidc_auth_state="))!;
expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc");
});
});
+394
View File
@@ -0,0 +1,394 @@
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import {
deriveUniqueUsername,
discoverOidcMetadata,
exchangeCodeForTokens,
fetchUserinfoEmail,
getOidcConfig,
getOidcRedirectUri,
verifyIdToken,
} from "@/lib/auth/oidc";
import {
OIDC_MFA_COOKIE,
OIDC_MFA_COOKIE_PATH,
OIDC_MFA_TTL_MS,
OIDC_STATE_COOKIE,
OIDC_STATE_COOKIE_PATH,
} from "@/lib/auth/oidc-cookie";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
const LOGIN_ERROR_URL = "/auth/login";
function errorRedirect(req: NextRequest, reason: string): NextResponse {
const response = NextResponse.redirect(
new URL(`${LOGIN_ERROR_URL}?error=${reason}`, req.url),
);
deleteStateCookie(response);
return response;
}
/**
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
* delete would target `/` and never match, leaving the single-use blob
* alive for the rest of its TTL).
*/
function deleteStateCookie(response: NextResponse): void {
response.cookies.delete({
name: OIDC_STATE_COOKIE,
path: OIDC_STATE_COOKIE_PATH,
});
}
/**
* Closed set of RFC 6749 §4.1.2.1 / OIDC Core §3.1.2.6 authorization error
* codes. The wide-event meta only ever carries a member of this set (or
* "other") — never the raw, attacker-influenceable query value.
*/
const KNOWN_IDP_ERROR_CODES = new Set([
"access_denied",
"invalid_request",
"unauthorized_client",
"unsupported_response_type",
"invalid_scope",
"server_error",
"temporarily_unavailable",
"interaction_required",
"login_required",
"account_selection_required",
"consent_required",
"invalid_request_uri",
"invalid_request_object",
"request_not_supported",
"request_uri_not_supported",
"registration_not_supported",
]);
interface StoredOidcState {
state: string;
nonce: string;
codeVerifier: string;
next: string;
}
function isStoredOidcState(value: unknown): value is StoredOidcState {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Record<string, unknown>).state === "string" &&
typeof (value as Record<string, unknown>).nonce === "string" &&
typeof (value as Record<string, unknown>).codeVerifier === "string" &&
typeof (value as Record<string, unknown>).next === "string"
);
}
/**
* OIDC callback. Must work with NO existing session — this route
* authenticates the user in the first place, unlike the per-account
* integration callbacks (Withings/Polar/etc.) which all require
* `requireAuth()`. Every failure branch redirects to `/auth/login?error=...`
* (never JSON — this is a top-level browser navigation) and deletes the
* single-use state cookie, mirroring `src/app/api/withings/callback/route.ts`.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.callback" } });
const config = getOidcConfig();
if (!config) return errorRedirect(req, "oidc_disabled");
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:oidc:callback",
20,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) return errorRedirect(req, "oidc_rate_limited");
const { searchParams } = req.nextUrl;
const idpError = searchParams.get("error");
if (idpError) {
annotate({
meta: {
reason: "idp_denied",
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
},
});
return errorRedirect(req, "oidc_denied");
}
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) return errorRedirect(req, "oidc_state");
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
let stored: StoredOidcState | null = null;
if (rawCookie) {
try {
const parsed: unknown = JSON.parse(decrypt(rawCookie));
if (isStoredOidcState(parsed)) stored = parsed;
} catch {
stored = null;
}
}
if (!stored) return errorRedirect(req, "oidc_state");
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
if (
state.length !== stored.state.length ||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
) {
annotate({ meta: { reason: "csrf" } });
return errorRedirect(req, "oidc_state");
}
try {
const metadata = await discoverOidcMetadata(config);
const redirectUri = getOidcRedirectUri();
const tokens = await exchangeCodeForTokens({
metadata,
config,
code,
codeVerifier: stored.codeVerifier,
redirectUri,
});
if (!tokens.id_token) return errorRedirect(req, "oidc_failed");
const identity = await verifyIdToken({
metadata,
config,
idToken: tokens.id_token,
nonce: stored.nonce,
});
let email = identity.email;
let emailVerified = identity.emailVerified;
if (!email && tokens.access_token) {
const userinfo = await fetchUserinfoEmail({
metadata,
accessToken: tokens.access_token,
});
email = userinfo.email;
emailVerified = userinfo.emailVerified;
}
// (1) Durable identity match — the (issuer, sub) pair stamped on a
// previous login, link, or provision. Email is deliberately NOT
// consulted here: after the stamp it is a display field, so an
// IdP-side email change can refresh it below but can never re-point
// the login at a different account.
let user = await prisma.user.findFirst({
where: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
});
if (user) {
// Refresh the display email only — and only from a claim the IdP
// explicitly marks verified. Skipped when another account already
// holds the address (`email` is unique); the login itself proceeds
// either way, keyed by (issuer, sub).
const displayEmail = email ? email.toLowerCase() : null;
if (
displayEmail &&
emailVerified === true &&
user.email?.toLowerCase() !== displayEmail
) {
const emailTaken = await prisma.user.findFirst({
where: {
email: { equals: displayEmail, mode: "insensitive" },
NOT: { id: user.id },
},
select: { id: true },
});
if (!emailTaken) {
user = await prisma.user.update({
where: { id: user.id },
data: { email: displayEmail },
});
}
}
} else {
// No stamped identity yet — both remaining paths (link-once,
// provision) bind this (issuer, sub) to an account keyed by email,
// so the claim must be explicitly `email_verified: true`. An ABSENT
// claim is a reject, not a benefit of the doubt: an IdP that does
// not assert verification cannot anchor a link that later gates a
// health record.
if (!email) {
annotate({ meta: { reason: "no_email" } });
return errorRedirect(req, "oidc_no_email");
}
if (emailVerified !== true) {
annotate({ meta: { reason: "email_unverified" } });
return errorRedirect(req, "oidc_email_unverified");
}
const byEmail = await prisma.user.findFirst({
where: { email: { equals: email, mode: "insensitive" } },
});
if (byEmail) {
// (2) Link ONCE. An account already pinned to a different IdP
// identity is never silently re-bound — a rebind would let a
// second IdP identity that acquires the same email capture the
// account. Audited so the operator can see the collision.
if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) {
await auditLog("auth.oidc.link_conflict", {
userId: byEmail.id,
ipAddress: ip,
});
annotate({ meta: { reason: "identity_conflict" } });
return errorRedirect(req, "oidc_identity_conflict");
}
user = await prisma.user.update({
where: { id: byEmail.id },
data: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
});
await auditLog("auth.oidc.linked", {
userId: user.id,
ipAddress: ip,
});
} else {
// (3) Provision.
const userCount = await prisma.user.count();
let registrationEnabled = true;
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (settings && !settings.registrationEnabled && userCount > 0) {
registrationEnabled = false;
}
} catch {
// Table may not exist yet; allow provisioning (matches register/route.ts).
}
if (!registrationEnabled) {
annotate({ meta: { reason: "registration_disabled" } });
return errorRedirect(req, "oidc_registration_disabled");
}
const username = await deriveUniqueUsername(email, (candidate) =>
prisma.user
.findUnique({ where: { username: candidate } })
.then((u: unknown) => u !== null),
);
user = await prisma.user.create({
data: {
email: email.toLowerCase(),
username,
passwordHash: null,
role: userCount === 0 ? "ADMIN" : "USER",
// Same server-default resolution the register route uses — an
// SSO-provisioned account has no registration form to carry the
// browser-detected timezone.
timezone: await resolveServerDefaultTimezone(),
oidcIssuer: metadata.issuer,
oidcSub: identity.sub,
},
});
await auditLog("auth.oidc.provisioned", {
userId: user.id,
ipAddress: ip,
});
}
}
// Native second factor. OIDC is a DELEGATED factor: it proves the IdP
// authenticated someone, but it never substitutes for this app's own
// second factor. An account with a confirmed TOTP secret or a
// registered security key gets the same MFA challenge step password
// login gets — no session is minted here; the single-use ticket rides
// the handoff cookie to the login page, which renders the same
// challenge UI and completes at `/api/auth/mfa/verify`. (The
// trusted-device skip cannot apply on this path: its cookie is
// SameSite=Strict, which the browser withholds on an IdP-initiated
// redirect chain.)
const hasTotp = Boolean(user.totpConfirmedAt);
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: user.id },
});
const hasWebauthn = webauthnKeyCount > 0;
if (hasTotp || hasWebauthn) {
const challenge = await createMfaChallenge(user.id, "login");
const methods: ("totp" | "recovery" | "webauthn")[] = [];
if (hasTotp) methods.push("totp", "recovery");
if (hasWebauthn) methods.push("webauthn");
await auditLog("auth.mfa.challenge", {
userId: user.id,
ipAddress: ip,
details: { source: "login.oidc" },
});
annotate({
action: { name: "auth.mfa.challenge" },
meta: { mfa_required: true },
});
const loginUrl = new URL(LOGIN_ERROR_URL, req.url);
if (stored.next !== "/") {
loginUrl.searchParams.set("next", stored.next);
}
const response = NextResponse.redirect(loginUrl);
deleteStateCookie(response);
response.cookies.set(
OIDC_MFA_COOKIE,
JSON.stringify({ ticket: challenge.ticket, methods }),
{
// Not httpOnly by design — see the constant's doc comment.
httpOnly: false,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OIDC_MFA_TTL_MS / 1000),
path: OIDC_MFA_COOKIE_PATH,
},
);
return response;
}
const ua = req.headers.get("user-agent");
// v1.23 parity with password login — the admin-enforced-MFA hint cookie
// sends a single-factor account into forced enrollment after sign-in.
await syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
});
// The 5th argument stays null: OIDC is a delegated factor, so an SSO
// session must never satisfy `requireFreshMfa` step-up (MFA disable,
// encryption-key rotation, encrypted export, account deletion) — the
// same treatment a trusted-device login gets.
await createSession(
user.id,
user.onboardingCompletedAt === null,
ip,
ua,
null,
);
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip });
const response = NextResponse.redirect(new URL(stored.next, req.url));
deleteStateCookie(response);
return response;
} catch (err) {
getEvent()?.setError(err);
annotate({ meta: { reason: "exchange_or_verify_failed" } });
return errorRedirect(req, "oidc_failed");
}
});
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => null,
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `enc(${s})`),
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
vi.mock("@/lib/auth/oidc", async () => {
// Keep the real `sanitizeOidcNextPath` — it's the security-relevant part
// of this route, and the tests below assert its output round-trips into
// the state cookie rather than re-deriving the same logic here.
const actual =
await vi.importActual<typeof import("@/lib/auth/oidc")>("@/lib/auth/oidc");
return {
getOidcConfig: vi.fn(),
discoverOidcMetadata: vi.fn(),
buildAuthorizationUrl: vi.fn(() => "https://idp.example.com/authorize?x=1"),
getOidcRedirectUri: () =>
"https://healthlog.example.com/api/auth/oidc/callback",
sanitizeOidcNextPath: actual.sanitizeOidcNextPath,
};
});
import { GET } from "../route";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc";
function makeRequest(path = "/api/auth/oidc/login"): NextRequest {
return new NextRequest(`http://localhost${path}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 10,
reset: 0,
ip: "1.2.3.4",
} as never);
});
describe("GET /api/auth/oidc/login", () => {
it("redirects to /auth/login?error=oidc_disabled when unconfigured", async () => {
vi.mocked(getOidcConfig).mockReturnValue(null);
const res = await GET(makeRequest());
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"/auth/login?error=oidc_disabled",
);
});
it("redirects to the rate-limit error page when throttled", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
reset: 0,
ip: "1.2.3.4",
} as never);
const res = await GET(makeRequest());
expect(res.headers.get("location")).toContain("error=oidc_rate_limited");
});
it("redirects to the IdP and sets the encrypted state cookie on success", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(discoverOidcMetadata).mockResolvedValue({
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
});
const res = await GET(makeRequest("/api/auth/oidc/login?next=/dashboard"));
expect(res.headers.get("location")).toBe(
"https://idp.example.com/authorize?x=1",
);
const cookie = res.cookies.get("oidc_auth_state");
expect(cookie?.value).toMatch(/^enc\(/);
expect(cookie?.httpOnly).toBe(true);
expect(cookie?.sameSite).toBe("lax");
});
it("neutralizes a backslash-normalization open-redirect payload in next", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(discoverOidcMetadata).mockResolvedValue({
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
});
const res = await GET(
makeRequest(
`/api/auth/oidc/login?next=${encodeURIComponent("/\\evil.com")}`,
),
);
const cookie = res.cookies.get("oidc_auth_state");
const stored = JSON.parse(
cookie!.value.replace(/^enc\(/, "").replace(/\)$/, ""),
);
expect(stored.next).toBe("/");
});
it("falls back to the login error page on discovery failure", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(discoverOidcMetadata).mockRejectedValue(new Error("boom"));
const res = await GET(makeRequest());
expect(res.headers.get("location")).toContain("error=oidc_failed");
});
});
+97
View File
@@ -0,0 +1,97 @@
import { randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
import {
buildAuthorizationUrl,
discoverOidcMetadata,
getOidcConfig,
getOidcRedirectUri,
sanitizeOidcNextPath,
} from "@/lib/auth/oidc";
import {
OIDC_STATE_COOKIE,
OIDC_STATE_COOKIE_PATH,
OIDC_STATE_TTL_MS,
} from "@/lib/auth/oidc-cookie";
/**
* Redirects the browser to the configured OIDC provider's authorization
* endpoint. Full-page navigation, not a fetch — every failure branch
* redirects to `/auth/login?error=...` rather than returning a JSON
* envelope, mirroring `src/app/api/withings/connect/route.ts`.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.login" } });
const config = getOidcConfig();
if (!config) {
return NextResponse.redirect(
new URL("/auth/login?error=oidc_disabled", req.url),
);
}
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:oidc:login",
10,
15 * 60 * 1000,
);
if (!rl.allowed) {
return NextResponse.redirect(
new URL("/auth/login?error=oidc_rate_limited", req.url),
);
}
let metadata;
try {
metadata = await discoverOidcMetadata(config);
} catch (err) {
getEvent()?.setError(err);
return NextResponse.redirect(
new URL("/auth/login?error=oidc_failed", req.url),
);
}
const state = randomBytes(32).toString("base64url");
const nonce = randomBytes(32).toString("base64url");
const codeVerifier = randomBytes(64).toString("base64url");
const codeChallenge = s256Challenge(codeVerifier);
const safeNext = sanitizeOidcNextPath(
req.nextUrl.searchParams.get("next"),
req.url,
);
const redirectUri = getOidcRedirectUri();
const authorizationUrl = buildAuthorizationUrl({
metadata,
config,
state,
nonce,
codeChallenge,
redirectUri,
});
const response = NextResponse.redirect(authorizationUrl);
const cookiePayload = encrypt(
JSON.stringify({ state, nonce, codeVerifier, next: safeNext }),
);
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
// Cross-site top-level redirect back from the IdP, same reasoning as
// the session cookie and the Withings OAuth state cookie.
sameSite: "lax",
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
// Shared constant — the callback's delete must repeat this exact path
// (RFC 6265 keys cookies by name+domain+path).
path: OIDC_STATE_COOKIE_PATH,
});
return response;
});
+19
View File
@@ -0,0 +1,19 @@
import { apiSuccess } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { getOidcConfig, isOidcOnly } from "@/lib/auth/oidc";
export const dynamic = "force-dynamic";
/** Public — the login page reads this pre-session to decide whether to
* render the SSO button and whether to hide password/passkey login. Pure
* env reads; nothing here can throw. */
export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.oidc.status" } });
const config = getOidcConfig();
return apiSuccess({
enabled: config !== null,
buttonLabel: config?.buttonLabel ?? null,
only: isOidcOnly(),
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/passkey", () => ({
createAuthenticationOptions: vi.fn().mockResolvedValue({
options: {},
challengeId: "ch-1",
}),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
import { POST } from "../route";
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-options", {
method: "POST",
});
}
beforeEach(() => {
vi.resetAllMocks();
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
vi.mocked(createAuthenticationOptions).mockResolvedValue({
options: {},
challengeId: "ch-1",
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
ip: "203.0.113.1",
} as never);
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
describe("POST /api/auth/passkey/login-options — OIDC_ONLY server-side enforcement", () => {
it("rejects before minting a WebAuthn challenge when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(createAuthenticationOptions).not.toHaveBeenCalled();
});
it("still allows passkey login-options when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(200);
});
});
@@ -1,11 +1,19 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: Request) => {
// OIDC_ONLY must block passkey login too, not just password login.
if (isOidcOnly()) {
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
apiToken: { create: vi.fn() },
refreshToken: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/passkey", () => ({
verifyAuthentication: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/mfa-enrollment", () => ({
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/login-alert", () => ({
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ challengeId: "ch-1", credential: { id: "cred-1" } }),
});
}
beforeEach(() => {
vi.resetAllMocks();
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "testuser",
email: "user@example.com",
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: true },
passkey: { userId: "user-1" },
} as never);
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
describe("POST /api/auth/passkey/login-verify — OIDC_ONLY server-side enforcement", () => {
it("rejects passkey login before touching WebAuthn verification when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(verifyAuthentication).not.toHaveBeenCalled();
});
it("still allows passkey login when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(200);
});
});
@@ -17,8 +17,17 @@ import {
isCookielessNativeCaller,
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block passkey login too, not just password login — a
// credential minted before SSO was mandated must not stay a live bypass.
if (isOidcOnly()) {
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
await ensureDbCompatibility();
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
@@ -1,7 +1,7 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/register.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -118,3 +118,50 @@ describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
async function postPathThrough(body: unknown): Promise<Response> {
return POST(postReq(body));
}
describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () => {
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
it("rejects self-registration before any DB lookup when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await postPathThrough({
email: "new@example.com",
username: "newuser",
password: "a-very-strong-password-123",
});
expect(res.status).toBe(403);
});
it("still allows registration when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await postPathThrough({ email: "not-an-email", username: "x" });
// Falls through to normal validation (422 here) rather than 403 — a
// half-set OIDC group must never lock everyone out.
expect(res.status).toBe(422);
});
});
+10
View File
@@ -26,8 +26,18 @@ import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block self-registration too — otherwise anyone can
// self-provision a fresh password account and bypass the operator's
// SSO-only policy outright, invite token or not.
if (isOidcOnly()) {
return apiError("Registration is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
// is misconfigured; otherwise byte-equivalent per-IP semantics.
const rl = await checkAuthSurfaceRateLimit(
+189 -93
View File
@@ -3,7 +3,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Lock, Loader2 } from "lucide-react";
import { KeyRound, Lock, Loader2, Shield } from "lucide-react";
import { Logo } from "@/components/ui/logo";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -20,9 +20,11 @@ import {
apiPost,
} from "@/lib/api/api-fetch";
import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step";
import { OIDC_MFA_COOKIE, OIDC_MFA_COOKIE_PATH } from "@/lib/auth/oidc-cookie";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { sanitizeSameOriginPath } from "@/lib/url-safety";
export default function LoginPage() {
const router = useRouter();
@@ -32,7 +34,18 @@ export default function LoginPage() {
const [mode, setMode] = useState<"passkey" | "password">("passkey");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(() => {
const oidcError = searchParams.get("error");
if (!oidcError) return null;
const key: Record<string, string> = {
oidc_denied: "auth.oidc.errorDenied",
oidc_no_email: "auth.oidc.errorNoEmail",
oidc_email_unverified: "auth.oidc.errorEmailUnverified",
oidc_identity_conflict: "auth.oidc.errorIdentityConflict",
oidc_registration_disabled: "auth.oidc.errorRegistrationDisabled",
};
return t(key[oidcError] ?? "auth.oidc.errorFailed");
});
const [loading, setLoading] = useState(false);
// v1.23 — second step of login when the password response carries
// `meta.mfaRequired`. Holds the opaque ticket + the factors the account can
@@ -66,14 +79,32 @@ export default function LoginPage() {
staleTime: 60 * 1000,
});
const { data: oidcStatus } = useQuery({
queryKey: queryKeys.authOidcStatus(),
queryFn: async () => {
try {
return await apiGet<{
enabled?: boolean;
buttonLabel?: string | null;
only?: boolean;
}>("/api/auth/oidc/status", { cache: "no-store" });
} catch {
return { enabled: false, buttonLabel: null, only: false };
}
},
staleTime: 60 * 1000,
});
function handleOidcLogin() {
const params = new URLSearchParams({ next: getRedirectTarget() });
window.location.href = `/api/auth/oidc/login?${params}`;
}
function getRedirectTarget(): string {
const next = searchParams.get("next");
if (!next) return "/";
// Prevent open redirects: only allow local absolute paths.
if (next.startsWith("/") && !next.startsWith("//")) {
return next;
}
return "/";
return sanitizeSameOriginPath(
searchParams.get("next"),
window.location.href,
);
}
// v1.16.6 first-load waterfall fix — the session cookie exists the
@@ -188,11 +219,50 @@ export default function LoginPage() {
}
}
// OIDC → second-factor handoff. The OAuth callback cannot render UI: for
// a 2FA-enrolled account it mints the same single-use MFA ticket password
// login uses and parks it in a short-lived, login-page-scoped cookie
// (`oidc-cookie.ts` documents why it is readable here). Pick it up once,
// clear it, and enter the exact same MfaLoginStep the password flow uses —
// `next` continuation rides the query string as usual.
useEffect(() => {
const raw = document.cookie
.split("; ")
.find((c) => c.startsWith(`${OIDC_MFA_COOKIE}=`))
?.slice(OIDC_MFA_COOKIE.length + 1);
if (!raw) return;
document.cookie = `${OIDC_MFA_COOKIE}=; path=${OIDC_MFA_COOKIE_PATH}; max-age=0`;
try {
const parsed = JSON.parse(decodeURIComponent(raw)) as {
ticket?: string;
methods?: MfaMethod[];
};
if (parsed.ticket) {
// A lazy useState initializer cannot replace this: `document` does
// not exist during the server render, and seeding client-only state
// there would desync hydration. Mount-time pickup of an external
// one-shot store is exactly the effect escape hatch.
// eslint-disable-next-line react-hooks/set-state-in-effect
setMfaChallenge({
ticket: parsed.ticket,
methods: parsed.methods ?? ["totp", "recovery"],
});
}
} catch {
// Malformed handoff — fall through to the normal login UI.
}
}, []);
// v1.23 — passkey conditional-UI autofill. When the browser supports it,
// arm a discoverable-credential assertion bound to the username field so a
// returning user can pick their passkey straight from the autofill prompt.
// Best-effort: any abort (the user types a password instead) is swallowed.
// Waits for the OIDC status and stays off entirely under OIDC_ONLY —
// passkey login is a dead end there (server-enforced), so arming the
// browser's credential prompt would only dangle a door that is locked.
useEffect(() => {
if (oidcStatus === undefined) return;
if (oidcStatus.only === true) return;
if (autofillStarted.current) return;
autofillStarted.current = true;
let cancelled = false;
@@ -231,7 +301,7 @@ export default function LoginPage() {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [oidcStatus]);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
@@ -268,102 +338,128 @@ export default function LoginPage() {
</div>
) : (
<div className="mt-8 space-y-4">
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
minimum) on mobile. Login is the most-tapped flow on a
fresh install. */}
<Button
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
disabled={loading}
>
{loading && mode === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("auth.loginWithPasskey")}
</Button>
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
{t("common.or")}
</span>
<Separator className="flex-1" />
</div>
{mode === "passkey" ? (
{oidcStatus?.enabled && (
<Button
variant="outline"
onClick={handleOidcLogin}
variant={oidcStatus.only ? "default" : "outline"}
className="min-h-11 w-full"
size="lg"
onClick={() => setMode("password")}
>
<Lock className="h-4 w-4" />
{t("auth.loginWithPassword")}
<Shield className="h-4 w-4" />
{oidcStatus.buttonLabel || t("auth.oidc.signInDefault")}
</Button>
) : (
<form onSubmit={handlePasswordLogin} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
<Input
id="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
// `webauthn` token arms the conditional-UI passkey
// autofill prompt on this field (see the mount effect).
autoComplete="username webauthn"
inputMode="email"
enterKeyHint="next"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder={t("auth.emailOrUsernamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
enterKeyHint="go"
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder="********"
/>
</div>
)}
{oidcStatus?.enabled && !oidcStatus.only && (
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
{t("common.or")}
</span>
<Separator className="flex-1" />
</div>
)}
{!oidcStatus?.only && (
<>
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
minimum) on mobile. Login is the most-tapped flow on a
fresh install. */}
<Button
type="submit"
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
disabled={loading}
>
{loading && mode === "password" ? (
{loading && mode === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="h-4 w-4" />
<KeyRound className="h-4 w-4" />
)}
{t("auth.login")}
{t("auth.loginWithPasskey")}
</Button>
<button
type="button"
onClick={() => setMode("passkey")}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.backToPasskey")}
</button>
</form>
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
{t("common.or")}
</span>
<Separator className="flex-1" />
</div>
{mode === "passkey" ? (
<Button
variant="outline"
className="min-h-11 w-full"
size="lg"
onClick={() => setMode("password")}
>
<Lock className="h-4 w-4" />
{t("auth.loginWithPassword")}
</Button>
) : (
<form onSubmit={handlePasswordLogin} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
<Input
id="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
// `webauthn` token arms the conditional-UI passkey
// autofill prompt on this field (see the mount effect).
autoComplete="username webauthn"
inputMode="email"
enterKeyHint="next"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder={t("auth.emailOrUsernamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
enterKeyHint="go"
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder="********"
/>
</div>
<Button
type="submit"
className="min-h-11 w-full"
size="lg"
disabled={loading}
>
{loading && mode === "password" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="h-4 w-4" />
)}
{t("auth.login")}
</Button>
<button
type="button"
onClick={() => setMode("passkey")}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.backToPasskey")}
</button>
</form>
)}
</>
)}
{error && (
@@ -377,7 +473,7 @@ export default function LoginPage() {
</div>
)}
{registrationEnabled === true && (
{!oidcStatus?.only && registrationEnabled === true && (
<p className="text-muted-foreground text-center text-xs">
{t("auth.noAccount")}{" "}
<Link
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { sanitizeSameOriginPath } from "../url-safety";
const BASE = "https://healthlog.example.com/auth/login";
describe("sanitizeSameOriginPath", () => {
it("passes through a plain same-origin path", () => {
expect(sanitizeSameOriginPath("/dashboard", BASE)).toBe("/dashboard");
});
it("defaults to / when next is null, undefined, or empty", () => {
expect(sanitizeSameOriginPath(null, BASE)).toBe("/");
expect(sanitizeSameOriginPath(undefined, BASE)).toBe("/");
expect(sanitizeSameOriginPath("", BASE)).toBe("/");
});
it("rejects a protocol-relative next (//host)", () => {
expect(sanitizeSameOriginPath("//evil.com", BASE)).toBe("/");
});
it("rejects an absolute next pointing at another origin", () => {
expect(sanitizeSameOriginPath("https://evil.com/phish", BASE)).toBe("/");
});
it("rejects the backslash-normalization bypass (/\\evil.com)", () => {
// WHATWG URL parsing treats a leading backslash as a path separator for
// special schemes, so a naive startsWith("/") && !startsWith("//")
// check would accept this — Next.js's router.push() re-parses the
// string the same way and hard-navigates to the resolved origin.
expect(sanitizeSameOriginPath("/\\evil.com", BASE)).toBe("/");
expect(sanitizeSameOriginPath("\\\\evil.com", BASE)).toBe("/");
});
it("preserves a same-origin path's search and hash", () => {
expect(sanitizeSameOriginPath("/settings?tab=security#top", BASE)).toBe(
"/settings?tab=security#top",
);
});
it("falls back to / on an unparseable value", () => {
expect(sanitizeSameOriginPath("https://", BASE)).toBe("/");
});
});
+489
View File
@@ -0,0 +1,489 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() }));
vi.mock("jose", () => ({
jwtVerify: vi.fn(),
createRemoteJWKSet: vi.fn(() => ({ jwks: "fake" })),
}));
import { safeFetch } from "@/lib/safe-fetch";
import {
createRemoteJWKSet as mockCreateRemoteJWKSet,
jwtVerify as mockJwtVerify,
} from "jose";
import {
_resetOidcCacheForTests,
buildAuthorizationUrl,
deriveUniqueUsername,
discoverOidcMetadata,
exchangeCodeForTokens,
fetchUserinfoEmail,
getOidcConfig,
getOidcRedirectUri,
isOidcConfigured,
isOidcOnly,
sanitizeOidcNextPath,
verifyIdToken,
} from "../oidc";
const ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_SCOPES",
"OIDC_BUTTON_LABEL",
"OIDC_ONLY",
"NEXT_PUBLIC_APP_URL",
] as const;
const ORIGINAL_ENV: Record<string, string | undefined> = {};
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: async () => body,
} as unknown as Response;
}
beforeEach(() => {
for (const key of ENV_KEYS) ORIGINAL_ENV[key] = process.env[key];
vi.resetAllMocks();
_resetOidcCacheForTests();
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (ORIGINAL_ENV[key] === undefined) delete process.env[key];
else process.env[key] = ORIGINAL_ENV[key];
}
});
function configureEnv() {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-123";
process.env.OIDC_CLIENT_SECRET = "secret-abc";
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.com";
}
describe("isOidcConfigured / getOidcConfig", () => {
it("is unconfigured when any of the three vars is missing", () => {
delete process.env.OIDC_ISSUER_URL;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
expect(isOidcConfigured()).toBe(false);
expect(getOidcConfig()).toBeNull();
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-123";
// secret still missing
expect(isOidcConfigured()).toBe(false);
});
it("applies defaults for scopes and button label", () => {
configureEnv();
delete process.env.OIDC_SCOPES;
delete process.env.OIDC_BUTTON_LABEL;
const config = getOidcConfig();
expect(config?.scopes).toBe("openid email profile");
expect(config?.buttonLabel).toBe("Single Sign-On");
// Trailing slash on the issuer is normalised away.
process.env.OIDC_ISSUER_URL = "https://idp.example.com/";
expect(getOidcConfig()?.issuerUrl).toBe("https://idp.example.com");
});
});
describe("isOidcOnly", () => {
it("only takes effect when the provider is fully configured", () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_ONLY = "true";
expect(isOidcOnly()).toBe(false);
configureEnv();
process.env.OIDC_ONLY = "true";
expect(isOidcOnly()).toBe(true);
process.env.OIDC_ONLY = "false";
expect(isOidcOnly()).toBe(false);
});
});
describe("getOidcRedirectUri", () => {
it("derives the callback URL from NEXT_PUBLIC_APP_URL", () => {
configureEnv();
expect(getOidcRedirectUri()).toBe(
"https://healthlog.example.com/api/auth/oidc/callback",
);
});
});
describe("sanitizeOidcNextPath", () => {
const REQUEST_URL = "https://healthlog.example.com/api/auth/oidc/login";
it("passes through a plain same-origin path", () => {
expect(sanitizeOidcNextPath("/dashboard", REQUEST_URL)).toBe("/dashboard");
});
it("defaults to / when next is null or empty", () => {
expect(sanitizeOidcNextPath(null, REQUEST_URL)).toBe("/");
expect(sanitizeOidcNextPath("", REQUEST_URL)).toBe("/");
});
it("rejects a protocol-relative next (//host)", () => {
expect(sanitizeOidcNextPath("//evil.com", REQUEST_URL)).toBe("/");
});
it("rejects an absolute next pointing at another origin", () => {
expect(sanitizeOidcNextPath("https://evil.com/phish", REQUEST_URL)).toBe(
"/",
);
});
it("rejects the backslash-normalization bypass (/\\evil.com)", () => {
// WHATWG URL parsing treats a leading backslash as a path separator
// for special schemes, so a naive startsWith("/") && !startsWith("//")
// check would accept this — resolving through URL and comparing the
// real origin closes that bypass.
expect(sanitizeOidcNextPath("/\\evil.com", REQUEST_URL)).toBe("/");
expect(sanitizeOidcNextPath("\\\\evil.com", REQUEST_URL)).toBe("/");
});
it("preserves a same-origin path's search and hash", () => {
expect(
sanitizeOidcNextPath("/settings?tab=security#top", REQUEST_URL),
).toBe("/settings?tab=security#top");
});
it("falls back to / on an unparseable value", () => {
expect(sanitizeOidcNextPath("https://", REQUEST_URL)).toBe("/");
});
});
const METADATA_DOC = {
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
userinfo_endpoint: "https://idp.example.com/userinfo",
};
describe("discoverOidcMetadata", () => {
beforeEach(() => configureEnv());
it("fetches and validates the discovery document", async () => {
vi.mocked(safeFetch).mockResolvedValue(jsonResponse(METADATA_DOC));
const config = getOidcConfig()!;
const metadata = await discoverOidcMetadata(config);
expect(metadata.token_endpoint).toBe(METADATA_DOC.token_endpoint);
expect(safeFetch).toHaveBeenCalledWith(
"https://idp.example.com/.well-known/openid-configuration",
expect.objectContaining({ method: "GET" }),
);
});
it("caches the result — a second call within the TTL does not refetch", async () => {
vi.mocked(safeFetch).mockResolvedValue(jsonResponse(METADATA_DOC));
const config = getOidcConfig()!;
await discoverOidcMetadata(config);
await discoverOidcMetadata(config);
expect(safeFetch).toHaveBeenCalledTimes(1);
});
it("rejects a discovery doc whose issuer does not match", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ ...METADATA_DOC, issuer: "https://evil.example.com" }),
);
const config = getOidcConfig()!;
await expect(discoverOidcMetadata(config)).rejects.toThrow(
/issuer mismatch/,
);
});
it("tolerates exactly one trailing slash between config and discovery issuer", async () => {
// Several IdPs mint their issuer with a canonical trailing slash while
// the operator configures without one. The provider-sent form is kept
// in the metadata — the ID token's `iss` must match IT byte-exact.
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ ...METADATA_DOC, issuer: "https://idp.example.com/" }),
);
const config = getOidcConfig()!;
const metadata = await discoverOidcMetadata(config);
expect(metadata.issuer).toBe("https://idp.example.com/");
});
it("does not stretch the slash tolerance beyond a single trailing slash", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ ...METADATA_DOC, issuer: "https://idp.example.com//" }),
);
const config = getOidcConfig()!;
await expect(discoverOidcMetadata(config)).rejects.toThrow(
/issuer mismatch/,
);
});
it("rejects a discovery doc missing required fields", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ issuer: METADATA_DOC.issuer }),
);
const config = getOidcConfig()!;
await expect(discoverOidcMetadata(config)).rejects.toThrow(
/missing required fields/,
);
});
});
describe("buildAuthorizationUrl", () => {
it("includes PKCE, state, nonce, and requested scopes", () => {
configureEnv();
const config = getOidcConfig()!;
const url = new URL(
buildAuthorizationUrl({
metadata: METADATA_DOC,
config,
state: "state-1",
nonce: "nonce-1",
codeChallenge: "challenge-1",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
}),
);
expect(url.origin + url.pathname).toBe(METADATA_DOC.authorization_endpoint);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("client_id")).toBe(config.clientId);
expect(url.searchParams.get("state")).toBe("state-1");
expect(url.searchParams.get("nonce")).toBe("nonce-1");
expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("scope")).toBe(config.scopes);
});
});
describe("exchangeCodeForTokens", () => {
beforeEach(() => configureEnv());
it("posts the authorization code + PKCE verifier and returns the token response", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ id_token: "id-token-value", access_token: "at-1" }),
);
const config = getOidcConfig()!;
const tokens = await exchangeCodeForTokens({
metadata: METADATA_DOC,
config,
code: "auth-code",
codeVerifier: "verifier-1",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
});
expect(tokens.id_token).toBe("id-token-value");
const [url, init] = vi.mocked(safeFetch).mock.calls[0];
expect(url).toBe(METADATA_DOC.token_endpoint);
const body = (init as RequestInit).body as string;
expect(body).toContain("grant_type=authorization_code");
expect(body).toContain("code=auth-code");
expect(body).toContain("code_verifier=verifier-1");
});
it("throws on a non-OK response", async () => {
vi.mocked(safeFetch).mockResolvedValue(jsonResponse({}, false, 400));
const config = getOidcConfig()!;
await expect(
exchangeCodeForTokens({
metadata: METADATA_DOC,
config,
code: "bad-code",
codeVerifier: "verifier-1",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
}),
).rejects.toThrow(/token exchange failed/);
});
it("throws when the response is missing id_token", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ access_token: "at" }),
);
const config = getOidcConfig()!;
await expect(
exchangeCodeForTokens({
metadata: METADATA_DOC,
config,
code: "code",
codeVerifier: "verifier",
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
}),
).rejects.toThrow(/missing id_token/);
});
});
describe("verifyIdToken", () => {
beforeEach(() => configureEnv());
it("returns the identity claims on success", async () => {
// Test payloads deliberately omit the full JWTVerifyResult shape.
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: {
sub: "user-sub-1",
email: "person@example.com",
email_verified: true,
name: "Person",
nonce: "nonce-1",
},
} as never);
const config = getOidcConfig()!;
const identity = await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(identity).toEqual({
sub: "user-sub-1",
email: "person@example.com",
emailVerified: true,
name: "Person",
});
});
it("rejects a nonce mismatch", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "wrong-nonce" },
} as never);
const config = getOidcConfig()!;
await expect(
verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
}),
).rejects.toThrow(/nonce mismatch/);
});
it("rejects a token missing sub", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { nonce: "nonce-1" },
} as never);
const config = getOidcConfig()!;
await expect(
verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
}),
).rejects.toThrow(/missing sub claim/);
});
it("verifies with a closed asymmetric algorithm allowlist and bounded clock tolerance", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "nonce-1" },
} as never);
const config = getOidcConfig()!;
await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(mockJwtVerify).toHaveBeenCalledWith(
"id-token",
expect.anything(),
expect.objectContaining({
issuer: METADATA_DOC.issuer,
audience: config.clientId,
algorithms: ["RS256", "PS256", "ES256", "EdDSA"],
clockTolerance: "60s",
}),
);
});
it("pins the JWKS fetch to the issuer's host with a bounded timeout", async () => {
vi.mocked(mockJwtVerify).mockResolvedValue({
payload: { sub: "user-sub-1", nonce: "nonce-1" },
} as never);
const config = getOidcConfig()!;
await verifyIdToken({
metadata: METADATA_DOC,
config,
idToken: "id-token",
nonce: "nonce-1",
});
expect(mockCreateRemoteJWKSet).toHaveBeenCalledWith(
new URL(METADATA_DOC.jwks_uri),
{ timeoutDuration: 15_000 },
);
});
it("rejects a discovery doc whose jwks_uri points at a foreign host", async () => {
const config = getOidcConfig()!;
await expect(
verifyIdToken({
metadata: {
...METADATA_DOC,
jwks_uri: "https://evil.example.com/jwks",
},
config,
idToken: "id-token",
nonce: "nonce-1",
}),
).rejects.toThrow(/jwks_uri host mismatch/);
expect(mockCreateRemoteJWKSet).not.toHaveBeenCalled();
});
});
describe("fetchUserinfoEmail", () => {
it("returns the userinfo email on success", async () => {
vi.mocked(safeFetch).mockResolvedValue(
jsonResponse({ email: "fallback@example.com", email_verified: true }),
);
const result = await fetchUserinfoEmail({
metadata: METADATA_DOC,
accessToken: "at-1",
});
expect(result).toEqual({
email: "fallback@example.com",
emailVerified: true,
});
});
it("fails soft (null email) when the endpoint is missing or errors", async () => {
const noEndpoint = await fetchUserinfoEmail({
metadata: { ...METADATA_DOC, userinfo_endpoint: undefined },
accessToken: "at-1",
});
expect(noEndpoint).toEqual({ email: null, emailVerified: undefined });
vi.mocked(safeFetch).mockRejectedValue(new Error("network down"));
const onError = await fetchUserinfoEmail({
metadata: METADATA_DOC,
accessToken: "at-1",
});
expect(onError).toEqual({ email: null, emailVerified: undefined });
});
});
describe("deriveUniqueUsername", () => {
it("sanitises the email local-part into a valid username", async () => {
const exists = vi.fn().mockResolvedValue(false);
const username = await deriveUniqueUsername(
"j.doe+sso@example.com",
exists,
);
expect(username).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(username.length).toBeGreaterThanOrEqual(3);
expect(username.length).toBeLessThanOrEqual(30);
});
it("appends a numeric suffix on collision", async () => {
const exists = vi
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
const username = await deriveUniqueUsername("taken@example.com", exists);
expect(username).toBe("taken2");
expect(exists).toHaveBeenCalledTimes(3);
});
it("pads a too-short local-part up to the 3-char minimum", async () => {
const exists = vi.fn().mockResolvedValue(false);
const username = await deriveUniqueUsername("a@example.com", exists);
expect(username.length).toBeGreaterThanOrEqual(3);
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Shared between `/api/auth/oidc/login` (writer) and `/api/auth/oidc/callback`
* (reader) — the encrypted, single-use blob carrying `{state, nonce,
* codeVerifier, next}` across the browser round-trip to the IdP and back.
*/
export const OIDC_STATE_COOKIE = "oidc_auth_state";
/**
* RFC 6265 keys a cookie by (name, domain, path) — a delete must repeat the
* exact path the set used or it silently targets a different (non-existent)
* cookie and the stale state blob survives. Every set AND delete goes
* through this constant.
*/
export const OIDC_STATE_COOKIE_PATH = "/api/auth/oidc";
export const OIDC_STATE_TTL_MS = 10 * 60 * 1000;
/**
* MFA handoff between `/api/auth/oidc/callback` (writer) and the login page
* (reader). When the OIDC account has a native second factor, the callback
* mints the same single-use MFA ticket password login uses — but it ends on
* a browser redirect, not a JSON response, so the ticket travels in this
* short-lived, login-page-scoped cookie instead of an envelope `meta`.
* Deliberately NOT httpOnly: the login page's script must read the ticket —
* exactly the exposure the password flow already has, where the ticket
* arrives in a JSON body read by the same script. The ticket alone carries
* no session authority (single-use, hashed at rest, ~5-minute TTL,
* attempt-capped — see `src/lib/auth/mfa/challenge.ts`).
*/
export const OIDC_MFA_COOKIE = "oidc_mfa_handoff";
export const OIDC_MFA_COOKIE_PATH = "/auth/login";
/** Mirrors MFA_CHALLENGE_TTL_MS — the cookie dies with the ticket. */
export const OIDC_MFA_TTL_MS = 5 * 60 * 1000;
+387
View File
@@ -0,0 +1,387 @@
/**
* OpenID Connect (OIDC) relying-party login. A single, env-configured
* provider (Authentik / Keycloak / Authelia / Google Workspace / etc.) — no
* per-provider database table, matching the Polar/Oura/Strava "shared app
* from env" convention. Discovery, PKCE, and token exchange are hand-rolled
* over `safeFetch` (same convention as the Codex OAuth client and the MCP
* OAuth server) rather than pulling in a full OAuth/OIDC framework; `jose`
* is used only for JWKS fetch + ID-token signature/claims verification,
* which is not worth hand-rolling.
*
* None of the outbound calls opt into `requirePublicHost`: a LAN- or
* Tailscale-only IdP (Authentik/Keycloak/Authelia on the same box) is a
* legitimate, common self-host topology, and the issuer comes from operator
* env config — never from user input.
*/
import { createRemoteJWKSet, jwtVerify } from "jose";
import { safeFetch } from "@/lib/safe-fetch";
import { sanitizeSameOriginPath } from "@/lib/url-safety";
export interface OidcConfig {
issuerUrl: string;
clientId: string;
clientSecret: string;
scopes: string;
buttonLabel: string;
}
/** `||`, not `??`: the compose whitelist materialises an unset var as `""`. */
function envOrEmpty(name: string): string {
return process.env[name] || "";
}
export function isOidcConfigured(): boolean {
return Boolean(
envOrEmpty("OIDC_ISSUER_URL") &&
envOrEmpty("OIDC_CLIENT_ID") &&
envOrEmpty("OIDC_CLIENT_SECRET"),
);
}
/**
* `OIDC_ONLY` only takes effect when the provider is actually fully
* configured — a truthy flag with a half-set (or unset) provider group
* must never lock every user out of the app, so it silently falls back to
* "additive" rather than hiding password/passkey login.
*
* Enforced server-side on every password/passkey auth route (`/api/auth/
* login`, `/api/auth/register`, `/api/auth/passkey/{login-options,
* login-verify}`) — the login page hiding those buttons is not the
* boundary. There is currently no OIDC-compatible login flow for the
* native iOS client (it authenticates via password/passkey only, see
* `src/lib/auth/native-client.ts`), so turning this on locks the iOS app
* out of authentication entirely until a native SSO flow exists. Documented
* in `.env.production.example`; check before recommending this to an
* operator running the iOS app.
*/
export function isOidcOnly(): boolean {
return isOidcConfigured() && envOrEmpty("OIDC_ONLY").toLowerCase() === "true";
}
export function getOidcConfig(): OidcConfig | null {
if (!isOidcConfigured()) return null;
return {
issuerUrl: envOrEmpty("OIDC_ISSUER_URL").replace(/\/+$/, ""),
clientId: envOrEmpty("OIDC_CLIENT_ID"),
clientSecret: envOrEmpty("OIDC_CLIENT_SECRET"),
scopes: envOrEmpty("OIDC_SCOPES") || "openid email profile",
buttonLabel: envOrEmpty("OIDC_BUTTON_LABEL") || "Single Sign-On",
};
}
export function getOidcRedirectUri(): string {
return `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback`;
}
/**
* Resolve a caller-supplied post-login `next` path against the request's own
* origin and only accept it if it stays there — see
* `sanitizeSameOriginPath` for why a plain `startsWith` check isn't enough.
*/
export function sanitizeOidcNextPath(
next: string | null,
requestUrl: string,
): string {
return sanitizeSameOriginPath(next, requestUrl);
}
export interface OidcMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
userinfo_endpoint?: string;
}
interface MetadataCacheEntry {
metadata: OidcMetadata;
fetchedAt: number;
}
const METADATA_TTL_MS = 10 * 60 * 1000;
let metadataCache: MetadataCacheEntry | null = null;
let metadataInFlight: Promise<OidcMetadata> | null = null;
let jwksCache: {
issuerUrl: string;
jwks: ReturnType<typeof createRemoteJWKSet>;
} | null = null;
export function _resetOidcCacheForTests(): void {
metadataCache = null;
metadataInFlight = null;
jwksCache = null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/**
* Fetch + validate the provider's `.well-known/openid-configuration`.
* Module-level cached (10 min TTL) with in-flight de-dup so concurrent
* logins don't stampede the discovery endpoint. The doc's own `issuer`
* claim must match the configured issuer URL (RFC 8414 §3.3) — a
* mismatch means the provider is misconfigured or something is spoofing
* the discovery response. The comparison tolerates exactly ONE trailing
* slash on either side (several IdPs mint their issuer with a canonical
* trailing slash while operators configure without one, and vice versa);
* anything beyond that single-slash normalisation is still a hard reject.
* The provider-sent `issuer` is what gets cached and later matched against
* the ID token's `iss` claim byte-exact — the tolerance applies only to
* the operator-config comparison, never to token verification.
*/
export async function discoverOidcMetadata(
config: OidcConfig,
): Promise<OidcMetadata> {
const now = Date.now();
if (metadataCache && now - metadataCache.fetchedAt < METADATA_TTL_MS) {
return metadataCache.metadata;
}
if (metadataInFlight) return metadataInFlight;
metadataInFlight = (async () => {
try {
const res = await safeFetch(
`${config.issuerUrl}/.well-known/openid-configuration`,
{ method: "GET", headers: { Accept: "application/json" } },
);
if (!res.ok) {
throw new Error(`discovery request failed: ${res.status}`);
}
const json: unknown = await res.json();
if (
!isRecord(json) ||
typeof json.issuer !== "string" ||
typeof json.authorization_endpoint !== "string" ||
typeof json.token_endpoint !== "string" ||
typeof json.jwks_uri !== "string"
) {
throw new Error("discovery document missing required fields");
}
const stripOneTrailingSlash = (value: string): string =>
value.endsWith("/") ? value.slice(0, -1) : value;
if (
stripOneTrailingSlash(json.issuer) !==
stripOneTrailingSlash(config.issuerUrl)
) {
throw new Error(
`discovery issuer mismatch: expected ${config.issuerUrl}, got ${json.issuer}`,
);
}
const metadata: OidcMetadata = {
issuer: json.issuer,
authorization_endpoint: json.authorization_endpoint,
token_endpoint: json.token_endpoint,
jwks_uri: json.jwks_uri,
userinfo_endpoint:
typeof json.userinfo_endpoint === "string"
? json.userinfo_endpoint
: undefined,
};
metadataCache = { metadata, fetchedAt: Date.now() };
return metadata;
} finally {
metadataInFlight = null;
}
})();
return metadataInFlight;
}
function getJwks(metadata: OidcMetadata, config: OidcConfig) {
if (jwksCache && jwksCache.issuerUrl === metadata.issuer) {
return jwksCache.jwks;
}
// `jose`'s own fetch to the discovery-supplied `jwks_uri` is a
// deliberate, scoped exception to the safe-fetch-required convention
// (the ESLint rule only lints project source, not library internals).
// It is disciplined two ways instead: the `jwks_uri` host must exactly
// equal the operator-configured issuer's host — a spoofed or sloppy
// discovery doc cannot point key fetching at a third party — and the
// fetch carries the same 15 s timeout `safeFetch` defaults to.
const jwksUrl = new URL(metadata.jwks_uri);
const issuerHost = new URL(config.issuerUrl).host;
if (jwksUrl.host !== issuerHost) {
throw new Error(
`jwks_uri host mismatch: expected ${issuerHost}, got ${jwksUrl.host}`,
);
}
const jwks = createRemoteJWKSet(jwksUrl, { timeoutDuration: 15_000 });
jwksCache = { issuerUrl: metadata.issuer, jwks };
return jwks;
}
export function buildAuthorizationUrl(params: {
metadata: OidcMetadata;
config: OidcConfig;
state: string;
nonce: string;
codeChallenge: string;
redirectUri: string;
}): string {
const url = new URL(params.metadata.authorization_endpoint);
url.search = new URLSearchParams({
response_type: "code",
client_id: params.config.clientId,
redirect_uri: params.redirectUri,
scope: params.config.scopes,
state: params.state,
nonce: params.nonce,
code_challenge: params.codeChallenge,
code_challenge_method: "S256",
}).toString();
return url.toString();
}
interface OidcTokenResponse {
id_token?: string;
access_token?: string;
token_type?: string;
expires_in?: number;
}
export async function exchangeCodeForTokens(params: {
metadata: OidcMetadata;
config: OidcConfig;
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<OidcTokenResponse> {
const res = await safeFetch(params.metadata.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.config.clientId,
client_secret: params.config.clientSecret,
code_verifier: params.codeVerifier,
}).toString(),
});
if (!res.ok) {
throw new Error(`token exchange failed: ${res.status}`);
}
const json: unknown = await res.json();
if (!isRecord(json) || typeof json.id_token !== "string") {
throw new Error("token response missing id_token");
}
return json as OidcTokenResponse;
}
export interface OidcIdentity {
sub: string;
email: string | null;
emailVerified: boolean | undefined;
name: string | null;
}
/**
* Verify the ID token's signature (against the provider's live JWKS),
* `iss`, `aud`, and `exp`, then confirm the `nonce` claim matches the one
* minted at `/api/auth/oidc/login`. Signature verification matters even
* though this token arrived over a direct back-channel HTTPS call to the
* token endpoint (not a browser-relayed redirect) — it's still the
* documented defense against a provider-side key-confusion attack.
*/
export async function verifyIdToken(params: {
metadata: OidcMetadata;
config: OidcConfig;
idToken: string;
nonce: string;
}): Promise<OidcIdentity> {
const jwks = getJwks(params.metadata, params.config);
const { payload } = await jwtVerify(params.idToken, jwks, {
issuer: params.metadata.issuer,
audience: params.config.clientId,
// Closed allowlist of asymmetric signature algorithms — every ID token
// must be provider-key signed. Never extend this with an HMAC (HS*)
// entry: a symmetric alg would let anyone who knows the client secret
// forge identities.
algorithms: ["RS256", "PS256", "ES256", "EdDSA"],
// Self-hosted IdP + app on different boxes routinely drift a few
// seconds apart; a minute of tolerance on exp/iat/nbf absorbs that
// without meaningfully extending a token's life.
clockTolerance: "60s",
});
if (typeof payload.sub !== "string" || !payload.sub) {
throw new Error("id token missing sub claim");
}
if (payload.nonce !== params.nonce) {
throw new Error("id token nonce mismatch");
}
return {
sub: payload.sub,
email: typeof payload.email === "string" ? payload.email : null,
emailVerified:
typeof payload.email_verified === "boolean"
? payload.email_verified
: undefined,
name: typeof payload.name === "string" ? payload.name : null,
};
}
/**
* Fallback for providers that omit `email` from the ID token's granted
* scopes but expose it via userinfo. Best-effort: any failure here just
* leaves the identity's email null and the caller rejects the login.
*/
export async function fetchUserinfoEmail(params: {
metadata: OidcMetadata;
accessToken: string;
}): Promise<{ email: string | null; emailVerified: boolean | undefined }> {
if (!params.metadata.userinfo_endpoint) {
return { email: null, emailVerified: undefined };
}
try {
const res = await safeFetch(params.metadata.userinfo_endpoint, {
method: "GET",
headers: {
Authorization: `Bearer ${params.accessToken}`,
Accept: "application/json",
},
});
if (!res.ok) return { email: null, emailVerified: undefined };
const json: unknown = await res.json();
if (!isRecord(json)) return { email: null, emailVerified: undefined };
return {
email: typeof json.email === "string" ? json.email : null,
emailVerified:
typeof json.email_verified === "boolean"
? json.email_verified
: undefined,
};
} catch {
return { email: null, emailVerified: undefined };
}
}
/**
* Derive a `registerSchema`-valid username (`^[a-zA-Z0-9_-]+$`, 3-30 chars)
* from an auto-provisioned SSO account's email local-part, de-duped via the
* caller-supplied existence check. DB-free and pure so it's unit-testable
* without a Prisma mock; the callback route supplies `exists` bound to
* `prisma.user.findUnique`.
*/
export async function deriveUniqueUsername(
email: string,
exists: (candidate: string) => Promise<boolean>,
): Promise<string> {
const localPart = email.split("@")[0] ?? "user";
let base = localPart.replace(/[^a-zA-Z0-9_-]/g, "");
if (base.length < 3) base = `${base}user`.slice(0, 3).padEnd(3, "0");
base = base.slice(0, 26); // leave room for a numeric suffix up to 30 chars
let candidate = base;
let suffix = 0;
while (await exists(candidate)) {
suffix += 1;
candidate = `${base}${suffix}`;
}
return candidate;
}
+1
View File
@@ -14,6 +14,7 @@ export const authKeys = {
*/
authMe: () => ["auth", "me"] as const,
authRegistrationStatus: () => ["auth", "registration-status"] as const,
authOidcStatus: () => ["auth", "oidc", "status"] as const,
/**
* v1.4.40 W-RSC — Settings → AI surfaces and the targets editor
* subscribe to the user-thresholds API. Centralise the key so a
+32
View File
@@ -0,0 +1,32 @@
/**
* Isomorphic (client + server) — no `node:crypto`/`jose`/server-only imports,
* so client components can pull this in without dragging server auth code
* into the browser bundle.
*/
/**
* Resolve a caller-supplied "redirect back to" path against a base URL and
* only accept it if it stays on that origin. A plain `startsWith("/") &&
* !startsWith("//")` string check is not enough — WHATWG URL parsing treats
* a leading backslash as a path separator for special schemes, so a value
* like `/\evil.com` passes that check but resolves off-origin (confirmed:
* `router.push()` on such a value drives Next.js's client router into a
* real cross-origin `location.assign()`, not just client-side History
* state). Resolving through `new URL()` first and comparing the real
* origin closes that class of bypass instead of pattern-matching the raw
* string.
*/
export function sanitizeSameOriginPath(
next: string | null | undefined,
baseUrl: string,
): string {
if (!next) return "/";
try {
const resolved = new URL(next, baseUrl);
const base = new URL(baseUrl);
if (resolved.origin !== base.origin) return "/";
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
} catch {
return "/";
}
}
+8
View File
@@ -15,6 +15,14 @@ const PUBLIC_PATHS = [
"/api/auth/registration-status",
"/api/auth/passkey/login-options",
"/api/auth/passkey/login-verify",
// OIDC SSO login. `login` starts the redirect to the IdP, `callback`
// completes it and mints the session — both must be reachable with no
// existing session, since they authenticate the user in the first
// place. `status` is the public flag the login page reads to decide
// whether to render the SSO button.
"/api/auth/oidc/login",
"/api/auth/oidc/callback",
"/api/auth/oidc/status",
"/api/health",
"/api/version",
"/api/notifications/vapid",