fix(auth): harden the second-factor verify and disable surfaces

Reject any challenge that is not a login challenge before minting a session,
cap the disable and confirm request bodies through safeJson, and correct the
recovery-code scan comment to match its early-return-on-match behaviour.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:08:10 +02:00
1 parent 6d053c3667
commit 8f56ac95d8
4 files changed
+31 -12

No files matched your search

+10 -5
View File
@@ -16,7 +16,12 @@ import {
HttpError,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -47,10 +52,10 @@ export const POST = apiHandler(async (req: Request) => {
return res;
}
let body: unknown;
try {
body = await req.json();
} catch {
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4096,
});
if (jsonError) {
throw new HttpError(400, "Invalid JSON body");
}
const parsed = mfaDisableSchema.safeParse(body);
+10 -5
View File
@@ -13,7 +13,12 @@
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA.
*/
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -54,10 +59,10 @@ export const POST = apiHandler(async (req: Request) => {
throw new HttpError(409, "Start enrollment before confirming");
}
let body: unknown;
try {
body = await req.json();
} catch {
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4096,
});
if (jsonError) {
throw new HttpError(400, "Invalid JSON body");
}
const parsed = totpConfirmSchema.safeParse(body);
+8
View File
@@ -70,6 +70,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid or expired challenge", 401);
}
// Only a login-issued challenge may be redeemed for a session here. A future
// step-up or webauthn challenge kind must never mint a login session through
// this endpoint, so reject anything but "login" with the same generic 401.
if (challenge.kind !== "login") {
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: {
+3 -2
View File
@@ -117,8 +117,9 @@ export async function verifyAndConsumeRecoveryCode(
});
for (const row of rows) {
// Argon2id verify is constant-time; iterate the full set so the match
// position is not observable through timing.
// Argon2id verify is constant-time per code. A match returns early (the 200
// already reveals validity, so match-position timing leaks nothing useful);
// a miss scans on, so a wrong code never reveals how many remain.
const match = await verifyPassword(row.codeHash, normalised);
if (!match) continue;