Files
HealthLog/src/app/api/auth/login/route.ts
T
Balaji Vijayakumar f815e4a83f 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.
2026-07-13 16:33:13 +05:30

208 lines
7.4 KiB
TypeScript

import { prisma } from "@/lib/db";
import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import { apiError, safeJson } from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
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.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:login",
5,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{
data: null,
error: "Too many login attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = loginPasswordSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid credentials", 422);
}
const { email, password } = parsed.data;
const identifier = email.trim();
// HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`,
// mirrors the `/api/auth/check-user` pattern. The raw identifier
// stays out of the audit row (H-1 contract); the hash gives a
// future spray-detector a forensic anchor it can correlate across
// IPs without having to look up users by email.
const identifierHash = hashToken(identifier);
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: { equals: identifier, mode: "insensitive" } },
{ username: { equals: identifier, mode: "insensitive" } },
],
},
});
if (!user || !user.passwordHash) {
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
// the audit row — `reason` already tells the operator what
// happened, and PII must not land in operator artefacts. The
// HMAC anchor below is one-way — recoverable only with the HMAC
// key (operator secret), used purely to group same-identifier
// attempts across IPs.
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: "user_not_found_or_no_password", identifierHash },
});
return apiError("Invalid credentials", 401);
}
const valid = await verifyPassword(user.passwordHash, password);
if (!valid) {
await auditLog("auth.login.failed", {
userId: user.id,
ipAddress: ip,
details: { reason: "invalid_password", identifierHash },
});
return apiError("Invalid credentials", 401);
}
const ua = request.headers.get("user-agent");
await auditLog("auth.login.password", {
userId: user.id,
ipAddress: ip,
});
// v1.23 — second-factor gate. The password is correct, but an account with
// a confirmed second factor (a TOTP secret and/or a registered WebAuthn
// security key) is NOT yet authenticated: no `Session` row and no token
// bundle is minted here. Instead a single-use, ~5-minute MFA ticket is
// returned (the partial state lives in the ticket, never in a half-built
// session) and the client completes the login at `/api/auth/mfa/verify`
// (TOTP / recovery) or `/api/auth/mfa/webauthn/verify` (security key).
//
// Enumeration note: `meta.mfaRequired` is only ever returned AFTER a valid
// password, and the invalid-credentials response above is identical
// regardless of MFA state — an attacker without the password cannot learn
// whether an account has MFA.
const hasTotp = Boolean(user.totpConfirmedAt);
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: user.id },
});
const hasWebauthn = webauthnKeyCount > 0;
if (hasTotp || hasWebauthn) {
// v1.23 — "remember this device". A browser the user previously trusted
// skips factor 2 within the 30-day window. The password (factor 1) has
// already been verified above, so a trusted device only ever drops the
// second challenge — it never replaces the password. The minted session is
// deliberately NOT stamped `mfaVerifiedAt` (mfaVerified omitted below), so a
// trusted-device login can never satisfy step-up (`requireFreshMfa`): a
// stolen device cookie cannot reach a destructive action.
if (await consumeTrustedDevice(user.id)) {
await auditLog("auth.mfa.trusted_device", {
userId: user.id,
ipAddress: ip,
});
annotate({
action: { name: "auth.mfa.trusted_device" },
meta: { mfa_skipped: true },
});
// The account has a second factor, so the enforcement gate is satisfied;
// keep the hint cookie honest.
await syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
});
return finishLogin({
user,
request,
ip,
userAgent: ua,
source: "login.password.trusted_device",
});
}
const challenge = await createMfaChallenge(user.id, "login");
// Recovery codes are only ever issued alongside TOTP enrollment, so the
// recovery method is offered exactly when TOTP is active.
const methods: ("totp" | "recovery" | "webauthn")[] = [];
if (hasTotp) methods.push("totp", "recovery");
if (hasWebauthn) methods.push("webauthn");
await auditLog("auth.mfa.challenge", {
userId: user.id,
ipAddress: ip,
details: { source: "login.password" },
});
annotate({
action: { name: "auth.mfa.challenge" },
meta: { mfa_required: true },
});
return NextResponse.json(
{
data: null,
error: null,
meta: {
mfaRequired: true,
mfaTicket: challenge.ticket,
methods,
},
},
{ status: 200 },
);
}
annotate({ action: { name: "auth.login.password" } });
// v1.23 — admin-enforced MFA policy. A single-factor account under the policy
// is sent to forced enrollment after sign-in; the proxy reads this hint
// cookie without a DB round-trip. Cheap (short-circuits when not enforced).
await syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
});
// No second factor — issue the session/token exactly as before.
return finishLogin({
user,
request,
ip,
userAgent: ua,
source: "login.password",
});
});