mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
b446a66753
commit
6b3d3fd840
10 files changed
+1508
-91
No files matched your search
+321
-4
@@ -1354,8 +1354,15 @@ paths:
|
||||
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."
|
||||
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.
|
||||
|
||||
|
||||
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:
|
||||
@@ -1374,11 +1381,124 @@ paths:
|
||||
- password
|
||||
responses:
|
||||
"200":
|
||||
description: Login succeeded.
|
||||
description: Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LoginResponse"
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/LoginResponse"
|
||||
- $ref: "#/components/schemas/MfaRequiredResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/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:
|
||||
$ref: "#/components/schemas/MfaVerifyRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Second factor verified — session + optional bearer issued.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaVerifyResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/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:
|
||||
$ref: "#/components/schemas/TotpSetupEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/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:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
pattern: ^\d{6}$
|
||||
required:
|
||||
- code
|
||||
responses:
|
||||
"200":
|
||||
description: Factor activated — recovery codes returned once.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TotpConfirmEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/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:
|
||||
$ref: "#/components/schemas/MfaDisableRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Factor disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MfaDisableEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/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:
|
||||
$ref: "#/components/schemas/MfaRecoveryRegenEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
@@ -7492,6 +7612,43 @@ paths:
|
||||
"429": *a4
|
||||
components:
|
||||
schemas:
|
||||
MfaVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
mfaTicket:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: The opaque single-use ticket from the login response.
|
||||
method:
|
||||
default: totp
|
||||
type: string
|
||||
enum:
|
||||
- totp
|
||||
- recovery
|
||||
code:
|
||||
type: string
|
||||
minLength: 6
|
||||
maxLength: 32
|
||||
required:
|
||||
- mfaTicket
|
||||
- code
|
||||
description: Complete a second-factor login challenge and receive the same token bundle the password path issues.
|
||||
MfaDisableRequest:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
minLength: 6
|
||||
maxLength: 32
|
||||
method:
|
||||
default: totp
|
||||
type: string
|
||||
enum:
|
||||
- totp
|
||||
- recovery
|
||||
required:
|
||||
- code
|
||||
description: Disable the second factor. Requires fresh step-up plus a current TOTP or recovery code.
|
||||
PasskeyLoginVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -10660,6 +10817,166 @@ components:
|
||||
- tokenExpiresAt
|
||||
additionalProperties: false
|
||||
description: Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`.
|
||||
MfaRequiredResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: "null"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
mfaRequired:
|
||||
type: boolean
|
||||
const: true
|
||||
mfaTicket:
|
||||
type: string
|
||||
description: Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify.
|
||||
methods:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- totp
|
||||
- recovery
|
||||
description: Second factors the account can complete the challenge with.
|
||||
required:
|
||||
- mfaRequired
|
||||
- mfaTicket
|
||||
- methods
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
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.
|
||||
MfaVerifyResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AccessRefreshBundle"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
TotpSetupEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/TotpSetupResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
TotpSetupResponse:
|
||||
type: object
|
||||
properties:
|
||||
otpauthUri:
|
||||
type: string
|
||||
description: otpauth:// URI to render as a QR code (carries the secret).
|
||||
totpSecret:
|
||||
type: string
|
||||
description: Base32 secret for manual entry. Pending until confirmed.
|
||||
required:
|
||||
- otpauthUri
|
||||
- totpSecret
|
||||
additionalProperties: false
|
||||
TotpConfirmEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MfaRecoveryCodesResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MfaRecoveryCodesResponse:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
recoveryCodes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Single-use recovery codes, shown once. Save them now.
|
||||
recoveryCodesRemaining:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- recoveryCodes
|
||||
- recoveryCodesRemaining
|
||||
additionalProperties: false
|
||||
MfaDisableEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MfaToggleResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MfaToggleResponse:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- enabled
|
||||
additionalProperties: false
|
||||
MfaRecoveryRegenEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MfaRecoveryCodesResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PasskeyLoginVerifyResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findFirst: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 1e6,
|
||||
ip: "203.0.113.7",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ data: { user: { id: "user-1" } }, error: null }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
createMfaChallenge: vi.fn().mockResolvedValue({
|
||||
ticket: "opaque-ticket-value",
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000),
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/auth/login/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
|
||||
function loginRequest() {
|
||||
return new Request("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "user@example.com",
|
||||
password: "correct-horse-battery",
|
||||
}),
|
||||
}) as unknown as Parameters<typeof POST>[0];
|
||||
}
|
||||
|
||||
const BASE_USER = {
|
||||
id: "user-1",
|
||||
username: "u",
|
||||
email: "user@example.com",
|
||||
passwordHash: "argon2-hash",
|
||||
onboardingCompletedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("login MFA branch", () => {
|
||||
it("MFA user → returns mfaRequired + ticket and issues NO session", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...BASE_USER,
|
||||
totpConfirmedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(loginRequest());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.meta.mfaRequired).toBe(true);
|
||||
expect(body.meta.mfaTicket).toBe("opaque-ticket-value");
|
||||
expect(body.meta.methods).toEqual(["totp", "recovery"]);
|
||||
|
||||
expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login");
|
||||
// The session/token tail is never reached.
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("non-MFA user → issues the session via finishLogin, no challenge", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...BASE_USER,
|
||||
totpConfirmedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(loginRequest());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(finishLogin).toHaveBeenCalledTimes(1);
|
||||
expect(createMfaChallenge).not.toHaveBeenCalled();
|
||||
const arg = vi.mocked(finishLogin).mock.calls[0][0];
|
||||
expect(arg.source).toBe("login.password");
|
||||
expect(arg.mfaVerified).toBeUndefined();
|
||||
});
|
||||
|
||||
it("wrong password never reaches the MFA branch", async () => {
|
||||
const { verifyPassword } = await import("@/lib/auth/password");
|
||||
vi.mocked(verifyPassword).mockResolvedValueOnce(false);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
...BASE_USER,
|
||||
totpConfirmedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await POST(loginRequest());
|
||||
expect(res.status).toBe(401);
|
||||
expect(createMfaChallenge).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 1e6,
|
||||
ip: "203.0.113.9",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ data: { user: { id: "user-1" } }, error: null }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
loadActiveChallenge: vi.fn(),
|
||||
recordChallengeFailure: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ exhausted: false, attempts: 1 }),
|
||||
claimChallenge: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/verify-factor", () => ({
|
||||
verifyMfaFactor: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/auth/mfa/verify/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
recordChallengeFailure,
|
||||
claimChallenge,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
|
||||
function verifyRequest(body: unknown) {
|
||||
return new Request("http://localhost/api/auth/mfa/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}) as unknown as Parameters<typeof POST>[0];
|
||||
}
|
||||
|
||||
const GOOD_BODY = { mfaTicket: "tkt", method: "totp", code: "123456" };
|
||||
const ACTIVE_CHALLENGE = {
|
||||
id: "ch-1",
|
||||
userId: "user-1",
|
||||
kind: "login",
|
||||
attempts: 0,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
};
|
||||
const MFA_USER = {
|
||||
id: "user-1",
|
||||
username: "u",
|
||||
email: "user@example.com",
|
||||
onboardingCompletedAt: new Date(),
|
||||
totpConfirmedAt: new Date(),
|
||||
totpSecretEncrypted: "v2.cipher",
|
||||
totpLastStep: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/auth/mfa/verify", () => {
|
||||
it("happy path: valid factor → claims ticket and issues the bundle", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never);
|
||||
vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true, replay: false });
|
||||
vi.mocked(claimChallenge).mockResolvedValue(true);
|
||||
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(claimChallenge).toHaveBeenCalledWith("ch-1");
|
||||
expect(finishLogin).toHaveBeenCalledTimes(1);
|
||||
const arg = vi.mocked(finishLogin).mock.calls[0][0];
|
||||
expect(arg.mfaVerified).toBe(true);
|
||||
expect(arg.source).toBe("mfa.verify");
|
||||
});
|
||||
|
||||
it("invalid ticket → 401, no session", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(null);
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wrong code → records failure, 401, no session", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never);
|
||||
vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: false, replay: false });
|
||||
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(recordChallengeFailure).toHaveBeenCalledWith("ch-1");
|
||||
expect(claimChallenge).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("factor valid but claim lost (race) → 401, no session", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(MFA_USER as never);
|
||||
vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true, replay: false });
|
||||
vi.mocked(claimChallenge).mockResolvedValue(false);
|
||||
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("factor disabled between password and verify → failure, 401", async () => {
|
||||
vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...MFA_USER,
|
||||
totpConfirmedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(verifyRequest(GOOD_BODY));
|
||||
expect(res.status).toBe(401);
|
||||
expect(recordChallengeFailure).toHaveBeenCalledWith("ch-1");
|
||||
expect(verifyMfaFactor).not.toHaveBeenCalled();
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("malformed body → 422", async () => {
|
||||
const res = await POST(verifyRequest({ mfaTicket: "", code: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,16 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
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 { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
isCookielessNativeCaller,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
|
||||
@@ -95,93 +89,55 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` now anchors the
|
||||
// `hl_onboarding` cookie itself; pass the user's onboarding state
|
||||
// through so the proxy can short-circuit the redirect before the
|
||||
// page hydrates.
|
||||
await createSession(user.id, user.onboardingCompletedAt == null, ip, ua);
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
// v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs)
|
||||
// get a 24h access token + 60d rotating refresh token. Web UAs keep the
|
||||
// legacy 90d Bearer (issued only when X-Client-Type: native is set —
|
||||
// browser flows continue to use the session cookie).
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
// M-3 hardening: a 60-day refresh token is only ever delivered to a
|
||||
// genuinely cookie-less native caller. A browser spoofing
|
||||
// `X-Client-Type: native` (Mozilla UA or an inbound session cookie)
|
||||
// falls through to the plain access-token path below — never handed a
|
||||
// long-lived secret into a DOM/XSS-reachable context.
|
||||
if (
|
||||
policy.refreshTokenDays !== null &&
|
||||
isCookielessNativeCaller(request.headers)
|
||||
) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip,
|
||||
source: "login.password",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.password", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_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", {
|
||||
// v1.23 — second-factor gate. The password is correct, but an account with
|
||||
// a confirmed TOTP factor 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`.
|
||||
//
|
||||
// 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.
|
||||
if (user.totpConfirmedAt) {
|
||||
const challenge = await createMfaChallenge(user.id, "login");
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.password",
|
||||
policy: "web",
|
||||
},
|
||||
details: { source: "login.password" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
tokenExpiresAt: issued.expiresAt.toISOString(),
|
||||
action: { name: "auth.mfa.challenge" },
|
||||
meta: { mfa_required: true },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: null,
|
||||
meta: {
|
||||
mfaRequired: true,
|
||||
mfaTicket: challenge.ticket,
|
||||
methods: ["totp", "recovery"],
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
// No second factor — issue the session/token exactly as before.
|
||||
return finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "login.password",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* POST /api/auth/mfa/verify
|
||||
*
|
||||
* Complete a second-factor login challenge. The caller presents the opaque
|
||||
* `mfaTicket` from the `meta.mfaRequired` login response plus a TOTP or
|
||||
* recovery code. On success the **same** session/token bundle the password
|
||||
* path issues is returned (web → session cookie; native → access+refresh),
|
||||
* with the session stamped `mfaVerifiedAt`.
|
||||
*
|
||||
* Security-critical invariants:
|
||||
* - No session or token exists until the factor passes — the partial state
|
||||
* lived in the ticket, never in a half-built session.
|
||||
* - The ticket is **claimed once, atomically**, only AFTER the factor
|
||||
* verifies; two concurrent valid submissions can mint at most one session.
|
||||
* - Wrong factors increment the ticket attempt counter and burn it at the
|
||||
* cap (NIST throttle, not an account lock). Anonymous surface is rate-
|
||||
* limited per IP on top of the per-ticket cap.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
loadActiveChallenge,
|
||||
recordChallengeFailure,
|
||||
claimChallenge,
|
||||
} from "@/lib/auth/mfa/challenge";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import { mfaVerifySchema } from "@/lib/validations/mfa";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:mfa-verify",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = mfaVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { mfaTicket, method, code } = parsed.data;
|
||||
|
||||
// Resolve the ticket. A single generic 401 covers unknown / expired /
|
||||
// consumed / over-cap so a ticket-guesser learns nothing.
|
||||
const challenge = await loadActiveChallenge(mfaTicket);
|
||||
if (!challenge) {
|
||||
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: challenge.userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
onboardingCompletedAt: true,
|
||||
totpConfirmedAt: true,
|
||||
totpSecretEncrypted: true,
|
||||
totpLastStep: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The factor must still be active (it could have been disabled between the
|
||||
// password step and this call). Treat anything else as a failed attempt.
|
||||
if (!user || !user.totpConfirmedAt) {
|
||||
await recordChallengeFailure(challenge.id);
|
||||
annotate({ action: { name: "auth.mfa.verify.no_active_factor" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
const factor = await verifyMfaFactor(user, method, code);
|
||||
if (!factor.ok) {
|
||||
const { exhausted } = await recordChallengeFailure(challenge.id);
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { stage: "login", method, replay: factor.replay, exhausted },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.failed" },
|
||||
meta: { method, replay: factor.replay, exhausted },
|
||||
});
|
||||
return apiError("Invalid code", 401);
|
||||
}
|
||||
|
||||
// Factor verified — claim the ticket atomically. Only the single winning
|
||||
// caller proceeds to mint a session; a concurrent claim loses here.
|
||||
const claimed = await claimChallenge(challenge.id);
|
||||
if (!claimed) {
|
||||
annotate({ action: { name: "auth.mfa.verify.claim_lost" } });
|
||||
return apiError("Invalid or expired challenge", 401);
|
||||
}
|
||||
|
||||
await auditLog("auth.mfa.verified", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method },
|
||||
});
|
||||
if (method === "recovery") {
|
||||
await auditLog("auth.mfa.recovery_used", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
}
|
||||
annotate({ action: { name: "auth.mfa.verified" }, meta: { method } });
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
return finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "mfa.verify",
|
||||
mfaVerified: true,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Integration guard for the second-factor login path against real Postgres.
|
||||
*
|
||||
* Unit tests mock Prisma, so they cannot prove the security-critical DB-backed
|
||||
* invariants: that a verified TOTP step is actually persisted (replay guard),
|
||||
* that the recovery-code burn is durable and one-time, and that the challenge
|
||||
* ticket can be claimed exactly once. This exercises all three end to end.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as OTPAuth from "otpauth";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
// hashToken (challenge ticket hashing) is fail-closed on a weak/absent key.
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-test-hmac-key-test-hmac-key-0123456789";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
function codeAt(secretBase32: string, atMs: number): string {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: "HealthLog",
|
||||
label: "HealthLog",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(secretBase32),
|
||||
});
|
||||
return totp.generate({ timestamp: atMs });
|
||||
}
|
||||
|
||||
let secret: string;
|
||||
let encrypt: (s: string) => string;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ encrypt } = await import("@/lib/crypto"));
|
||||
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
|
||||
secret = generateTotpSecret();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("MFA login flow (real Postgres)", () => {
|
||||
it("TOTP verify persists the step and rejects a replay", async () => {
|
||||
const { verifyMfaFactor } = await import("@/lib/auth/mfa/verify-factor");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "totp-user",
|
||||
email: "totp@example.test",
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
totpConfirmedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// verifyMfaFactor verifies against the real wall clock, so the code must
|
||||
// be generated for "now".
|
||||
const code = codeAt(secret, Date.now());
|
||||
|
||||
const first = await verifyMfaFactor(
|
||||
{ id: user.id, totpSecretEncrypted: encrypt(secret), totpLastStep: null },
|
||||
"totp",
|
||||
code,
|
||||
);
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
const afterFirst = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { totpLastStep: true },
|
||||
});
|
||||
expect(afterFirst?.totpLastStep).not.toBeNull();
|
||||
|
||||
// Replay the same code — the persisted step now floors it out.
|
||||
const replay = await verifyMfaFactor(
|
||||
{
|
||||
id: user.id,
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
totpLastStep: afterFirst?.totpLastStep ?? null,
|
||||
},
|
||||
"totp",
|
||||
code,
|
||||
);
|
||||
expect(replay.ok).toBe(false);
|
||||
expect(replay.replay).toBe(true);
|
||||
});
|
||||
|
||||
it("challenge is single-use and burns at the attempt cap", async () => {
|
||||
const {
|
||||
createMfaChallenge,
|
||||
loadActiveChallenge,
|
||||
claimChallenge,
|
||||
recordChallengeFailure,
|
||||
MFA_CHALLENGE_ATTEMPT_CAP,
|
||||
} = await import("@/lib/auth/mfa/challenge");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "ch-user", email: "ch@example.test" },
|
||||
});
|
||||
|
||||
const { ticket } = await createMfaChallenge(user.id, "login");
|
||||
const active = await loadActiveChallenge(ticket);
|
||||
expect(active).not.toBeNull();
|
||||
|
||||
// Claim-once: first claim wins, second loses, ticket no longer loadable.
|
||||
expect(await claimChallenge(active!.id)).toBe(true);
|
||||
expect(await claimChallenge(active!.id)).toBe(false);
|
||||
expect(await loadActiveChallenge(ticket)).toBeNull();
|
||||
|
||||
// Attempt cap burns a fresh ticket.
|
||||
const second = await createMfaChallenge(user.id, "login");
|
||||
const row = await loadActiveChallenge(second.ticket);
|
||||
let exhausted = false;
|
||||
for (let i = 0; i < MFA_CHALLENGE_ATTEMPT_CAP; i++) {
|
||||
({ exhausted } = await recordChallengeFailure(row!.id));
|
||||
}
|
||||
expect(exhausted).toBe(true);
|
||||
expect(await loadActiveChallenge(second.ticket)).toBeNull();
|
||||
});
|
||||
|
||||
it("recovery code is one-time-use and regeneration invalidates the set", async () => {
|
||||
const {
|
||||
generateRecoveryCodes,
|
||||
persistRecoveryCodes,
|
||||
verifyAndConsumeRecoveryCode,
|
||||
countRemainingRecoveryCodes,
|
||||
regenerateRecoveryCodes,
|
||||
} = await import("@/lib/auth/mfa/recovery-codes");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "rec-user", email: "rec@example.test" },
|
||||
});
|
||||
|
||||
const codes = generateRecoveryCodes();
|
||||
await persistRecoveryCodes(prisma, user.id, codes);
|
||||
expect(await countRemainingRecoveryCodes(user.id)).toBe(codes.length);
|
||||
|
||||
// First use succeeds, second use of the same code fails.
|
||||
expect(await verifyAndConsumeRecoveryCode(user.id, codes[0])).toBe(true);
|
||||
expect(await verifyAndConsumeRecoveryCode(user.id, codes[0])).toBe(false);
|
||||
expect(await countRemainingRecoveryCodes(user.id)).toBe(codes.length - 1);
|
||||
|
||||
// Regeneration drops every prior code (including the one not yet used).
|
||||
const fresh = await regenerateRecoveryCodes(user.id);
|
||||
expect(await countRemainingRecoveryCodes(user.id)).toBe(fresh.length);
|
||||
expect(await verifyAndConsumeRecoveryCode(user.id, codes[1])).toBe(false);
|
||||
expect(await verifyAndConsumeRecoveryCode(user.id, fresh[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user