mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(auth): add OIDC SSO login
Adds an OpenID Connect relying-party login path (PKCE authorization-code flow) against a single env-configured identity provider — additive to password/passkey login by default, with an operator opt-in (OIDC_ONLY, enforced server-side on every auth route) to disable them entirely. First-time sign-in auto-provisions or links an account by verified email. Closes an open redirect in the post-login `next` parameter (both the new OIDC path and the pre-existing password/passkey flow shared the same backslash-normalization bypass) by resolving the target through a real URL parse and comparing origins instead of a prefix check.
This commit is contained in:
1 parent
6712619495
commit
f815e4a83f
33 files changed
+2202
-95
No files matched your search
@@ -103,6 +103,29 @@ 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.
|
||||
# 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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <issuer>/.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 <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,279 @@
|
||||
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(),
|
||||
},
|
||||
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, unknown>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<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 } });
|
||||
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");
|
||||
}
|
||||
});
|
||||
@@ -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,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;
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
+146
-92
@@ -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<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_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() {
|
||||
</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 +431,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,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<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("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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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<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.
|
||||
*/
|
||||
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");
|
||||
}
|
||||
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<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);
|
||||
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<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