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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:40:53 +02:00
1 parent 86ac1e1d12
commit 64ea8e4102
4 files changed
+93 -36

No files matched your search

@@ -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 });
});
+54 -1
View File
@@ -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 (
+1
View File
@@ -16,6 +16,7 @@ export const GET = apiHandler(async () => {
credentialDeviceType: true,
credentialBackedUp: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "desc" },
});
+14 -35
View File
@@ -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(),
},
});
}