feat(auth): branch login through a second-factor challenge

When a password is accepted for an account with a confirmed TOTP factor, the
login route no longer mints a session or token. It creates a single-use,
~5-minute MfaChallenge (the opaque ticket is stored only as a hash) and
returns HTTP 200 with data:null, error:null and meta.mfaRequired + mfaTicket +
methods. The client completes the login at POST /api/auth/mfa/verify with the
ticket + a TOTP or recovery code, receiving the exact same token bundle the
password path issues, with the session stamped second-factor-verified.

- The ticket is claimed once, atomically, only after the factor verifies, so
  two concurrent submissions can mint at most one session; no session ever
  exists before factor two.
- Wrong codes increment the ticket attempt counter and burn it at the cap
  (throttle, not an account lock); the anonymous verify surface is also
  rate-limited per IP.
- meta.mfaRequired is returned only after a valid password and the
  invalid-credentials response is identical regardless of MFA state, so an
  attacker without the password cannot enumerate MFA.
- The session/token tail is extracted to login-response.ts and shared by both
  the password path and the verify path, keeping the transport contract
  identical. OpenAPI registers the verify endpoint, the MFA management
  endpoints, and the mfaRequired login variant.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 19:56:30 +02:00
1 parent b446a66753
commit 6b3d3fd840
10 files changed
+1508 -91

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
/**
* Shared "issue the authenticated response" tail used by both the password
* login route and the `/api/auth/mfa/verify` route.
*
* Keeping one implementation guarantees the transport contract is identical
* whether or not a second factor sat in the middle: a web caller gets a
* session cookie, a cookie-less native caller gets the access + refresh
* bundle, and a legacy `X-Client-Type: native` caller gets a plain access
* token. The only behavioural difference the second factor introduces is the
* `mfaVerifiedAt` stamp threaded onto the issued session (cookie path) so
* step-up freshness can read it.
*
* Bearer-issuing branches do not carry an `mfaVerifiedAt` equivalent — the
* step-up primitive (`requireFreshMfa`) is cookie-only by construction, the
* same structural boundary as `requireAdmin`, so a token transport can never
* satisfy step-up regardless.
*/
import type { NextRequest } from "next/server";
import { apiSuccess } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
import {
resolveTokenPolicy,
shouldIssueBearerToken,
isCookielessNativeCaller,
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import type { User } from "@/generated/prisma/client";
export interface FinishLoginParams {
user: Pick<User, "id" | "username" | "onboardingCompletedAt">;
request: NextRequest;
ip: string;
userAgent: string | null;
/** Audit `source` discriminator, e.g. `"login.password"` / `"mfa.verify"`. */
source: string;
/**
* When true the issued session is stamped `mfaVerifiedAt = now()` so
* step-up gates treat it as freshly second-factor-verified. Only the
* cookie path carries the stamp.
*/
mfaVerified?: boolean;
}
/**
* Mint the session/token for an already-authenticated user and return the
* client response. Caller is responsible for the prior factor checks and for
* its own `auth.login.*` / `auth.mfa.*` audit entries; this writes only the
* token-issuance audit rows.
*/
export async function finishLogin(
params: FinishLoginParams,
): Promise<Response> {
const { user, request, ip, userAgent, source, mfaVerified } = params;
if (
shouldIssueBearerToken(request.headers) ||
isNativeClientRequest(request.headers)
) {
const policy = resolveTokenPolicy(request.headers);
const deviceId = request.headers.get("x-device-id");
// A 60-day refresh token is only ever delivered to a genuinely
// cookie-less native caller — a browser spoofing `X-Client-Type: native`
// falls through to the plain access-token path below.
if (
policy.refreshTokenDays !== null &&
isCookielessNativeCaller(request.headers)
) {
const bundle = await issueAccessAndRefresh({
userId: user.id,
policy,
deviceId,
userAgent,
ipAddress: ip,
source,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { source, policy: "native" },
});
return apiSuccess({
user: { id: user.id, username: user.username },
token: bundle.accessToken,
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
refreshToken: bundle.refreshToken,
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
});
}
// Web policy with explicit X-Client-Type:native (legacy iOS auto-login).
const issued = await issueApiToken({
userId: user.id,
name: `web auto-login ${new Date().toISOString()}`,
permissions: ["*"],
expiresInDays: policy.accessTokenDays,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { tokenId: issued.tokenId, source, policy: "web" },
});
return apiSuccess({
user: { id: user.id, username: user.username },
token: issued.token,
tokenExpiresAt: issued.expiresAt.toISOString(),
});
}
// Browser path — session cookie. `createSession` anchors the onboarding
// cookie itself; thread the user's onboarding state through.
await createSession(
user.id,
user.onboardingCompletedAt == null,
ip,
userAgent,
mfaVerified ? new Date() : null,
);
return apiSuccess({
user: { id: user.id, username: user.username },
});
}
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
mfaChallenge: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}));
// Stand-in for the real HMAC: deterministic but NOT containing the raw
// ticket, so the "raw ticket is never persisted" assertion is meaningful.
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((t: string) => `H${t.length}`),
}));
import {
createMfaChallenge,
loadActiveChallenge,
recordChallengeFailure,
claimChallenge,
MFA_CHALLENGE_ATTEMPT_CAP,
} from "../challenge";
import { prisma } from "@/lib/db";
beforeEach(() => {
vi.clearAllMocks();
});
function activeRow(overrides: Record<string, unknown> = {}) {
return {
id: "ch-1",
userId: "user-1",
kind: "login",
attempts: 0,
expiresAt: new Date(Date.now() + 60_000),
consumedAt: null,
...overrides,
};
}
describe("mfa challenge", () => {
it("mints a ticket and stores only its hash", async () => {
vi.mocked(prisma.mfaChallenge.create).mockResolvedValue({} as never);
const { ticket } = await createMfaChallenge("user-1", "login");
expect(typeof ticket).toBe("string");
expect(ticket.length).toBeGreaterThan(20);
const arg = vi.mocked(prisma.mfaChallenge.create).mock.calls[0][0];
expect(arg.data.ticketHash).toBe(`H${ticket.length}`);
// Raw ticket is never persisted.
expect(JSON.stringify(arg)).not.toContain(ticket);
});
it("loads an active challenge by hash", async () => {
vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue(
activeRow() as never,
);
const res = await loadActiveChallenge("tkt");
expect(res?.id).toBe("ch-1");
expect(
vi.mocked(prisma.mfaChallenge.findUnique).mock.calls[0][0],
).toMatchObject({ where: { ticketHash: "H3" } });
});
it("refuses a consumed ticket (single-use)", async () => {
vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue(
activeRow({ consumedAt: new Date() }) as never,
);
expect(await loadActiveChallenge("tkt")).toBeNull();
});
it("refuses an expired ticket (TTL)", async () => {
vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue(
activeRow({ expiresAt: new Date(Date.now() - 1000) }) as never,
);
expect(await loadActiveChallenge("tkt")).toBeNull();
});
it("refuses a ticket at the attempt cap", async () => {
vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue(
activeRow({ attempts: MFA_CHALLENGE_ATTEMPT_CAP }) as never,
);
expect(await loadActiveChallenge("tkt")).toBeNull();
});
it("refuses an unknown ticket", async () => {
vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue(null as never);
expect(await loadActiveChallenge("nope")).toBeNull();
});
it("burns the ticket when the attempt cap is reached", async () => {
vi.mocked(prisma.mfaChallenge.update).mockResolvedValue({
attempts: MFA_CHALLENGE_ATTEMPT_CAP,
} as never);
vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValue({
count: 1,
} as never);
const res = await recordChallengeFailure("ch-1");
expect(res.exhausted).toBe(true);
// Burn sets consumedAt only while still null (claim-once guard).
expect(prisma.mfaChallenge.updateMany).toHaveBeenCalledWith({
where: { id: "ch-1", consumedAt: null },
data: { consumedAt: expect.any(Date) },
});
});
it("does not burn below the attempt cap", async () => {
vi.mocked(prisma.mfaChallenge.update).mockResolvedValue({
attempts: 1,
} as never);
const res = await recordChallengeFailure("ch-1");
expect(res.exhausted).toBe(false);
expect(prisma.mfaChallenge.updateMany).not.toHaveBeenCalled();
});
it("claim-once: only the winning update returns true", async () => {
vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValueOnce({
count: 1,
} as never);
expect(await claimChallenge("ch-1")).toBe(true);
vi.mocked(prisma.mfaChallenge.updateMany).mockResolvedValueOnce({
count: 0,
} as never);
expect(await claimChallenge("ch-1")).toBe(false);
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* MfaChallenge — the short-lived, single-use login step-up ticket.
*
* When a password (or other primary credential) is accepted for an account
* that has a second factor enabled, **no session or token is issued**.
* Instead a challenge row is minted: the partial "password OK, awaiting
* factor 2" state lives entirely in this row, never in a half-built
* `Session`. The caller receives an opaque ticket; only its hash
* (`hashToken`) is stored, so a leaked database row cannot reconstruct a
* usable ticket.
*
* Guarantees enforced here:
* - **TTL** (~5 min) — `expiresAt`; an expired ticket is never loadable.
* - **Attempt cap** — `attempts` is incremented on every wrong factor and
* the ticket is burned (`consumedAt` set) once the cap is hit, forcing a
* fresh password login (NIST throttle, not an account lock).
* - **Claim-once** — consuming a ticket is an atomic guarded update
* (`consumedAt: null` in the WHERE), so two concurrent verifications can
* never both succeed and mint two sessions. The factor is verified first;
* the session is issued only after the claim wins.
*/
import { randomBytes } from "node:crypto";
import { hashToken } from "@/lib/auth/hmac";
import { prisma } from "@/lib/db";
/** Login step-up: the only `kind` Phase M exercises. */
export type MfaChallengeKind = "login";
/** 5-minute ticket life — long enough to read a code, short enough to bound replay. */
export const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000;
/** Wrong-factor attempts before the ticket is burned. */
export const MFA_CHALLENGE_ATTEMPT_CAP = 5;
export interface CreatedChallenge {
/** The opaque ticket handed to the client (never stored in the clear). */
ticket: string;
expiresAt: Date;
}
/** Mint a fresh single-use challenge for a user/kind. */
export async function createMfaChallenge(
userId: string,
kind: MfaChallengeKind,
): Promise<CreatedChallenge> {
const ticket = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + MFA_CHALLENGE_TTL_MS);
await prisma.mfaChallenge.create({
data: {
userId,
kind,
ticketHash: hashToken(ticket),
expiresAt,
},
});
return { ticket, expiresAt };
}
export interface ActiveChallenge {
id: string;
userId: string;
kind: string;
attempts: number;
expiresAt: Date;
}
/**
* Resolve a presented ticket to its live challenge, or null when it is
* unknown / already consumed / expired / over the attempt cap. The lookup
* is by hash — the raw ticket is never compared against a stored plaintext.
*/
export async function loadActiveChallenge(
ticket: string,
): Promise<ActiveChallenge | null> {
const row = await prisma.mfaChallenge.findUnique({
where: { ticketHash: hashToken(ticket) },
select: {
id: true,
userId: true,
kind: true,
attempts: true,
expiresAt: true,
consumedAt: true,
},
});
if (!row) return null;
if (row.consumedAt !== null) return null;
if (row.expiresAt.getTime() <= Date.now()) return null;
if (row.attempts >= MFA_CHALLENGE_ATTEMPT_CAP) return null;
return {
id: row.id,
userId: row.userId,
kind: row.kind,
attempts: row.attempts,
expiresAt: row.expiresAt,
};
}
/**
* Record a failed factor attempt. Increments `attempts`; when the cap is
* reached the ticket is burned (`consumedAt` set) so it cannot be retried.
* Returns whether the ticket is now exhausted.
*/
export async function recordChallengeFailure(
challengeId: string,
): Promise<{ exhausted: boolean; attempts: number }> {
const updated = await prisma.mfaChallenge.update({
where: { id: challengeId },
data: { attempts: { increment: 1 } },
select: { attempts: true },
});
const exhausted = updated.attempts >= MFA_CHALLENGE_ATTEMPT_CAP;
if (exhausted) {
await prisma.mfaChallenge.updateMany({
where: { id: challengeId, consumedAt: null },
data: { consumedAt: new Date() },
});
}
return { exhausted, attempts: updated.attempts };
}
/**
* Atomically claim the ticket. Returns true only for the single caller that
* transitions `consumedAt` from null → now. The factor MUST already be
* verified before this is called; the session is issued only when this
* returns true.
*/
export async function claimChallenge(challengeId: string): Promise<boolean> {
const claimed = await prisma.mfaChallenge.updateMany({
where: { id: challengeId, consumedAt: null },
data: { consumedAt: new Date() },
});
return claimed.count === 1;
}
+178 -3
View File
@@ -8,6 +8,11 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { loginPasswordSchema } from "@/lib/validations/auth";
import {
mfaVerifySchema,
totpConfirmSchema,
mfaDisableSchema,
} from "@/lib/validations/mfa";
import { dataEnvelope, stdResponses } from "./shared";
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
@@ -73,23 +78,193 @@ const accessRefreshBundle = z
"Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`.",
});
// ── v1.23 second-factor (MFA) shapes ─────────────────────────────────
const mfaRequiredEnvelope = z
.object({
data: z.null(),
error: z.null(),
meta: z.object({
mfaRequired: z.literal(true),
mfaTicket: z
.string()
.describe(
"Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify.",
),
methods: z
.array(z.enum(["totp", "recovery"]))
.describe(
"Second factors the account can complete the challenge with.",
),
}),
})
.meta({
id: "MfaRequiredResponse",
description:
"Password accepted but a second factor is required. Not an error and not a session — no token is issued until /api/auth/mfa/verify succeeds.",
});
const totpSetupResponse = z
.object({
otpauthUri: z
.string()
.describe("otpauth:// URI to render as a QR code (carries the secret)."),
totpSecret: z
.string()
.describe("Base32 secret for manual entry. Pending until confirmed."),
})
.meta({ id: "TotpSetupResponse" });
const recoveryCodesResponse = z
.object({
enabled: z.boolean().optional(),
recoveryCodes: z
.array(z.string())
.describe("Single-use recovery codes, shown once. Save them now."),
recoveryCodesRemaining: z.number().int(),
})
.meta({ id: "MfaRecoveryCodesResponse" });
const mfaToggleResponse = z
.object({ enabled: z.boolean() })
.meta({ id: "MfaToggleResponse" });
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/login": {
post: {
tags: ["Auth"],
summary: "Email-or-username login (password)",
description:
"Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.",
"Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.\n\n" +
"v1.23 — when the account has a confirmed second factor, the response carries no session/token. It returns HTTP 200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the `meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token bundle. Accounts without MFA are unchanged.",
requestBody: {
required: true,
content: { "application/json": { schema: loginPasswordSchema } },
},
responses: {
"200": {
description: "Login succeeded.",
description:
"Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`).",
content: {
"application/json": {
schema: dataEnvelope(accessRefreshBundle, "LoginResponse"),
schema: z.union([
dataEnvelope(accessRefreshBundle, "LoginResponse"),
mfaRequiredEnvelope,
]),
},
},
},
...stdResponses,
},
},
},
"/api/auth/mfa/verify": {
post: {
tags: ["Auth"],
summary: "Complete a second-factor login challenge",
description:
"Presents the `mfaTicket` from the login `meta.mfaRequired` response plus a TOTP or recovery code. On success returns the SAME token bundle / session the password path issues, with the session marked second-factor-verified. The ticket is single-use; wrong codes are throttled and the ticket is burned at the attempt cap.",
requestBody: {
required: true,
content: { "application/json": { schema: mfaVerifySchema } },
},
responses: {
"200": {
description:
"Second factor verified — session + optional bearer issued.",
content: {
"application/json": {
schema: dataEnvelope(accessRefreshBundle, "MfaVerifyResponse"),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/totp/setup": {
post: {
tags: ["Auth"],
summary: "Begin TOTP enrollment (cookie session only)",
description:
"Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA.",
responses: {
"200": {
description: "Pending secret created.",
content: {
"application/json": {
schema: dataEnvelope(totpSetupResponse, "TotpSetupEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/totp/confirm": {
post: {
tags: ["Auth"],
summary: "Confirm TOTP enrollment (cookie session only)",
description:
"Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. Cookie-only.",
requestBody: {
required: true,
content: { "application/json": { schema: totpConfirmSchema } },
},
responses: {
"200": {
description: "Factor activated — recovery codes returned once.",
content: {
"application/json": {
schema: dataEnvelope(
recoveryCodesResponse,
"TotpConfirmEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/disable": {
post: {
tags: ["Auth"],
summary: "Disable the second factor (step-up gated)",
description:
"Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the secret and deletes recovery codes. Bearer can never satisfy the step-up gate.",
requestBody: {
required: true,
content: { "application/json": { schema: mfaDisableSchema } },
},
responses: {
"200": {
description: "Factor disabled.",
content: {
"application/json": {
schema: dataEnvelope(mfaToggleResponse, "MfaDisableEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/recovery-codes/regenerate": {
post: {
tags: ["Auth"],
summary: "Regenerate recovery codes (step-up gated)",
description:
"Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can never satisfy the gate.",
responses: {
"200": {
description: "Fresh recovery codes issued.",
content: {
"application/json": {
schema: dataEnvelope(
recoveryCodesResponse,
"MfaRecoveryRegenEnvelope",
),
},
},
},