mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(auth): add OIDC SSO login
Adds an OpenID Connect relying-party login path (PKCE authorization-code flow) against a single env-configured identity provider — additive to password/passkey login by default, with an operator opt-in (OIDC_ONLY, enforced server-side on every auth route) to disable them entirely. First-time sign-in auto-provisions or links an account by verified email. Closes an open redirect in the post-login `next` parameter (both the new OIDC path and the pre-existing password/passkey flow shared the same backslash-normalization bypass) by resolving the target through a real URL parse and comparing origins instead of a prefix check.
This commit is contained in:
1 parent
6712619495
commit
f815e4a83f
33 files changed
+2202
-95
No files matched your search
@@ -5,7 +5,7 @@
|
||||
* only signal operators need; the identifier itself is PII and stays
|
||||
* out of the row.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -135,3 +135,48 @@ describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
|
||||
expect(JSON.stringify(payload)).not.toContain(TYPED_EMAIL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ONLY",
|
||||
] as const;
|
||||
const original: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects password login before touching the DB when OIDC_ONLY is set", async () => {
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows password login when OIDC_ONLY is set but the provider is half-configured", async () => {
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
// Falls through to normal password verification (which fails here
|
||||
// since verifyPassword is mocked to return false) rather than 403 —
|
||||
// a half-set OIDC group must never lock everyone out.
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,18 @@ import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
// button — otherwise a pre-existing password (or a leaked/reused one)
|
||||
// stays a live bypass of the operator's SSO-only policy.
|
||||
if (isOidcOnly()) {
|
||||
return apiError("Password login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
|
||||
// global bucket when the trust chain is misconfigured; otherwise it
|
||||
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({ setError: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((s: string) => s.replace(/^enc\(/, "").replace(/\)$/, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/oidc", () => ({
|
||||
getOidcConfig: vi.fn(),
|
||||
discoverOidcMetadata: vi.fn(),
|
||||
exchangeCodeForTokens: vi.fn(),
|
||||
verifyIdToken: vi.fn(),
|
||||
fetchUserinfoEmail: vi.fn(),
|
||||
getOidcRedirectUri: () =>
|
||||
"https://healthlog.example.com/api/auth/oidc/callback",
|
||||
deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import {
|
||||
getOidcConfig,
|
||||
discoverOidcMetadata,
|
||||
exchangeCodeForTokens,
|
||||
verifyIdToken,
|
||||
} from "@/lib/auth/oidc";
|
||||
|
||||
const CONFIG = {
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
};
|
||||
const METADATA = {
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
};
|
||||
|
||||
function makeRequest(opts: { query?: string; cookie?: string }): NextRequest {
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/auth/oidc/callback${opts.query ?? ""}`,
|
||||
);
|
||||
if (opts.cookie) {
|
||||
req.cookies.set("oidc_auth_state", opts.cookie);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
function stateCookie(payload: Record<string, unknown>): string {
|
||||
return `enc(${JSON.stringify(payload)})`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 20,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/callback", () => {
|
||||
it("redirects with oidc_denied when the IdP reports an error", async () => {
|
||||
const res = await GET(makeRequest({ query: "?error=access_denied" }));
|
||||
expect(res.headers.get("location")).toContain("error=oidc_denied");
|
||||
});
|
||||
|
||||
it("redirects with oidc_state when code/state are missing", async () => {
|
||||
const res = await GET(makeRequest({}));
|
||||
expect(res.headers.get("location")).toContain("error=oidc_state");
|
||||
});
|
||||
|
||||
it("redirects with oidc_state when the state cookie is missing", async () => {
|
||||
const res = await GET(makeRequest({ query: "?code=abc&state=xyz" }));
|
||||
expect(res.headers.get("location")).toContain("error=oidc_state");
|
||||
});
|
||||
|
||||
it("redirects with oidc_state on a state/cookie mismatch (CSRF)", async () => {
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=WRONG",
|
||||
cookie: stateCookie({
|
||||
state: "RIGHT",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toContain("error=oidc_state");
|
||||
});
|
||||
|
||||
it("redirects with oidc_no_email when the identity has no verified email", async () => {
|
||||
vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" });
|
||||
vi.mocked(verifyIdToken).mockResolvedValue({
|
||||
sub: "sub-1",
|
||||
email: null,
|
||||
emailVerified: undefined,
|
||||
name: null,
|
||||
});
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toContain("error=oidc_no_email");
|
||||
});
|
||||
|
||||
it("logs in an existing user matched by email without provisioning", async () => {
|
||||
vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" });
|
||||
vi.mocked(verifyIdToken).mockResolvedValue({
|
||||
sub: "sub-1",
|
||||
email: "existing@example.com",
|
||||
emailVerified: true,
|
||||
name: "Existing",
|
||||
});
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
id: "user-1",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/dashboard",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(createSession).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
false,
|
||||
"1.2.3.4",
|
||||
null,
|
||||
expect.any(Date),
|
||||
);
|
||||
expect(res.headers.get("location")).toContain("/dashboard");
|
||||
});
|
||||
|
||||
it("auto-provisions a new user when registration is open", async () => {
|
||||
vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" });
|
||||
vi.mocked(verifyIdToken).mockResolvedValue({
|
||||
sub: "sub-2",
|
||||
email: "new@example.com",
|
||||
emailVerified: true,
|
||||
name: "New",
|
||||
});
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(3);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
registrationEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue({
|
||||
id: "user-new",
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(prisma.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
email: "new@example.com",
|
||||
passwordHash: null,
|
||||
role: "USER",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.status).toBe(307);
|
||||
});
|
||||
|
||||
it("refuses to auto-provision when registration is closed", async () => {
|
||||
vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" });
|
||||
vi.mocked(verifyIdToken).mockResolvedValue({
|
||||
sub: "sub-3",
|
||||
email: "blocked@example.com",
|
||||
emailVerified: true,
|
||||
name: null,
|
||||
});
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(5);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
registrationEnabled: false,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"error=oidc_registration_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects with oidc_failed when the token exchange throws", async () => {
|
||||
vi.mocked(exchangeCodeForTokens).mockRejectedValue(new Error("network"));
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toContain("error=oidc_failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import {
|
||||
deriveUniqueUsername,
|
||||
discoverOidcMetadata,
|
||||
exchangeCodeForTokens,
|
||||
fetchUserinfoEmail,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
verifyIdToken,
|
||||
} from "@/lib/auth/oidc";
|
||||
import { OIDC_STATE_COOKIE } from "@/lib/auth/oidc-cookie";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
function errorRedirect(req: NextRequest, reason: string): NextResponse {
|
||||
const response = NextResponse.redirect(
|
||||
new URL(`${LOGIN_ERROR_URL}?error=${reason}`, req.url),
|
||||
);
|
||||
response.cookies.delete(OIDC_STATE_COOKIE);
|
||||
return response;
|
||||
}
|
||||
|
||||
interface StoredOidcState {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
next: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as Record<string, unknown>).state === "string" &&
|
||||
typeof (value as Record<string, unknown>).nonce === "string" &&
|
||||
typeof (value as Record<string, unknown>).codeVerifier === "string" &&
|
||||
typeof (value as Record<string, unknown>).next === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC callback. Must work with NO existing session — this route
|
||||
* authenticates the user in the first place, unlike the per-account
|
||||
* integration callbacks (Withings/Polar/etc.) which all require
|
||||
* `requireAuth()`. Every failure branch redirects to `/auth/login?error=...`
|
||||
* (never JSON — this is a top-level browser navigation) and deletes the
|
||||
* single-use state cookie, mirroring `src/app/api/withings/callback/route.ts`.
|
||||
*/
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.callback" } });
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return errorRedirect(req, "oidc_disabled");
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:callback",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) return errorRedirect(req, "oidc_rate_limited");
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const idpError = searchParams.get("error");
|
||||
if (idpError) {
|
||||
annotate({ meta: { reason: "idp_denied", idpError } });
|
||||
return errorRedirect(req, "oidc_denied");
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
if (!code || !state) return errorRedirect(req, "oidc_state");
|
||||
|
||||
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
|
||||
let stored: StoredOidcState | null = null;
|
||||
if (rawCookie) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(decrypt(rawCookie));
|
||||
if (isStoredOidcState(parsed)) stored = parsed;
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
}
|
||||
if (!stored) return errorRedirect(req, "oidc_state");
|
||||
|
||||
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
||||
if (
|
||||
state.length !== stored.state.length ||
|
||||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
|
||||
) {
|
||||
annotate({ meta: { reason: "csrf" } });
|
||||
return errorRedirect(req, "oidc_state");
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await discoverOidcMetadata(config);
|
||||
const redirectUri = getOidcRedirectUri();
|
||||
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
metadata,
|
||||
config,
|
||||
code,
|
||||
codeVerifier: stored.codeVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
if (!tokens.id_token) return errorRedirect(req, "oidc_failed");
|
||||
|
||||
const identity = await verifyIdToken({
|
||||
metadata,
|
||||
config,
|
||||
idToken: tokens.id_token,
|
||||
nonce: stored.nonce,
|
||||
});
|
||||
|
||||
let email = identity.email;
|
||||
let emailVerified = identity.emailVerified;
|
||||
if (!email && tokens.access_token) {
|
||||
const userinfo = await fetchUserinfoEmail({
|
||||
metadata,
|
||||
accessToken: tokens.access_token,
|
||||
});
|
||||
email = userinfo.email;
|
||||
emailVerified = userinfo.emailVerified;
|
||||
}
|
||||
|
||||
// Accept a verified email, or one with no `email_verified` claim at
|
||||
// all (some providers never send it) — but never one explicitly
|
||||
// marked unverified.
|
||||
if (!email || emailVerified === false) {
|
||||
annotate({ meta: { reason: "no_verified_email" } });
|
||||
return errorRedirect(req, "oidc_no_email");
|
||||
}
|
||||
|
||||
let user = await prisma.user.findFirst({
|
||||
where: { email: { equals: email, mode: "insensitive" } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const userCount = await prisma.user.count();
|
||||
let registrationEnabled = true;
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
if (settings && !settings.registrationEnabled && userCount > 0) {
|
||||
registrationEnabled = false;
|
||||
}
|
||||
} catch {
|
||||
// Table may not exist yet; allow provisioning (matches register/route.ts).
|
||||
}
|
||||
if (!registrationEnabled) {
|
||||
annotate({ meta: { reason: "registration_disabled" } });
|
||||
return errorRedirect(req, "oidc_registration_disabled");
|
||||
}
|
||||
|
||||
const username = await deriveUniqueUsername(email, (candidate) =>
|
||||
prisma.user
|
||||
.findUnique({ where: { username: candidate } })
|
||||
.then((u: unknown) => u !== null),
|
||||
);
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
passwordHash: null,
|
||||
role: userCount === 0 ? "ADMIN" : "USER",
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("auth.oidc.provisioned", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
}
|
||||
|
||||
const ua = req.headers.get("user-agent");
|
||||
// The IdP already performed its own authentication — OIDC login
|
||||
// satisfies HealthLog's own MFA step-up, the same treatment passkey
|
||||
// login gives its own factor.
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt === null,
|
||||
ip,
|
||||
ua,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
|
||||
|
||||
await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip });
|
||||
|
||||
const response = NextResponse.redirect(new URL(stored.next, req.url));
|
||||
response.cookies.delete(OIDC_STATE_COOKIE);
|
||||
return response;
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ meta: { reason: "exchange_or_verify_failed" } });
|
||||
return errorRedirect(req, "oidc_failed");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `enc(${s})`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/oidc", async () => {
|
||||
// Keep the real `sanitizeOidcNextPath` — it's the security-relevant part
|
||||
// of this route, and the tests below assert its output round-trips into
|
||||
// the state cookie rather than re-deriving the same logic here.
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/auth/oidc")>("@/lib/auth/oidc");
|
||||
return {
|
||||
getOidcConfig: vi.fn(),
|
||||
discoverOidcMetadata: vi.fn(),
|
||||
buildAuthorizationUrl: vi.fn(() => "https://idp.example.com/authorize?x=1"),
|
||||
getOidcRedirectUri: () =>
|
||||
"https://healthlog.example.com/api/auth/oidc/callback",
|
||||
sanitizeOidcNextPath: actual.sanitizeOidcNextPath,
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc";
|
||||
|
||||
function makeRequest(path = "/api/auth/oidc/login"): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/login", () => {
|
||||
it("redirects to /auth/login?error=oidc_disabled when unconfigured", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(null);
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"/auth/login?error=oidc_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects to the rate-limit error page when throttled", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue({
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
});
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_rate_limited");
|
||||
});
|
||||
|
||||
it("redirects to the IdP and sets the encrypted state cookie on success", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue({
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
});
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue({
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
});
|
||||
|
||||
const res = await GET(makeRequest("/api/auth/oidc/login?next=/dashboard"));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"https://idp.example.com/authorize?x=1",
|
||||
);
|
||||
const cookie = res.cookies.get("oidc_auth_state");
|
||||
expect(cookie?.value).toMatch(/^enc\(/);
|
||||
expect(cookie?.httpOnly).toBe(true);
|
||||
expect(cookie?.sameSite).toBe("lax");
|
||||
});
|
||||
|
||||
it("neutralizes a backslash-normalization open-redirect payload in next", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue({
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
});
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue({
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
makeRequest(
|
||||
`/api/auth/oidc/login?next=${encodeURIComponent("/\\evil.com")}`,
|
||||
),
|
||||
);
|
||||
const cookie = res.cookies.get("oidc_auth_state");
|
||||
const stored = JSON.parse(
|
||||
cookie!.value.replace(/^enc\(/, "").replace(/\)$/, ""),
|
||||
);
|
||||
expect(stored.next).toBe("/");
|
||||
});
|
||||
|
||||
it("falls back to the login error page on discovery failure", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue({
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
});
|
||||
vi.mocked(discoverOidcMetadata).mockRejectedValue(new Error("boom"));
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
sanitizeOidcNextPath,
|
||||
} from "@/lib/auth/oidc";
|
||||
import { OIDC_STATE_COOKIE, OIDC_STATE_TTL_MS } from "@/lib/auth/oidc-cookie";
|
||||
|
||||
/**
|
||||
* Redirects the browser to the configured OIDC provider's authorization
|
||||
* endpoint. Full-page navigation, not a fetch — every failure branch
|
||||
* redirects to `/auth/login?error=...` rather than returning a JSON
|
||||
* envelope, mirroring `src/app/api/withings/connect/route.ts`.
|
||||
*/
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.login" } });
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/auth/login?error=oidc_disabled", req.url),
|
||||
);
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:login",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/auth/login?error=oidc_rate_limited", req.url),
|
||||
);
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await discoverOidcMetadata(config);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return NextResponse.redirect(
|
||||
new URL("/auth/login?error=oidc_failed", req.url),
|
||||
);
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString("base64url");
|
||||
const nonce = randomBytes(32).toString("base64url");
|
||||
const codeVerifier = randomBytes(64).toString("base64url");
|
||||
const codeChallenge = s256Challenge(codeVerifier);
|
||||
|
||||
const safeNext = sanitizeOidcNextPath(
|
||||
req.nextUrl.searchParams.get("next"),
|
||||
req.url,
|
||||
);
|
||||
|
||||
const redirectUri = getOidcRedirectUri();
|
||||
const authorizationUrl = buildAuthorizationUrl({
|
||||
metadata,
|
||||
config,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(authorizationUrl);
|
||||
const cookiePayload = encrypt(
|
||||
JSON.stringify({ state, nonce, codeVerifier, next: safeNext }),
|
||||
);
|
||||
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
// Cross-site top-level redirect back from the IdP, same reasoning as
|
||||
// the session cookie and the Withings OAuth state cookie.
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
|
||||
path: "/api/auth/oidc",
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getOidcConfig, isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Public — the login page reads this pre-session to decide whether to
|
||||
* render the SSO button and whether to hide password/passkey login. */
|
||||
export const GET = apiHandler(async () => {
|
||||
try {
|
||||
const config = getOidcConfig();
|
||||
annotate({ action: { name: "auth.oidc.status" } });
|
||||
return apiSuccess({
|
||||
enabled: config !== null,
|
||||
buttonLabel: config?.buttonLabel ?? null,
|
||||
only: isOidcOnly(),
|
||||
});
|
||||
} catch {
|
||||
// Fail closed, matching registration-status.
|
||||
annotate({ action: { name: "auth.oidc.status" } });
|
||||
return apiSuccess({ enabled: false, buttonLabel: null, only: false });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/passkey", () => ({
|
||||
createAuthenticationOptions: vi.fn().mockResolvedValue({
|
||||
options: {},
|
||||
challengeId: "ch-1",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
ip: "203.0.113.1",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ONLY",
|
||||
] as const;
|
||||
const original: Record<string, string | undefined> = {};
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/passkey/login-options", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
|
||||
vi.mocked(createAuthenticationOptions).mockResolvedValue({
|
||||
options: {},
|
||||
challengeId: "ch-1",
|
||||
} as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
describe("POST /api/auth/passkey/login-options — OIDC_ONLY server-side enforcement", () => {
|
||||
it("rejects before minting a WebAuthn challenge when OIDC_ONLY is set", async () => {
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
expect(createAuthenticationOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows passkey login-options when OIDC_ONLY is set but the provider is half-configured", async () => {
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,19 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
// OIDC_ONLY must block passkey login too, not just password login.
|
||||
if (isOidcOnly()) {
|
||||
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/passkey", () => ({
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ONLY",
|
||||
] as const;
|
||||
const original: Record<string, string | undefined> = {};
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ challengeId: "ch-1", credential: { id: "cred-1" } }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
email: "user@example.com",
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: 0,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: true },
|
||||
passkey: { userId: "user-1" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
describe("POST /api/auth/passkey/login-verify — OIDC_ONLY server-side enforcement", () => {
|
||||
it("rejects passkey login before touching WebAuthn verification when OIDC_ONLY is set", async () => {
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
expect(verifyAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows passkey login when OIDC_ONLY is set but the provider is half-configured", async () => {
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,17 @@ import {
|
||||
isCookielessNativeCaller,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY must block passkey login too, not just password login — a
|
||||
// credential minted before SSO was mandated must not stay a live bypass.
|
||||
if (isOidcOnly()) {
|
||||
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/register.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -118,3 +118,50 @@ describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
async function postPathThrough(body: unknown): Promise<Response> {
|
||||
return POST(postReq(body));
|
||||
}
|
||||
|
||||
describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () => {
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ONLY",
|
||||
] as const;
|
||||
const original: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects self-registration before any DB lookup when OIDC_ONLY is set", async () => {
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await postPathThrough({
|
||||
email: "new@example.com",
|
||||
username: "newuser",
|
||||
password: "a-very-strong-password-123",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("still allows registration when OIDC_ONLY is set but the provider is half-configured", async () => {
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await postPathThrough({ email: "not-an-email", username: "x" });
|
||||
// Falls through to normal validation (422 here) rather than 403 — a
|
||||
// half-set OIDC group must never lock everyone out.
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,18 @@ import {
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY must block self-registration too — otherwise anyone can
|
||||
// self-provision a fresh password account and bypass the operator's
|
||||
// SSO-only policy outright, invite token or not.
|
||||
if (isOidcOnly()) {
|
||||
return apiError("Registration is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
|
||||
// is misconfigured; otherwise byte-equivalent per-IP semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
|
||||
Reference in new issue
Block a user