mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge pull request #485 from balajiv113/feat/oidc-sso-login
feat(auth): add OIDC SSO login
This commit is contained in:
37 files changed
+2984
-97
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -21,7 +21,9 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
|
||||
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
|
||||
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
|
||||
Claude Desktop at it.
|
||||
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
|
||||
— OIDC SSO login: IdP setup, the identity-pinning security model,
|
||||
`OIDC_ONLY` consequences, and the break-glass runbook.
|
||||
- [`migration/`](./migration/) — release-by-release migration notes.
|
||||
Read the entry for the version you're upgrading from.
|
||||
- [`audit/`](./audit/) — per-release audit summaries archived for
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# SSO login (OIDC)
|
||||
|
||||
HealthLog can delegate sign-in to a self-hosted OpenID Connect identity
|
||||
provider — Authentik, Keycloak, Authelia, Pocket-ID, or anything else
|
||||
that serves a standard discovery document. The flow is the OIDC
|
||||
authorization-code flow with PKCE against a single, env-configured
|
||||
provider; by default it is **additive** (the SSO button appears next to
|
||||
password and passkey login), with an explicit opt-in to make it the only
|
||||
way in.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Register a **confidential** OIDC client at your IdP with the redirect
|
||||
URI
|
||||
|
||||
```
|
||||
${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
The comparison at the IdP is byte-exact: scheme, host, port, and path
|
||||
all matter. `https://healthlog.example.com/api/auth/oidc/callback` and
|
||||
`http://healthlog.example.com/api/auth/oidc/callback` are different
|
||||
URIs, and a trailing slash breaks the match.
|
||||
|
||||
2. Set the three provider vars (all three together enable the login-page
|
||||
button; anything less keeps SSO fully disabled):
|
||||
|
||||
```env
|
||||
OIDC_ISSUER_URL="https://idp.example.com"
|
||||
OIDC_CLIENT_ID="healthlog"
|
||||
OIDC_CLIENT_SECRET="..."
|
||||
```
|
||||
|
||||
The issuer must serve
|
||||
`<issuer>/.well-known/openid-configuration`. Prefer HTTPS — the
|
||||
authorization code and the token response carrying the ID token cross
|
||||
this connection. Plain HTTP is defensible only on a fully trusted
|
||||
private network (IdP and app on the same box, a Tailscale mesh, a
|
||||
VPN); a LAN-only issuer is explicitly supported.
|
||||
|
||||
3. Optional tuning:
|
||||
|
||||
```env
|
||||
OIDC_SCOPES="openid email profile" # default shown
|
||||
OIDC_BUTTON_LABEL="Single Sign-On" # default shown
|
||||
OIDC_ONLY="false" # see below before changing
|
||||
```
|
||||
|
||||
4. Remember the compose env whitelist: every `OIDC_*` var must appear
|
||||
under `environment:` in `docker-compose.yml` (the shipped file lists
|
||||
them) — vars not on the whitelist never reach the container even when
|
||||
`.env` has them.
|
||||
|
||||
`pnpm check-env` knows all six vars and flags a partially configured
|
||||
provider group.
|
||||
|
||||
## Per-IdP notes
|
||||
|
||||
Client registration essentials only — the IdP's own docs cover the rest.
|
||||
|
||||
- **Authentik.** Create a _Provider_ of type OAuth2/OpenID (confidential
|
||||
client), bind it to an _Application_, and use the provider's issuer URL
|
||||
(shaped like `https://authentik.example.com/application/o/<app-slug>/`
|
||||
— the trailing slash is part of Authentik's issuer; the discovery
|
||||
check tolerates the difference). Signing key: any RSA/EC key; the
|
||||
default RS256 works.
|
||||
- **Keycloak.** Create a client with _Client authentication_ ON and the
|
||||
_Standard flow_ enabled. The issuer is
|
||||
`https://keycloak.example.com/realms/<realm>`. Keep the default RS256
|
||||
signature algorithm; do not switch the client to `HS256` — HealthLog
|
||||
rejects HMAC-signed ID tokens.
|
||||
- **Authelia.** Define a client under `identity_providers.oidc.clients`
|
||||
with `token_endpoint_auth_method: client_secret_post` (HealthLog sends
|
||||
the secret in the POST body) and the redirect URI above. The issuer is
|
||||
Authelia's root URL.
|
||||
- **Pocket-ID.** Add an OIDC client, copy the generated ID and secret,
|
||||
and use the Pocket-ID base URL as the issuer. Pocket-ID authenticates
|
||||
users by passkey at the IdP; to HealthLog it is a provider like any
|
||||
other.
|
||||
|
||||
## Security model
|
||||
|
||||
- **Identity is pinned, not guessed.** The first OIDC sign-in either
|
||||
provisions a fresh account or links to an existing account matched by
|
||||
**verified** email (`email_verified: true` — an IdP that does not
|
||||
assert verification is rejected). Either way the account is stamped
|
||||
with the provider's `(issuer, sub)` pair, and every later login
|
||||
matches on that pair alone. An email change at the IdP updates the
|
||||
displayed email; it can never re-point the login at a different
|
||||
account. An email that matches an account already pinned to a
|
||||
_different_ identity is rejected and audited.
|
||||
- **SSO sessions never satisfy step-up.** Destructive actions (disabling
|
||||
MFA, rotating encryption keys, encrypted export, account deletion)
|
||||
require a _fresh_ second factor confirmed by HealthLog itself. An OIDC
|
||||
login is a delegated factor — whatever the IdP checked, this app did
|
||||
not see it — so an SSO session always has to confirm a native factor
|
||||
(or the password path) when it hits one of those gates.
|
||||
- **Accounts with native 2FA keep it.** If the account has a confirmed
|
||||
TOTP secret or a registered security key, an OIDC login does not
|
||||
complete on its own: after the IdP round-trip the user lands in the
|
||||
same second-factor step password login uses. SSO never downgrades an
|
||||
account's own MFA.
|
||||
- **MFA belongs at the IdP.** For SSO-provisioned accounts, enforce
|
||||
MFA in the IdP's policy engine — that is the point of delegating
|
||||
authentication. HealthLog-side 2FA remains available on top for the
|
||||
step-up gates above.
|
||||
- **Linking is once.** There is no unlink/relink flow in this cut; the
|
||||
pin is deliberately sticky. Moving an account to a new IdP identity is
|
||||
an operator decision (a manual `users.oidc_issuer` / `users.oidc_sub`
|
||||
update), not something a login can do.
|
||||
|
||||
## OIDC_ONLY
|
||||
|
||||
`OIDC_ONLY="true"` disables password and passkey login **server-side**
|
||||
(the routes return 403; hiding the buttons is not the boundary).
|
||||
Consequences to accept before turning it on:
|
||||
|
||||
- **The native iOS app cannot sign in.** It authenticates via
|
||||
password/passkey only; there is no native SSO flow yet. Do not enable
|
||||
`OIDC_ONLY` on a deployment with active iOS users.
|
||||
- **Existing sessions survive.** The flag gates new sign-ins, not live
|
||||
sessions — nobody is thrown out at flip time, including
|
||||
password-authenticated sessions.
|
||||
- **Self-disabling on half-config.** The flag only takes effect while
|
||||
the three provider vars are fully set, so a typo in the provider group
|
||||
can never lock every user out.
|
||||
|
||||
### Break-glass (IdP down or misconfigured)
|
||||
|
||||
1. Unset `OIDC_ONLY` (or set it to `"false"`) in `.env`, then
|
||||
`docker compose up -d`. Password and passkey login are live again
|
||||
immediately.
|
||||
2. Accounts that were **provisioned** through SSO have no password. Set
|
||||
one from the host, per [docs/ops/password-reset.md](../ops/password-reset.md):
|
||||
|
||||
```sh
|
||||
docker compose exec app node scripts/reset-password.mjs <username-or-email>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
|
||||
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
|
||||
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
|
||||
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
|
||||
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
|
||||
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
|
||||
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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("/");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 "/";
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user