From 64ea8e4102e34b6e1d6cee5571836fbf56e8296f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 20:40:53 +0200 Subject: [PATCH] feat(passkeys): surface last-used, rename, and an upgrade nudge Stamp each passkey's last-used time on a successful assertion and return it from the list, add a rename endpoint, and add a dismissible add-a-passkey nudge for accounts that have none. Share the relying-party configuration with the second-factor path. --- .../auth/me/passkey-nudge/dismiss/route.ts | 24 ++++++++ src/app/api/auth/passkeys/[id]/route.ts | 55 ++++++++++++++++++- src/app/api/auth/passkeys/route.ts | 1 + src/lib/auth/passkey.ts | 49 +++++------------ 4 files changed, 93 insertions(+), 36 deletions(-) create mode 100644 src/app/api/auth/me/passkey-nudge/dismiss/route.ts diff --git a/src/app/api/auth/me/passkey-nudge/dismiss/route.ts b/src/app/api/auth/me/passkey-nudge/dismiss/route.ts new file mode 100644 index 000000000..d21d0d3c6 --- /dev/null +++ b/src/app/api/auth/me/passkey-nudge/dismiss/route.ts @@ -0,0 +1,24 @@ +/** + * POST /api/auth/me/passkey-nudge/dismiss + * + * Permanently dismiss the "add a passkey" upgrade nudge for the calling user. + * Cookie-only; idempotent. Sets `User.passkeyUpgradeNudgeDismissed = true`. + */ +import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async () => { + const { user } = await requireCookieAuth(); + + await prisma.user.update({ + where: { id: user.id }, + data: { passkeyUpgradeNudgeDismissed: true }, + }); + + annotate({ action: { name: "auth.passkey.nudge.dismiss" } }); + return apiSuccess({ dismissed: true }); +}); diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 5b0417864..4c9aadf2f 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -1,9 +1,62 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { webauthnKeyNameSchema } from "@/lib/validations/mfa"; +import { z } from "zod/v4"; + +const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema }); + +export const PATCH = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + const { user } = await requireAuth(); + const { id } = await params; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = passkeyRenameSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + + const passkey = await prisma.passkey.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (!passkey || passkey.userId !== user.id) { + return apiError("Passkey not found", 404); + } + + const updated = await prisma.passkey.update({ + where: { id }, + data: { name: parsed.data.name }, + select: { + id: true, + name: true, + credentialDeviceType: true, + credentialBackedUp: true, + createdAt: true, + lastUsedAt: true, + }, + }); + + annotate({ action: { name: "auth.passkey.rename" } }); + return apiSuccess(updated); + }, +); export const DELETE = apiHandler( async ( diff --git a/src/app/api/auth/passkeys/route.ts b/src/app/api/auth/passkeys/route.ts index 6c2a7d497..dfebfed0c 100644 --- a/src/app/api/auth/passkeys/route.ts +++ b/src/app/api/auth/passkeys/route.ts @@ -16,6 +16,7 @@ export const GET = apiHandler(async () => { credentialDeviceType: true, credentialBackedUp: true, createdAt: true, + lastUsedAt: true, }, orderBy: { createdAt: "desc" }, }); diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index 4ebb661a0..3aa980ba1 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -10,6 +10,11 @@ import { } from "@simplewebauthn/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; +import { + RP_NAME as rpName, + getRpId, + getExpectedOrigin, +} from "@/lib/auth/webauthn-rp"; // v1.4.43 W13 L-3 — explicit Zod narrowing in front of // `verifyAuthentication`. Replaces the raw @@ -76,7 +81,9 @@ type Transport = | "smart-card" | "usb"; -const rpName = "HealthLog"; +// 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. async function cleanupExpiredChallenges() { await prisma.authChallenge.deleteMany({ @@ -84,38 +91,6 @@ async function cleanupExpiredChallenges() { }); } -function getConfiguredOrigins(): string[] { - const candidates = [ - process.env.APP_URL, - process.env.NEXT_PUBLIC_APP_URL, - "http://localhost:3000", - ] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)); - - const validOrigins = candidates - .map((value) => { - try { - return new URL(value).origin; - } catch { - return null; - } - }) - .filter((value): value is string => Boolean(value)); - - return Array.from(new Set(validOrigins)); -} - -function getRpId(): string { - const origins = getConfiguredOrigins(); - return new URL(origins[0]).hostname; -} - -function getExpectedOrigin(): string | string[] { - const origins = getConfiguredOrigins(); - return origins.length === 1 ? origins[0] : origins; -} - // ── Registration ───────────────────────────────────────── export async function createRegistrationOptions( @@ -294,10 +269,14 @@ export async function verifyAuthentication( }); if (verification.verified) { - // Update counter + // Update counter + stamp last-used so the management UI can surface + // when each passkey was last exercised (v1.23 passkey QoL). await prisma.passkey.update({ where: { id: passkey.id }, - data: { counter: BigInt(verification.authenticationInfo.newCounter) }, + data: { + counter: BigInt(verification.authenticationInfo.newCounter), + lastUsedAt: new Date(), + }, }); }