mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(auth): adding a credential beside a second factor needs that factor
On an account with a second factor, TOTP setup, security-key registration and passkey registration no longer accept the password, a password re-proof or a session younger than five minutes. A remembered browser skips the second factor at sign-in and still creates a fresh session, so the password alone could add a key that then satisfies every gate the factor protected. Only a TOTP code, a security key, a passkey or a completed second factor within five minutes opens enrollment there; the Bearer arm of TOTP setup and security-key registration asks the same of its elevation. Accounts without a second factor keep the previous rules. The refusal names the proofs the account can give, and the enrollment dialogs offer exactly those. A proof that verifies now gives its attempt back to the shared re-proof budget, including the current password on a password change.
This commit is contained in:
1 parent
66b1aef40c
commit
0692017f34
14 files changed
+519
-88
No files matched your search
@@ -16,8 +16,10 @@
|
||||
* factor that later satisfies every step-up gate, so a stolen session must not
|
||||
* be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof`
|
||||
* — a sign-in or second factor inside the last five minutes, or a password /
|
||||
* factor proof in the body (the step-up mint's shapes). The Bearer arm already
|
||||
* carries that proof in its elevation. The recovery-code batch is
|
||||
* factor proof in the body (the step-up mint's shapes); on an account that
|
||||
* already has a second factor, only that factor or a passkey. The Bearer arm
|
||||
* carries the proof in its elevation, under the same rule: on an account with
|
||||
* a second factor the elevation must come from one. The recovery-code batch is
|
||||
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
|
||||
* setup never persists codes.
|
||||
*/
|
||||
@@ -41,7 +43,7 @@ const SETUP_RATE_LIMIT = 5;
|
||||
const SETUP_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const auth = await requireMfaManagementAuth();
|
||||
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
|
||||
const { user } = auth;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
*
|
||||
* The cookie arm additionally passes `checkCookieEnrollmentProof`: a session
|
||||
* alone cannot add a security key, for the same reason it cannot enroll TOTP —
|
||||
* the new key would satisfy every step-up gate from then on.
|
||||
* the new key would satisfy every step-up gate from then on. On an account that
|
||||
* already has a second factor, both arms want that factor (or a passkey): a
|
||||
* password, or a session that skipped the factor on a remembered browser, does
|
||||
* not add a key.
|
||||
*/
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
@@ -20,7 +23,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const auth = await requireMfaManagementAuth();
|
||||
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
|
||||
const { user } = auth;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
|
||||
@@ -5,10 +5,16 @@ import {
|
||||
} from "@/lib/auth/passkey";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import {
|
||||
isSecondFactorProof,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiError,
|
||||
@@ -55,6 +61,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) return proofRequired();
|
||||
|
||||
// On an account with a second factor a password does not add a sign-in
|
||||
// credential, exactly as it does not satisfy the second factor at sign-in:
|
||||
// a passkey registered on the strength of the password alone would from then
|
||||
// on sign in without the second factor at all. Refused before a guess is
|
||||
// spent, with the proofs that would work.
|
||||
if (
|
||||
!isSecondFactorProof(parsed.data.method) &&
|
||||
(await hasSecondFactorEnrolled(user))
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.method_refused" },
|
||||
meta: { stage: "passkey_enroll", method: parsed.data.method },
|
||||
});
|
||||
return apiError("Confirm with your second factor or a passkey", 422, {
|
||||
errorCode: "auth.reproof.too_weak",
|
||||
methods: await recentProofMethods(user, true),
|
||||
});
|
||||
}
|
||||
|
||||
// Every proof counts against the account's shared re-proof bucket and every
|
||||
// refusal is audited. Without both, a stolen session could guess the account
|
||||
// password or a TOTP code here as fast as it could send requests.
|
||||
@@ -73,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
return proofRequired();
|
||||
}
|
||||
await refundReproof(user.id);
|
||||
|
||||
// A strong existing factor refreshes the session's MFA stamp. Password proof
|
||||
// deliberately clears it: registration is authorized by this single-use,
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("@/lib/auth/password", () => ({
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
@@ -77,7 +78,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import {
|
||||
requireFreshMfaIfEnrolled,
|
||||
@@ -146,6 +147,9 @@ describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
|
||||
expect(prisma.user.update).toHaveBeenCalled();
|
||||
// The right current password was not a guess: the shared re-proof
|
||||
// bucket gets its slot back.
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
throttleReproof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
@@ -72,6 +73,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
return apiError("Current password is incorrect", 401);
|
||||
}
|
||||
// The current password was right, so this attempt was not a guess. Give the
|
||||
// shared re-proof budget its slot back; otherwise a password change followed
|
||||
// by a few confirmations elsewhere runs the owner out of attempts.
|
||||
await refundReproof(user.id);
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
return apiError("New password must differ from current password", 422);
|
||||
|
||||
@@ -52,6 +52,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
REPROOF_FAILED_CODE,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
@@ -110,6 +111,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
await refundReproof(user.id);
|
||||
|
||||
const now = new Date();
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
|
||||
@@ -44,6 +44,28 @@ export function isReproofRequired(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const REAUTH_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
|
||||
/**
|
||||
* The proofs the refusal says this account can give, or null when it names
|
||||
* none. On an account with a second factor the server leaves the password out,
|
||||
* so the dialog does not offer a choice that can only be refused.
|
||||
*/
|
||||
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
const raw = err.meta?.methods;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const offered = raw.filter((m): m is ReauthMethod =>
|
||||
REAUTH_METHODS.includes(m as ReauthMethod),
|
||||
);
|
||||
return offered.length > 0 ? offered : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a fresh proof of a credential the account already holds, before a
|
||||
* new factor is added. A passkey or security-key assertion begins at the
|
||||
|
||||
@@ -36,6 +36,7 @@ import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
@@ -98,8 +99,14 @@ export function SecurityKeysCard({
|
||||
// options call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
const reauthMethods: ReauthMethod[] = [
|
||||
"password",
|
||||
// An account with a second factor confirms with that factor or a passkey;
|
||||
// the server refuses a password there and names what it will take.
|
||||
const hasSecondFactor = totpEnabled || keys.length > 0;
|
||||
const [offeredMethods, setOfferedMethods] = useState<ReauthMethod[] | null>(
|
||||
null,
|
||||
);
|
||||
const reauthMethods: ReauthMethod[] = offeredMethods ?? [
|
||||
...(hasSecondFactor ? [] : (["password"] as const)),
|
||||
...(totpEnabled ? (["totp"] as const) : []),
|
||||
"passkey",
|
||||
...(keys.length > 0 ? (["webauthn"] as const) : []),
|
||||
@@ -126,6 +133,7 @@ export function SecurityKeysCard({
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
setOfferedMethods(offeredReauthMethods(err));
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
|
||||
@@ -36,7 +36,9 @@ import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
interface SetupData {
|
||||
@@ -128,6 +130,11 @@ export function TotpCard({
|
||||
// call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
const [reauthMethods, setReauthMethods] = useState<ReauthMethod[]>([
|
||||
"password",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
]);
|
||||
|
||||
const beginSetup = useMutation({
|
||||
mutationFn: async (proof?: ExistingFactorProof) => {
|
||||
@@ -148,6 +155,8 @@ export function TotpCard({
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
const offered = offeredReauthMethods(err);
|
||||
if (offered) setReauthMethods(offered);
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
@@ -278,7 +287,7 @@ export function TotpCard({
|
||||
setReauthOpen(open);
|
||||
if (!open) setReauthError(null);
|
||||
}}
|
||||
methods={["password", "passkey", "webauthn"]}
|
||||
methods={reauthMethods}
|
||||
pending={beginSetup.isPending}
|
||||
error={reauthError}
|
||||
onProof={(proof) => beginSetup.mutate(proof)}
|
||||
|
||||
@@ -38,7 +38,11 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { requireRecentProof, StepUpRequiredError } from "../api-handler";
|
||||
import {
|
||||
requireMfaManagementAuth,
|
||||
requireRecentProof,
|
||||
StepUpRequiredError,
|
||||
} from "../api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { headers } from "next/headers";
|
||||
@@ -219,3 +223,44 @@ describe("Bearer", () => {
|
||||
await refusal(requireRecentProof({ bearer: "elevation" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding a factor over Bearer (`freshFactorIfEnrolled`)", () => {
|
||||
const passwordElevation = {
|
||||
userId: "user-1",
|
||||
apiTokenId: "tok-1",
|
||||
consumedAt: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
method: "password",
|
||||
};
|
||||
|
||||
it("a password-proved elevation still adds a first factor", async () => {
|
||||
bearer(PLAIN_USER, `hle_${"a".repeat(64)}`);
|
||||
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue(
|
||||
passwordElevation as never,
|
||||
);
|
||||
const auth = await requireMfaManagementAuth({
|
||||
freshFactorIfEnrolled: true,
|
||||
});
|
||||
expect(auth.transport).toBe("bearer");
|
||||
});
|
||||
|
||||
it("a password-proved elevation does not add a factor beside an existing one", async () => {
|
||||
bearer(MFA_USER, `hle_${"a".repeat(64)}`);
|
||||
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue(
|
||||
passwordElevation as never,
|
||||
);
|
||||
await refusal(requireMfaManagementAuth({ freshFactorIfEnrolled: true }));
|
||||
});
|
||||
|
||||
it("a second-factor elevation does", async () => {
|
||||
bearer(MFA_USER, `hle_${"a".repeat(64)}`);
|
||||
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({
|
||||
...passwordElevation,
|
||||
method: "totp",
|
||||
} as never);
|
||||
const auth = await requireMfaManagementAuth({
|
||||
freshFactorIfEnrolled: true,
|
||||
});
|
||||
expect(auth.transport).toBe("bearer");
|
||||
});
|
||||
});
|
||||
+19
-65
@@ -12,6 +12,13 @@ import { annotate, eventStorage, getEvent } from "./logging/context";
|
||||
import { emitIfSampled } from "./logging/transports";
|
||||
import { redactOptional, redactSecrets } from "./logging/redact";
|
||||
import { getSession } from "./auth/session";
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
recentProofMethods,
|
||||
type RecentProofMethod,
|
||||
} from "./auth/second-factor";
|
||||
|
||||
export { recentProofMethods, type RecentProofMethod };
|
||||
import { auditLog, recordDelegatedAccess } from "./auth/audit";
|
||||
import {
|
||||
resolveBearerToken,
|
||||
@@ -1388,13 +1395,6 @@ export async function requireFreshMfaOrElevationIfEnrolled(
|
||||
return { ...auth, commitElevation };
|
||||
}
|
||||
|
||||
/**
|
||||
* A proof the recent-proof gate can ask for. `password` and `passkey` on an
|
||||
* account without a second factor; `totp`, `webauthn` and `passkey` on one
|
||||
* with a second factor, where a password alone has never been enough.
|
||||
*/
|
||||
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
|
||||
|
||||
/**
|
||||
* How the recent-proof gate treats a Bearer caller.
|
||||
*
|
||||
@@ -1508,63 +1508,6 @@ export async function assertRecentCookieProof(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The proofs that would satisfy {@link assertRecentCookieProof} for this
|
||||
* account. Also what `POST /api/auth/reproof` accepts, so the dialog and the
|
||||
* endpoint cannot disagree.
|
||||
*/
|
||||
export async function recentProofMethods(
|
||||
user: Pick<User, "id" | "totpConfirmedAt" | "passwordHash">,
|
||||
enrolled?: boolean,
|
||||
): Promise<RecentProofMethod[]> {
|
||||
const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user));
|
||||
const [keys, passkeys] = await Promise.all([
|
||||
prisma.webauthnMfaCredential.count({ where: { userId: user.id } }),
|
||||
prisma.passkey.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
const methods: RecentProofMethod[] = [];
|
||||
if (hasSecond) {
|
||||
if (user.totpConfirmedAt) methods.push("totp");
|
||||
if (keys > 0) methods.push("webauthn");
|
||||
} else if (user.passwordHash) {
|
||||
methods.push("password");
|
||||
}
|
||||
if (passkeys > 0) methods.push("passkey");
|
||||
return methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this account have a SECOND FACTOR at all?
|
||||
*
|
||||
* Either factor enrols it: a confirmed TOTP secret OR a registered WebAuthn
|
||||
* security key. A webauthn-only account must clear step-up too, so every
|
||||
* boundary that asks this question tracks `requireFreshMfa`'s either-factor
|
||||
* rule rather than reading `totpConfirmedAt` alone. It was written out three
|
||||
* times before it was a function, which is two more places for the second arm
|
||||
* to be forgotten.
|
||||
*
|
||||
* DELIBERATELY DOES NOT COUNT A PRIMARY PASSKEY, even though a passkey login
|
||||
* stamps `mfaVerifiedAt` and could therefore satisfy the gate. This predicate
|
||||
* answers "is there a second factor", and that is the question
|
||||
* `requireFreshMfaIfEnrolled` asks before deciding whether account deletion, the
|
||||
* data reset, the password change, the encrypted export and key rotation demand
|
||||
* a step-up at all. Counting a passkey here would silently pull every
|
||||
* passkey-holding account into a gate those routes have never applied to them —
|
||||
* a behaviour change to five destructive actions, made in passing, for the
|
||||
* benefit of a sixth. {@link canProveFreshFactor} is where the wider question
|
||||
* lives.
|
||||
*/
|
||||
async function hasSecondFactorEnrolled(user: {
|
||||
id: string;
|
||||
totpConfirmedAt: Date | null;
|
||||
}): Promise<boolean> {
|
||||
if (user.totpConfirmedAt) return true;
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
return webauthnKeyCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this account produce a fresh-factor proof at all?
|
||||
*
|
||||
@@ -1719,10 +1662,17 @@ export type MfaManagementContext = {
|
||||
* set. Passkey removal passes `"any-possession"` because a primary passkey IS
|
||||
* the proof there — see {@link FreshFactorProofSource}. It changes only the
|
||||
* reachability pre-check; the gate itself is identical either way.
|
||||
* @param options.freshFactorIfEnrolled the Bearer arm of ADDING a factor
|
||||
* (TOTP setup, security-key registration). On an account that already has a
|
||||
* second factor, the elevation must come from a second-factor or passkey
|
||||
* proof, the rule `checkCookieEnrollmentProof` holds on the cookie arm; on
|
||||
* one without, a password-proved elevation is still enough. The cookie arm
|
||||
* is not changed by it: those routes run their own enrollment proof there.
|
||||
*/
|
||||
export async function requireMfaManagementAuth(
|
||||
options: {
|
||||
freshFactor?: boolean;
|
||||
freshFactorIfEnrolled?: boolean;
|
||||
proofSource?: FreshFactorProofSource;
|
||||
} = {},
|
||||
): Promise<MfaManagementContext> {
|
||||
@@ -1747,9 +1697,13 @@ export async function requireMfaManagementAuth(
|
||||
// Bearer path. Resolution first: an unknown, revoked, expired, or narrow-scope
|
||||
// token is refused here and never gets as far as presenting an elevation.
|
||||
const auth = await requireBearerAuth();
|
||||
const bearerFreshFactor =
|
||||
freshFactor ||
|
||||
(options.freshFactorIfEnrolled === true &&
|
||||
(await hasSecondFactorEnrolled(auth.user)));
|
||||
const commitElevation = await resolveBearerElevation(
|
||||
{ user: auth.user, apiTokenId: auth.apiTokenId },
|
||||
{ freshFactor, proofSource },
|
||||
{ freshFactor: bearerFreshFactor, proofSource },
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Adding a credential to an account that already has a second factor.
|
||||
*
|
||||
* A password, a young session or a password re-proof used to be enough to
|
||||
* add a security key, a TOTP authenticator or a passkey even when the account
|
||||
* was protected by a second factor. A session that signed in on a remembered
|
||||
* browser skips the factor and is still young, so the password alone could
|
||||
* add a key that then satisfies every gate the factor protected. On such an
|
||||
* account only possession of a factor counts now; the account without one is
|
||||
* unchanged.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
session: { findUnique: vi.fn(), update: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
passkey: { count: vi.fn() },
|
||||
authChallenge: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
refundRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn() }));
|
||||
vi.mock("@/lib/auth/passkey", () => ({
|
||||
verifyAuthentication: vi.fn(),
|
||||
createAuthenticationOptions: vi.fn(),
|
||||
createRegistrationOptions: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/webauthn", () => ({
|
||||
verifyMfaAuthentication: vi.fn(),
|
||||
createMfaAuthenticationOptions: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa/verify-factor", () => ({ verifyMfaFactor: vi.fn() }));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireCookieAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import { createRegistrationOptions } from "@/lib/auth/passkey";
|
||||
import { requireCookieAuth } from "@/lib/api-handler";
|
||||
import { checkCookieEnrollmentProof } from "../existing-factor-proof";
|
||||
import { POST as PASSKEY_REGISTER_OPTIONS } from "@/app/api/auth/passkey/register-options/route";
|
||||
|
||||
const NOW = Date.now();
|
||||
const fresh = new Date(NOW - 60_000);
|
||||
const stale = new Date(NOW - 60 * 60_000);
|
||||
|
||||
const ENROLLED: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
totpSecretEncrypted: string | null;
|
||||
totpLastStep: null;
|
||||
totpConfirmedAt: Date | null;
|
||||
} = {
|
||||
id: "user-1",
|
||||
username: "u",
|
||||
email: "u@example.com",
|
||||
passwordHash: "hash",
|
||||
totpSecretEncrypted: "enc",
|
||||
totpLastStep: null,
|
||||
totpConfirmedAt: new Date("2026-01-01"),
|
||||
};
|
||||
const PLAIN = { ...ENROLLED, totpSecretEncrypted: null, totpConfirmedAt: null };
|
||||
|
||||
function req(body?: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/mfa/totp/setup", {
|
||||
method: "POST",
|
||||
headers: body === undefined ? {} : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function session(stamps: {
|
||||
mfaVerifiedAt?: Date | null;
|
||||
createdAt?: Date;
|
||||
reproofAt?: Date | null;
|
||||
}) {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: stamps.mfaVerifiedAt ?? null,
|
||||
createdAt: stamps.createdAt ?? stale,
|
||||
reproofAt: stamps.reproofAt ?? null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
function gate(user: typeof ENROLLED | typeof PLAIN, request: Request) {
|
||||
return checkCookieEnrollmentProof({
|
||||
user: user as never,
|
||||
sessionId: "sess-1",
|
||||
request,
|
||||
ipAddress: "203.0.113.9",
|
||||
stage: "totp_enroll",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 5,
|
||||
remaining: 4,
|
||||
resetAt: NOW + 1000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("account with a second factor", () => {
|
||||
it("a session signed in a minute ago is not enough (remembered browser)", async () => {
|
||||
session({ createdAt: fresh });
|
||||
const res = await gate(ENROLLED, req());
|
||||
expect(res?.status).toBe(401);
|
||||
const body = await res!.json();
|
||||
expect(body.meta.errorCode).toBe("auth.reproof.required");
|
||||
expect(body.meta.methods).toEqual(["totp"]);
|
||||
});
|
||||
|
||||
it("a password re-proof stamp is not enough", async () => {
|
||||
session({ reproofAt: fresh });
|
||||
const res = await gate(ENROLLED, req());
|
||||
expect(res?.status).toBe(401);
|
||||
});
|
||||
|
||||
it("a password in the body is refused before it is verified", async () => {
|
||||
session({ createdAt: fresh });
|
||||
const res = await gate(
|
||||
ENROLLED,
|
||||
req({ method: "password", password: "correct horse" }),
|
||||
);
|
||||
expect(res?.status).toBe(401);
|
||||
expect(verifyPassword).not.toHaveBeenCalled();
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a completed second factor within five minutes passes", async () => {
|
||||
session({ mfaVerifiedAt: fresh });
|
||||
expect(await gate(ENROLLED, req())).toBeNull();
|
||||
});
|
||||
|
||||
it("a TOTP code in the body passes and gives the attempt back", async () => {
|
||||
session({});
|
||||
vi.mocked(verifyMfaFactor).mockResolvedValue({ ok: true } as never);
|
||||
expect(await gate(ENROLLED, req({ method: "totp", code: "123456" }))).toBe(
|
||||
null,
|
||||
);
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
|
||||
});
|
||||
|
||||
it("a security key alone makes the account enrolled", async () => {
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
session({ createdAt: fresh });
|
||||
const res = await gate(PLAIN, req());
|
||||
expect(res?.status).toBe(401);
|
||||
expect((await res!.json()).meta.methods).toEqual(["webauthn"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("account without a second factor (unchanged)", () => {
|
||||
it("a session signed in a minute ago passes", async () => {
|
||||
session({ createdAt: fresh });
|
||||
expect(await gate(PLAIN, req())).toBeNull();
|
||||
});
|
||||
|
||||
it("a password in the body passes", async () => {
|
||||
session({});
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
expect(
|
||||
await gate(PLAIN, req({ method: "password", password: "pw" })),
|
||||
).toBeNull();
|
||||
expect(verifyPassword).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/passkey/register-options", () => {
|
||||
function passkeyReq(body: unknown) {
|
||||
return new Request("http://localhost/api/auth/passkey/register-options", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("refuses a password on an account with a second factor, before verifying it", async () => {
|
||||
vi.mocked(requireCookieAuth).mockResolvedValue({
|
||||
user: ENROLLED,
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
const res = await PASSKEY_REGISTER_OPTIONS(
|
||||
passkeyReq({ method: "password", password: "correct horse" }) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta.errorCode).toBe("auth.reproof.too_weak");
|
||||
expect(body.meta.methods).toEqual(["totp"]);
|
||||
expect(verifyPassword).not.toHaveBeenCalled();
|
||||
expect(createRegistrationOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still takes a password on an account without one", async () => {
|
||||
vi.mocked(requireCookieAuth).mockResolvedValue({
|
||||
user: PLAIN,
|
||||
session: { id: "sess-1" },
|
||||
} as never);
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
vi.mocked(createRegistrationOptions).mockResolvedValue({
|
||||
options: {},
|
||||
challengeId: "ch-1",
|
||||
} as never);
|
||||
const res = await PASSKEY_REGISTER_OPTIONS(
|
||||
passkeyReq({ method: "password", password: "pw" }) as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,15 @@ import { apiError } from "@/lib/api-response";
|
||||
import { REPROOF_REQUIRED_CODE } from "@/lib/api-errors";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
refundRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
|
||||
@@ -73,6 +81,16 @@ export async function throttleReproof(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back the attempt a proof charged once it verified. The bucket exists to
|
||||
* stop guessing; a proof that verified was not a guess, and without the refund
|
||||
* an owner who confirms a few actions in a row (an export, a share link, a
|
||||
* password change) runs the shared budget dry and is told to wait.
|
||||
*/
|
||||
export async function refundReproof(userId: string): Promise<void> {
|
||||
await refundRateLimit(reproofBucket(userId));
|
||||
}
|
||||
|
||||
export type ProofOutcome = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
@@ -184,14 +202,15 @@ export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
* session could enroll their own authenticator, and from then on hold a factor
|
||||
* that satisfies every step-up gate, registers a passkey and survives the
|
||||
* owner changing the password. So the caller must show that the person at the
|
||||
* keyboard can authenticate as the account owner right now, in one of three
|
||||
* ways:
|
||||
* keyboard can authenticate as the account owner right now.
|
||||
*
|
||||
* - the session completed a second factor or passkey sign-in within the last
|
||||
* five minutes (`Session.mfaVerifiedAt`);
|
||||
* What counts depends on whether the account already has a second factor.
|
||||
*
|
||||
* Without one (password, passkey or single sign-on only), any of:
|
||||
* - the session completed a passkey sign-in or re-proof within the last five
|
||||
* minutes (`Session.mfaVerifiedAt`);
|
||||
* - the session re-proved a credential at `POST /api/auth/reproof` within
|
||||
* the last five minutes (`Session.reproofAt`), which accepts the same
|
||||
* proofs this gate takes in the body;
|
||||
* the last five minutes (`Session.reproofAt`);
|
||||
* - the session itself was created by a sign-in within the last five
|
||||
* minutes (`Session.createdAt`, which no later write touches) — this is
|
||||
* also what lets an SSO-only account with nothing else to re-prove enroll
|
||||
@@ -200,6 +219,13 @@ export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
* `{ method: "totp", code }` or a passkey / security-key assertion, the
|
||||
* same shapes the step-up mint accepts.
|
||||
*
|
||||
* With one, only a proof of possession of a factor: `Session.mfaVerifiedAt`
|
||||
* within five minutes, or a TOTP code, security-key or passkey assertion in
|
||||
* the body. A password is not enough and neither is a young session: signing
|
||||
* in on a remembered browser skips the second factor and still creates a
|
||||
* fresh session, so `createdAt` would let the password alone add a new key,
|
||||
* which then satisfies every gate the second factor was protecting.
|
||||
*
|
||||
* Returns the refusal to send, or null to proceed. Proofs in the body are
|
||||
* throttled and audited through the shared re-proof bucket.
|
||||
*/
|
||||
@@ -212,6 +238,7 @@ export async function checkCookieEnrollmentProof(args: {
|
||||
}): Promise<Response | null> {
|
||||
const { user, sessionId, request, ipAddress, stage } = args;
|
||||
|
||||
const enrolled = await hasSecondFactorEnrolled(user);
|
||||
const row = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true },
|
||||
@@ -219,20 +246,25 @@ export async function checkCookieEnrollmentProof(args: {
|
||||
const now = Date.now();
|
||||
const recent = (d: Date | null | undefined) =>
|
||||
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
|
||||
if (
|
||||
recent(row?.mfaVerifiedAt) ||
|
||||
recent(row?.createdAt) ||
|
||||
recent(row?.reproofAt)
|
||||
) {
|
||||
const sessionSatisfies = enrolled
|
||||
? recent(row?.mfaVerifiedAt)
|
||||
: recent(row?.mfaVerifiedAt) ||
|
||||
recent(row?.createdAt) ||
|
||||
recent(row?.reproofAt);
|
||||
if (sessionSatisfies) {
|
||||
annotate({ meta: { enrollment_proof: "recent_session" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
const proof = await readProof(request);
|
||||
if (proof === "absent") {
|
||||
annotate({ action: { name: "auth.reproof.required" }, meta: { stage } });
|
||||
annotate({
|
||||
action: { name: "auth.reproof.required" },
|
||||
meta: { stage, second_factor: enrolled },
|
||||
});
|
||||
return apiError("Confirm it is you before adding a sign-in method", 401, {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
methods: await recentProofMethods(user, enrolled),
|
||||
});
|
||||
}
|
||||
if (proof === "invalid") {
|
||||
@@ -240,6 +272,18 @@ export async function checkCookieEnrollmentProof(args: {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
});
|
||||
}
|
||||
// Refused before a guess is spent: a password that verified would still not
|
||||
// open this gate on an account with a second factor.
|
||||
if (enrolled && !isSecondFactorProof(proof.method)) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.method_refused" },
|
||||
meta: { stage, method: proof.method },
|
||||
});
|
||||
return apiError("Confirm with your second factor or a passkey", 401, {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
methods: await recentProofMethods(user, enrolled),
|
||||
});
|
||||
}
|
||||
|
||||
const limited = await throttleReproof(user.id, ipAddress, stage);
|
||||
if (limited) return limited;
|
||||
@@ -257,10 +301,22 @@ export async function checkCookieEnrollmentProof(args: {
|
||||
errorCode: REPROOF_FAILED_CODE,
|
||||
});
|
||||
}
|
||||
await refundReproof(user.id);
|
||||
annotate({ meta: { enrollment_proof: proof.method } });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The proofs that stand for possession of a factor rather than knowledge of
|
||||
* the password. On an account with a second factor these are the only ones
|
||||
* that may add a credential.
|
||||
*/
|
||||
export function isSecondFactorProof(
|
||||
method: ExistingFactorProof["method"],
|
||||
): boolean {
|
||||
return method === "totp" || method === "webauthn" || method === "passkey";
|
||||
}
|
||||
|
||||
async function readProof(
|
||||
request: Request,
|
||||
): Promise<ExistingFactorProof | "absent" | "invalid"> {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Does this account have a SECOND FACTOR at all?
|
||||
*
|
||||
* Either factor enrols it: a confirmed TOTP secret OR a registered WebAuthn
|
||||
* security key. A webauthn-only account must clear step-up too, so every
|
||||
* boundary that asks this question tracks `requireFreshMfa`'s either-factor
|
||||
* rule rather than reading `totpConfirmedAt` alone. It was written out three
|
||||
* times before it was a function, which is two more places for the second arm
|
||||
* to be forgotten.
|
||||
*
|
||||
* DELIBERATELY DOES NOT COUNT A PRIMARY PASSKEY, even though a passkey login
|
||||
* stamps `mfaVerifiedAt` and could therefore satisfy the gate. This predicate
|
||||
* answers "is there a second factor", and that is the question
|
||||
* `requireFreshMfaIfEnrolled` asks before deciding whether account deletion, the
|
||||
* data reset, the password change, the encrypted export and key rotation demand
|
||||
* a step-up at all. Counting a passkey here would silently pull every
|
||||
* passkey-holding account into a gate those routes have never applied to them —
|
||||
* a behaviour change to five destructive actions, made in passing, for the
|
||||
* benefit of a sixth. `canProveFreshFactor` in `api-handler.ts` is where the wider question
|
||||
* lives.
|
||||
*/
|
||||
export async function hasSecondFactorEnrolled(user: {
|
||||
id: string;
|
||||
totpConfirmedAt: Date | null;
|
||||
}): Promise<boolean> {
|
||||
if (user.totpConfirmedAt) return true;
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
return webauthnKeyCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A proof the recent-proof gate can ask for. `password` and `passkey` on an
|
||||
* account without a second factor; `totp`, `webauthn` and `passkey` on one
|
||||
* with a second factor, where a password alone has never been enough.
|
||||
*/
|
||||
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
|
||||
|
||||
/**
|
||||
* The proofs that would satisfy `assertRecentCookieProof` for this
|
||||
* account. Also what `POST /api/auth/reproof` accepts, so the dialog and the
|
||||
* endpoint cannot disagree.
|
||||
*/
|
||||
export async function recentProofMethods(
|
||||
user: Pick<User, "id" | "totpConfirmedAt" | "passwordHash">,
|
||||
enrolled?: boolean,
|
||||
): Promise<RecentProofMethod[]> {
|
||||
const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user));
|
||||
const [keys, passkeys] = await Promise.all([
|
||||
prisma.webauthnMfaCredential.count({ where: { userId: user.id } }),
|
||||
prisma.passkey.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
const methods: RecentProofMethod[] = [];
|
||||
if (hasSecond) {
|
||||
if (user.totpConfirmedAt) methods.push("totp");
|
||||
if (keys > 0) methods.push("webauthn");
|
||||
} else if (user.passwordHash) {
|
||||
methods.push("password");
|
||||
}
|
||||
if (passkeys > 0) methods.push("passkey");
|
||||
return methods;
|
||||
}
|
||||
Reference in new issue
Block a user