feat(01-08): bind passkey enrollment to fresh cookie proof

- require an existing-factor proof before issuing registration options
- atomically claim session-bound challenges before credential persistence
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 13:52:22 +02:00
1 parent 9cb11abedb
commit 89c83510d4
3 files changed
+230 -42

No files matched your search

@@ -1,14 +1,114 @@
import { createRegistrationOptions } from "@/lib/auth/passkey";
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { NextRequest } from "next/server";
import {
createAuthenticationOptions,
createRegistrationOptions,
verifyAuthentication,
} from "@/lib/auth/passkey";
import {
createMfaAuthenticationOptions,
verifyMfaAuthentication,
} from "@/lib/auth/mfa/webauthn";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { verifyPassword } from "@/lib/auth/password";
import { prisma } from "@/lib/db";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { stepUpMintSchema } from "@/lib/validations/step-up";
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
const proofRequired = () =>
apiError("Fresh existing-factor proof required", 401);
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
if (!request.headers.get("content-type")?.includes("application/json")) {
return proofRequired();
}
const { data: body, error: jsonError } = await safeJson<
Record<string, unknown>
>(request, { maxBytes: 64 * 1024 });
if (jsonError) return proofRequired();
// Begin an assertion ceremony without beginning registration. This lets the
// browser re-prove a passkey or second-factor security key before this route
// creates any enrollment challenge.
if (
Object.keys(body).length === 1 &&
(body.method === "passkey" || body.method === "webauthn")
) {
const result =
body.method === "passkey"
? await createAuthenticationOptions(user.id)
: await createMfaAuthenticationOptions(user.id);
if (!result) {
return apiError("That verification method is unavailable", 409);
}
return apiSuccess({ ...result, reauth: true });
}
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
let proved = false;
if (parsed.data.method === "password") {
proved = Boolean(
user.passwordHash &&
(await verifyPassword(user.passwordHash, parsed.data.password)),
);
} else if (parsed.data.method === "totp") {
const result = await verifyMfaFactor(user, "totp", parsed.data.code);
proved = result.ok;
} else {
const expectedType =
parsed.data.method === "passkey"
? "authentication"
: "mfa_authentication";
const challenge = await prisma.authChallenge.findUnique({
where: { id: parsed.data.challengeId },
select: { userId: true, type: true },
});
if (challenge?.userId === user.id && challenge.type === expectedType) {
try {
if (parsed.data.method === "passkey") {
const result = await verifyAuthentication(
parsed.data.challengeId,
parsed.data.credential,
);
proved =
result.verification.verified && result.passkey.userId === user.id;
} else {
proved = await verifyMfaAuthentication(
parsed.data.challengeId,
user.id,
parsed.data.credential,
);
}
} catch {
proved = false;
}
}
}
if (!proved) return proofRequired();
// A strong existing factor refreshes the session's MFA stamp. Password proof
// deliberately clears it: registration is authorized by this single-use,
// session-bound challenge but cannot upgrade password-only authentication.
await prisma.session.update({
where: { id: session.id },
data: {
mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(),
},
});
const { options, challengeId } = await createRegistrationOptions(
user.id,
user.email ?? user.username,
session.id,
);
annotate({ action: { name: "auth.passkey.register-options" } });
@@ -1,5 +1,8 @@
import { prisma } from "@/lib/db";
import { verifyRegistration } from "@/lib/auth/passkey";
import {
consumeRegistrationChallenge,
verifyRegistration,
} from "@/lib/auth/passkey";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
@@ -8,11 +11,11 @@ import {
safeJson,
} from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user, session } = await requireCookieAuth();
const { data: body, error: jsonError } = await safeJson<
Record<string, unknown>
@@ -26,7 +29,43 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("challengeId and credential required", 422);
}
const verification = await verifyRegistration(challengeId, credential);
// Read the live session row so a stale fresh-factor stamp cannot be carried
// through a registration ceremony after it has been downgraded/revoked.
const freshSession = await prisma.session.findUnique({
where: { id: session.id },
select: { mfaVerifiedAt: true },
});
if (
!freshSession ||
(freshSession.mfaVerifiedAt &&
freshSession.mfaVerifiedAt.getTime() < Date.now() - 5 * 60 * 1000)
) {
return apiError("Fresh reauthentication proof required", 401);
}
const claim = await consumeRegistrationChallenge({
challengeId,
userId: user.id,
sessionId: session.id,
});
if (!claim.ok) {
return apiError(
claim.reason === "wrong_context"
? "Fresh reauthentication proof required"
: "Registration challenge invalid or expired",
claim.reason === "wrong_context" ? 401 : 400,
);
}
let verification;
try {
verification = await verifyRegistration(
claim.expectedChallenge,
credential,
);
} catch {
return apiError("Passkey verification failed", 400);
}
if (!verification.verified || !verification.registrationInfo) {
return apiError("Passkey verification failed", 400);
+82 -33
View File
@@ -15,6 +15,7 @@ import {
getRpId,
getExpectedOrigin,
} from "@/lib/auth/webauthn-rp";
import { hashToken } from "@/lib/auth/hmac";
// v1.4.43 W13 L-3 — explicit Zod narrowing in front of
// `verifyAuthentication`. Replaces the raw
@@ -75,6 +76,12 @@ const registrationResponseSchema = z
type Transport =
"ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb";
const PASSKEY_REGISTRATION_CHALLENGE_PREFIX = "passkey-registration:v1:";
function registrationChallengeType(sessionId: string): string {
return `${PASSKEY_REGISTRATION_CHALLENGE_PREFIX}${hashToken(sessionId)}`;
}
// The relying-party config (`rpName` / `getRpId` / `getExpectedOrigin`) is
// shared with the second-factor security-key path via `@/lib/auth/webauthn-rp`
// so both ceremonies bind credentials to the identical RP.
@@ -90,6 +97,7 @@ async function cleanupExpiredChallenges() {
export async function createRegistrationOptions(
userId: string,
username: string,
sessionId: string,
) {
await cleanupExpiredChallenges();
@@ -124,7 +132,7 @@ export async function createRegistrationOptions(
data: {
userId,
challenge: options.challenge,
type: "registration",
type: registrationChallengeType(sessionId),
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
},
});
@@ -132,43 +140,84 @@ export async function createRegistrationOptions(
return { options, challengeId: challenge.id };
}
export async function verifyRegistration(
challengeId: string,
response: unknown,
): Promise<VerifiedRegistrationResponse> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
export type RegistrationChallengeClaim =
| { ok: true; expectedChallenge: string; createdAt: Date }
| { ok: false; reason: "invalid" | "wrong_context" };
/**
* Claim a registration challenge for exactly one cookie session.
*
* The preliminary lookup is used only to classify a foreign account/session
* without consuming its challenge. The guarded DELETE is the actual claim:
* concurrent requests cannot both receive the challenge material.
*/
export async function consumeRegistrationChallenge(input: {
challengeId: string;
userId: string;
sessionId: string;
}): Promise<RegistrationChallengeClaim> {
const row = await prisma.authChallenge.findUnique({
where: { id: input.challengeId },
select: { userId: true, type: true, expiresAt: true },
});
if (!challenge || challenge.expiresAt < new Date()) {
throw new Error("Challenge expired or not found");
if (!row) return { ok: false, reason: "invalid" };
const expectedType = registrationChallengeType(input.sessionId);
if (
row.userId !== input.userId ||
(row.type.startsWith(PASSKEY_REGISTRATION_CHALLENGE_PREFIX) &&
row.type !== expectedType)
) {
return { ok: false, reason: "wrong_context" };
}
try {
// v1.4.43 W10 senior-dev L-1 — Zod narrow at the boundary before
// delegating to SimpleWebAuthn's full cryptographic validation.
// A malformed body now fails fast with a structured Zod error
// rather than crashing on a follow-up `.id` deref deeper in the
// verifier. Mirrors the v1.4.43 W13 L-3 narrowing on the
// authentication side.
const parsed = registrationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Registration response shape invalid");
}
const verification = await verifyRegistrationResponse({
response: parsed.data as RegistrationResponseJSON,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
});
return verification;
} finally {
// Invalidate challenge after first verification attempt (success or failure)
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
if (row.type !== expectedType || row.expiresAt <= new Date()) {
return { ok: false, reason: "invalid" };
}
const claimed = await prisma.$queryRaw<
Array<{ challenge: string; created_at: Date }>
>`
DELETE FROM auth_challenges
WHERE id = ${input.challengeId}
AND user_id = ${input.userId}
AND type = ${expectedType}
AND expires_at > NOW()
RETURNING challenge, created_at
`;
if (claimed.length !== 1) {
return { ok: false, reason: "invalid" };
}
return {
ok: true,
expectedChallenge: claimed[0].challenge,
createdAt: claimed[0].created_at,
};
}
export async function verifyRegistration(
expectedChallenge: string,
response: unknown,
): Promise<VerifiedRegistrationResponse> {
// v1.4.43 W10 senior-dev L-1 — Zod narrow at the boundary before
// delegating to SimpleWebAuthn's full cryptographic validation.
// A malformed body now fails fast with a structured Zod error
// rather than crashing on a follow-up `.id` deref deeper in the
// verifier. Mirrors the v1.4.43 W13 L-3 narrowing on the
// authentication side.
const parsed = registrationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Registration response shape invalid");
}
return verifyRegistrationResponse({
response: parsed.data as RegistrationResponseJSON,
expectedChallenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
});
}
// ── Authentication ───────────────────────────────────────