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:
Balaji Vijayakumar committed 2026-07-13 16:33:13 +05:30
1 parent 6712619495
commit f815e4a83f
33 files changed
+2202 -95

No files matched your search

+23
View File
@@ -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)
+1
View File
@@ -165,6 +165,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
## DO-NOTs
+15
View File
@@ -142,6 +142,21 @@ services:
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
# hides password/passkey login entirely, but only takes effect when
# the three above are also fully set — a half-configured group never
# locks every user out. It IS enforced server-side, and there is no
# OIDC flow for the native iOS client yet — enabling it locks iOS
# users out of sign-in entirely. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
OIDC_SCOPES: "${OIDC_SCOPES:-}"
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
OIDC_ONLY: "${OIDC_ONLY:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
+7
View File
@@ -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.",
+7
View File
@@ -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.",
+7
View File
@@ -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.",
+7
View File
@@ -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.",
+7
View File
@@ -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.",
+7
View File
@@ -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.",
+1
View File
@@ -74,6 +74,7 @@
"date-fns": "^4.4.0",
"dotenv": "^17.4.2",
"fflate": "^0.8.3",
"jose": "^6.2.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
+3
View File
@@ -75,6 +75,9 @@ importers:
fflate:
specifier: ^0.8.3
version: 0.8.3
jose:
specifier: ^6.2.3
version: 6.2.3
jspdf:
specifier: ^4.2.1
version: 4.2.1
+20
View File
@@ -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.",
+46 -1
View File
@@ -5,7 +5,7 @@
* only signal operators need; the identifier itself is PII and stays
* out of the row.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -135,3 +135,48 @@ describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
expect(JSON.stringify(payload)).not.toContain(TYPED_EMAIL);
});
});
describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
it("rejects password login before touching the DB when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(prisma.user.findFirst).not.toHaveBeenCalled();
});
it("still allows password login when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
// Falls through to normal password verification (which fails here
// since verifyPassword is mocked to return false) rather than 403 —
// a half-set OIDC group must never lock everyone out.
expect(res.status).toBe(401);
});
});
+10
View File
@@ -13,8 +13,18 @@ import { finishLogin } from "@/lib/auth/login-response";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
// button — otherwise a pre-existing password (or a leaked/reused one)
// stays a live bypass of the operator's SSO-only policy.
if (isOidcOnly()) {
return apiError("Password login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
// global bucket when the trust chain is misconfigured; otherwise it
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
@@ -0,0 +1,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");
});
});
+211
View File
@@ -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");
});
});
+91
View File
@@ -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;
});
+24
View File
@@ -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);
});
});
+10
View File
@@ -26,8 +26,18 @@ import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block self-registration too — otherwise anyone can
// self-provision a fresh password account and bypass the operator's
// SSO-only policy outright, invite token or not.
if (isOidcOnly()) {
return apiError("Registration is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
// is misconfigured; otherwise byte-equivalent per-IP semantics.
const rl = await checkAuthSurfaceRateLimit(
+146 -92
View File
@@ -3,7 +3,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Lock, Loader2 } from "lucide-react";
import { KeyRound, Lock, Loader2, Shield } from "lucide-react";
import { Logo } from "@/components/ui/logo";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -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
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { sanitizeSameOriginPath } from "../url-safety";
const BASE = "https://healthlog.example.com/auth/login";
describe("sanitizeSameOriginPath", () => {
it("passes through a plain same-origin path", () => {
expect(sanitizeSameOriginPath("/dashboard", BASE)).toBe("/dashboard");
});
it("defaults to / when next is null, undefined, or empty", () => {
expect(sanitizeSameOriginPath(null, BASE)).toBe("/");
expect(sanitizeSameOriginPath(undefined, BASE)).toBe("/");
expect(sanitizeSameOriginPath("", BASE)).toBe("/");
});
it("rejects a protocol-relative next (//host)", () => {
expect(sanitizeSameOriginPath("//evil.com", BASE)).toBe("/");
});
it("rejects an absolute next pointing at another origin", () => {
expect(sanitizeSameOriginPath("https://evil.com/phish", BASE)).toBe("/");
});
it("rejects the backslash-normalization bypass (/\\evil.com)", () => {
// WHATWG URL parsing treats a leading backslash as a path separator for
// special schemes, so a naive startsWith("/") && !startsWith("//")
// check would accept this — Next.js's router.push() re-parses the
// string the same way and hard-navigates to the resolved origin.
expect(sanitizeSameOriginPath("/\\evil.com", BASE)).toBe("/");
expect(sanitizeSameOriginPath("\\\\evil.com", BASE)).toBe("/");
});
it("preserves a same-origin path's search and hash", () => {
expect(sanitizeSameOriginPath("/settings?tab=security#top", BASE)).toBe(
"/settings?tab=security#top",
);
});
it("falls back to / on an unparseable value", () => {
expect(sanitizeSameOriginPath("https://", BASE)).toBe("/");
});
});
+408
View File
@@ -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);
});
});
+7
View File
@@ -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;
+353
View File
@@ -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;
}
+1
View File
@@ -14,6 +14,7 @@ export const authKeys = {
*/
authMe: () => ["auth", "me"] as const,
authRegistrationStatus: () => ["auth", "registration-status"] as const,
authOidcStatus: () => ["auth", "oidc", "status"] as const,
/**
* v1.4.40 W-RSC — Settings → AI surfaces and the targets editor
* subscribe to the user-thresholds API. Centralise the key so a
+32
View File
@@ -0,0 +1,32 @@
/**
* Isomorphic (client + server) — no `node:crypto`/`jose`/server-only imports,
* so client components can pull this in without dragging server auth code
* into the browser bundle.
*/
/**
* Resolve a caller-supplied "redirect back to" path against a base URL and
* only accept it if it stays on that origin. A plain `startsWith("/") &&
* !startsWith("//")` string check is not enough — WHATWG URL parsing treats
* a leading backslash as a path separator for special schemes, so a value
* like `/\evil.com` passes that check but resolves off-origin (confirmed:
* `router.push()` on such a value drives Next.js's client router into a
* real cross-origin `location.assign()`, not just client-side History
* state). Resolving through `new URL()` first and comparing the real
* origin closes that class of bypass instead of pattern-matching the raw
* string.
*/
export function sanitizeSameOriginPath(
next: string | null | undefined,
baseUrl: string,
): string {
if (!next) return "/";
try {
const resolved = new URL(next, baseUrl);
const base = new URL(baseUrl);
if (resolved.origin !== base.origin) return "/";
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
} catch {
return "/";
}
}
+8
View File
@@ -15,6 +15,14 @@ const PUBLIC_PATHS = [
"/api/auth/registration-status",
"/api/auth/passkey/login-options",
"/api/auth/passkey/login-verify",
// OIDC SSO login. `login` starts the redirect to the IdP, `callback`
// completes it and mints the session — both must be reachable with no
// existing session, since they authenticate the user in the first
// place. `status` is the public flag the login page reads to decide
// whether to render the SSO button.
"/api/auth/oidc/login",
"/api/auth/oidc/callback",
"/api/auth/oidc/status",
"/api/health",
"/api/version",
"/api/notifications/vapid",