diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts index 23bb29e64..ec7852eb2 100644 --- a/src/app/api/auth/passkey/register-options/route.ts +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -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 + >(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" } }); diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts index 1b9ae3b84..e50fc106c 100644 --- a/src/app/api/auth/passkey/register-verify/route.ts +++ b/src/app/api/auth/passkey/register-verify/route.ts @@ -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 @@ -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); diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index 4cd89840f..ab7cbd1c3 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -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 { - 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 { + 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 { + // 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 ───────────────────────────────────────