diff --git a/.env.production.example b/.env.production.example index 1bf6cdfd5..7958d328a 100644 --- a/.env.production.example +++ b/.env.production.example @@ -103,6 +103,29 @@ API_TOKEN_HMAC_KEY="" # 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. +# 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) diff --git a/CLAUDE.md b/CLAUDE.md index 32fcdcc79..65047b225 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 4fa828fbb..469ab0a13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/messages/de.json b/messages/de.json index cca68b5f1..bf91cb1de 100644 --- a/messages/de.json +++ b/messages/de.json @@ -286,6 +286,13 @@ "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.", + "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.", diff --git a/messages/en.json b/messages/en.json index 4a0fc6c37..5e939bbec 100644 --- a/messages/en.json +++ b/messages/en.json @@ -286,6 +286,13 @@ "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.", + "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.", diff --git a/messages/es.json b/messages/es.json index 41b9df989..9e81610cf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -286,6 +286,13 @@ "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.", + "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.", diff --git a/messages/fr.json b/messages/fr.json index 1b3c8846e..34d5599ce 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -286,6 +286,13 @@ "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.", + "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.", diff --git a/messages/it.json b/messages/it.json index f256c5613..df288e671 100644 --- a/messages/it.json +++ b/messages/it.json @@ -286,6 +286,13 @@ "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.", + "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.", diff --git a/messages/pl.json b/messages/pl.json index ed624c9c1..02ac53576 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -286,6 +286,13 @@ "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.", + "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.", diff --git a/package.json b/package.json index e371aceea..d90927f33 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c75a0116..7a3fc219a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index bda14de83..effc45fda 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -78,6 +78,26 @@ } ] }, + { + "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 /.well-known/openid-configuration)." + }, + { + "name": "OIDC_CLIENT_ID", + "purpose": "OIDC confidential client id." + }, + { + "name": "OIDC_CLIENT_SECRET", + "purpose": "OIDC confidential client secret." + } + ] + }, { "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 required row whenever 1-3 of the 4 are set.", diff --git a/src/app/api/auth/login/__tests__/route.test.ts b/src/app/api/auth/login/__tests__/route.test.ts index 6d62abc84..c82732f95 100644 --- a/src/app/api/auth/login/__tests__/route.test.ts +++ b/src/app/api/auth/login/__tests__/route.test.ts @@ -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 = {}; + + 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); + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 8093c3a80..6d48a15b5 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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. diff --git a/src/app/api/auth/oidc/callback/__tests__/route.test.ts b/src/app/api/auth/oidc/callback/__tests__/route.test.ts new file mode 100644 index 000000000..3e81ecaf7 --- /dev/null +++ b/src/app/api/auth/oidc/callback/__tests__/route.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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(), + }, + appSettings: { findUnique: 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/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 { createSession } from "@/lib/auth/session"; +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", +}; + +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 { + return `enc(${JSON.stringify(payload)})`; +} + +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); +}); + +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 verified email", async () => { + vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" }); + vi.mocked(verifyIdToken).mockResolvedValue({ + sub: "sub-1", + email: null, + emailVerified: undefined, + name: null, + }); + const res = await GET( + makeRequest({ + query: "?code=abc&state=STATE", + cookie: stateCookie({ + state: "STATE", + nonce: "nonce-1", + codeVerifier: "verifier-1", + next: "/", + }), + }), + ); + expect(res.headers.get("location")).toContain("error=oidc_no_email"); + }); + + it("logs in an existing user matched by email without provisioning", async () => { + vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" }); + vi.mocked(verifyIdToken).mockResolvedValue({ + sub: "sub-1", + email: "existing@example.com", + emailVerified: true, + name: "Existing", + }); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "user-1", + onboardingCompletedAt: new Date(), + } as never); + + const res = await GET( + makeRequest({ + query: "?code=abc&state=STATE", + cookie: stateCookie({ + state: "STATE", + nonce: "nonce-1", + codeVerifier: "verifier-1", + next: "/dashboard", + }), + }), + ); + + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(createSession).toHaveBeenCalledWith( + "user-1", + false, + "1.2.3.4", + null, + expect.any(Date), + ); + expect(res.headers.get("location")).toContain("/dashboard"); + }); + + it("auto-provisions a new user when registration is open", async () => { + vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" }); + vi.mocked(verifyIdToken).mockResolvedValue({ + sub: "sub-2", + email: "new@example.com", + emailVerified: true, + name: "New", + }); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.count).mockResolvedValue(3); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + registrationEnabled: true, + } as never); + vi.mocked(prisma.user.create).mockResolvedValue({ + id: "user-new", + onboardingCompletedAt: null, + } as never); + + const res = await GET( + makeRequest({ + query: "?code=abc&state=STATE", + cookie: stateCookie({ + state: "STATE", + nonce: "nonce-1", + codeVerifier: "verifier-1", + next: "/", + }), + }), + ); + + expect(prisma.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + email: "new@example.com", + passwordHash: null, + role: "USER", + }), + }), + ); + expect(createSession).toHaveBeenCalled(); + expect(res.status).toBe(307); + }); + + it("refuses to auto-provision when registration is closed", async () => { + vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" }); + vi.mocked(verifyIdToken).mockResolvedValue({ + sub: "sub-3", + email: "blocked@example.com", + emailVerified: true, + name: null, + }); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.count).mockResolvedValue(5); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + registrationEnabled: false, + } as never); + + const res = await GET( + makeRequest({ + query: "?code=abc&state=STATE", + cookie: stateCookie({ + state: "STATE", + nonce: "nonce-1", + codeVerifier: "verifier-1", + next: "/", + }), + }), + ); + + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + expect(res.headers.get("location")).toContain( + "error=oidc_registration_disabled", + ); + }); + + it("redirects with oidc_failed when the token exchange throws", async () => { + vi.mocked(exchangeCodeForTokens).mockRejectedValue(new Error("network")); + const res = await GET( + makeRequest({ + query: "?code=abc&state=STATE", + cookie: stateCookie({ + state: "STATE", + nonce: "nonce-1", + codeVerifier: "verifier-1", + next: "/", + }), + }), + ); + expect(res.headers.get("location")).toContain("error=oidc_failed"); + }); +}); diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts new file mode 100644 index 000000000..262dca3a1 --- /dev/null +++ b/src/app/api/auth/oidc/callback/route.ts @@ -0,0 +1,211 @@ +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_STATE_COOKIE } from "@/lib/auth/oidc-cookie"; + +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), + ); + response.cookies.delete(OIDC_STATE_COOKIE); + return response; +} + +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).state === "string" && + typeof (value as Record).nonce === "string" && + typeof (value as Record).codeVerifier === "string" && + typeof (value as Record).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 } }); + 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; + } + + // Accept a verified email, or one with no `email_verified` claim at + // all (some providers never send it) — but never one explicitly + // marked unverified. + if (!email || emailVerified === false) { + annotate({ meta: { reason: "no_verified_email" } }); + return errorRedirect(req, "oidc_no_email"); + } + + let user = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + }); + + if (!user) { + 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, + username, + passwordHash: null, + role: userCount === 0 ? "ADMIN" : "USER", + }, + }); + + await auditLog("auth.oidc.provisioned", { + userId: user.id, + ipAddress: ip, + }); + } + + const ua = req.headers.get("user-agent"); + // The IdP already performed its own authentication — OIDC login + // satisfies HealthLog's own MFA step-up, the same treatment passkey + // login gives its own factor. + await createSession( + user.id, + user.onboardingCompletedAt === null, + ip, + ua, + new Date(), + ); + + 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)); + response.cookies.delete(OIDC_STATE_COOKIE); + return response; + } catch (err) { + getEvent()?.setError(err); + annotate({ meta: { reason: "exchange_or_verify_failed" } }); + return errorRedirect(req, "oidc_failed"); + } +}); diff --git a/src/app/api/auth/oidc/login/__tests__/route.test.ts b/src/app/api/auth/oidc/login/__tests__/route.test.ts new file mode 100644 index 000000000..1789d23fa --- /dev/null +++ b/src/app/api/auth/oidc/login/__tests__/route.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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("@/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"); + }); +}); diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts new file mode 100644 index 000000000..8d21cc3e6 --- /dev/null +++ b/src/app/api/auth/oidc/login/route.ts @@ -0,0 +1,91 @@ +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_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), + path: "/api/auth/oidc", + }); + + return response; +}); diff --git a/src/app/api/auth/oidc/status/route.ts b/src/app/api/auth/oidc/status/route.ts new file mode 100644 index 000000000..a51b115f7 --- /dev/null +++ b/src/app/api/auth/oidc/status/route.ts @@ -0,0 +1,24 @@ +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. */ +export const GET = apiHandler(async () => { + try { + const config = getOidcConfig(); + annotate({ action: { name: "auth.oidc.status" } }); + return apiSuccess({ + enabled: config !== null, + buttonLabel: config?.buttonLabel ?? null, + only: isOidcOnly(), + }); + } catch { + // Fail closed, matching registration-status. + annotate({ action: { name: "auth.oidc.status" } }); + return apiSuccess({ enabled: false, buttonLabel: null, only: false }); + } +}); diff --git a/src/app/api/auth/passkey/login-options/__tests__/oidc-only.test.ts b/src/app/api/auth/passkey/login-options/__tests__/oidc-only.test.ts new file mode 100644 index 000000000..7124ea5f9 --- /dev/null +++ b/src/app/api/auth/passkey/login-options/__tests__/oidc-only.test.ts @@ -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 = {}; + +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); + }); +}); diff --git a/src/app/api/auth/passkey/login-options/route.ts b/src/app/api/auth/passkey/login-options/route.ts index bb3ae3a38..e9ea2e6dc 100644 --- a/src/app/api/auth/passkey/login-options/route.ts +++ b/src/app/api/auth/passkey/login-options/route.ts @@ -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, diff --git a/src/app/api/auth/passkey/login-verify/__tests__/oidc-only.test.ts b/src/app/api/auth/passkey/login-verify/__tests__/oidc-only.test.ts new file mode 100644 index 000000000..5d451530f --- /dev/null +++ b/src/app/api/auth/passkey/login-verify/__tests__/oidc-only.test.ts @@ -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 = {}; + +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); + }); +}); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 6f3f68374..9b2b36878 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -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. diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index c73f0a211..c7e9372e1 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -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 { 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 = {}; + + 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); + }); +}); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index fb74e611e..a8acb77cb 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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( diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index a1f3caac4..a90b22a66 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -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"; @@ -23,6 +23,7 @@ import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step"; 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 +33,16 @@ export default function LoginPage() { const [mode, setMode] = useState<"passkey" | "password">("passkey"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [error, setError] = useState(null); + const [error, setError] = useState(() => { + const oidcError = searchParams.get("error"); + if (!oidcError) return null; + const key: Record = { + oidc_denied: "auth.oidc.errorDenied", + oidc_no_email: "auth.oidc.errorNoEmail", + 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 +76,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 @@ -268,102 +296,128 @@ export default function LoginPage() { ) : (
- {/* 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. */} - - -
- - - {t("common.or")} - - -
- - {mode === "passkey" ? ( + {oidcStatus?.enabled && ( - ) : ( -
-
- - 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")} - /> -
-
- - setPassword(e.target.value)} - required - autoComplete="current-password" - enterKeyHint="go" - aria-required="true" - aria-invalid={!!error || undefined} - aria-describedby={errorDescriptor} - placeholder="********" - /> -
+ )} + + {oidcStatus?.enabled && !oidcStatus.only && ( +
+ + + {t("common.or")} + + +
+ )} + + {!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. */} - -
+ +
+ + + {t("common.or")} + + +
+ + {mode === "passkey" ? ( + + ) : ( +
+
+ + 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")} + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + enterKeyHint="go" + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} + placeholder="********" + /> +
+ + +
+ )} + )} {error && ( @@ -377,7 +431,7 @@ export default function LoginPage() {
)} - {registrationEnabled === true && ( + {!oidcStatus?.only && registrationEnabled === true && (

{t("auth.noAccount")}{" "} { + 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("/"); + }); +}); diff --git a/src/lib/auth/__tests__/oidc.test.ts b/src/lib/auth/__tests__/oidc.test.ts new file mode 100644 index 000000000..3cf9c37a0 --- /dev/null +++ b/src/lib/auth/__tests__/oidc.test.ts @@ -0,0 +1,408 @@ +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 { 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 = {}; + +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("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/); + }); +}); + +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); + }); +}); diff --git a/src/lib/auth/oidc-cookie.ts b/src/lib/auth/oidc-cookie.ts new file mode 100644 index 000000000..3aae3d1e8 --- /dev/null +++ b/src/lib/auth/oidc-cookie.ts @@ -0,0 +1,7 @@ +/** + * 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"; +export const OIDC_STATE_TTL_MS = 10 * 60 * 1000; diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts new file mode 100644 index 000000000..5401a8c5f --- /dev/null +++ b/src/lib/auth/oidc.ts @@ -0,0 +1,353 @@ +/** + * 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. + */ +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 | null = null; +let jwksCache: { + issuerUrl: string; + jwks: ReturnType; +} | null = null; + +export function _resetOidcCacheForTests(): void { + metadataCache = null; + metadataInFlight = null; + jwksCache = null; +} + +function isRecord(value: unknown): value is Record { + 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. + */ +export async function discoverOidcMetadata( + config: OidcConfig, +): Promise { + 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"); + } + if (json.issuer !== 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) { + if (jwksCache && jwksCache.issuerUrl === metadata.issuer) { + return jwksCache.jwks; + } + // `jose`'s own fetch to the trusted, discovery-supplied `jwks_uri` is a + // deliberate, scoped exception to the safe-fetch-required convention — + // the target host comes from the operator-configured issuer, never from + // user input, and the ESLint rule only lints project source, not + // library internals. + const jwks = createRemoteJWKSet(new URL(metadata.jwks_uri)); + 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 { + 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 { + const jwks = getJwks(params.metadata); + const { payload } = await jwtVerify(params.idToken, jwks, { + issuer: params.metadata.issuer, + audience: params.config.clientId, + }); + + 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, +): Promise { + 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; +} diff --git a/src/lib/query-keys/auth.ts b/src/lib/query-keys/auth.ts index 197f1ebe2..53028e2e1 100644 --- a/src/lib/query-keys/auth.ts +++ b/src/lib/query-keys/auth.ts @@ -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 diff --git a/src/lib/url-safety.ts b/src/lib/url-safety.ts new file mode 100644 index 000000000..b8422e75d --- /dev/null +++ b/src/lib/url-safety.ts @@ -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 "/"; + } +} diff --git a/src/proxy.ts b/src/proxy.ts index 175bab03e..e1ede731a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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",