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

+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(