Passkeys can now be renamed

This commit is contained in:
Karl Ludwig Weise
2026-07-24 13:53:35 +02:00
parent c8424fbf07
commit 2677fed552
7 changed files with 475 additions and 3 deletions
@@ -0,0 +1,63 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import type { RedactedPasskeyHydrated } from "$lib/types/passkeys";
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
let { entity, done }: { entity: RedactedPasskeyHydrated; done: () => void } = $props();
const form = superForm(
{
passkeyId: untrack(() => entity.id),
deviceName: untrack(() => entity.deviceName || ""),
},
{
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["account.passkeys.edit.success"]());
done();
} else if (event.result.type === "failure") {
toast.error(m["account.passkeys.edit.error"]());
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
</script>
<Form.Root {enhance} action="?/edit">
<Form.Field {form} name="passkeyId" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.passkeyId} type="passkeyId" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="deviceName">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.name"]()}</Form.Label>
<Input {...props} bind:value={$formData.deviceName} type="deviceName" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<div class="mt-6 flex flex-col gap-4">
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
{m["account.passkeys.edit.action"]()}
</Form.Button>
</div>
</Form.Root>
@@ -0,0 +1,5 @@
import EditPasskeyForm from "./edit-passkey-form.svelte";
export { EditPasskeyForm };
export { formSchema } from "./schema";
export type { FormSchema } from "./schema";
@@ -0,0 +1,9 @@
import { m } from "$i18n/messages";
import { z } from "zod";
export const formSchema = z.object({
passkeyId: z.string(),
deviceName: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()),
});
export type FormSchema = typeof formSchema;
@@ -1,7 +1,10 @@
import { ROUTES } from "$lib/const/routes.js";
import logger from "$lib/logger";
import type { RedactedPasskey, RedactedPasskeyHydrated } from "$lib/types/passkeys.js";
import { redirect } from "@sveltejs/kit";
import { fail, redirect, type Actions } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 as zod } from "sveltekit-superforms/adapters";
import { formSchema as editFormSchema } from "./(components)/edit-passkey-form";
const log = logger.setContext(import.meta.filename);
@@ -47,3 +50,49 @@ export const load = async (event) => {
},
};
};
export const actions: Actions = {
edit: async (event) => {
const form = await superValidate(event, zod(editFormSchema));
if (!form.valid) {
log.error("Edit passkey form is not valid", { errors: form.errors });
return fail(400, {
form: { ...form, data: { ...form.data } },
error: "Form is not valid",
});
}
if (!form.data.passkeyId) {
log.error("User trying to edit a passkey, but has no passkeyId");
redirect(302, ROUTES.LOGOUT);
}
const resp = await event.fetch(`/api/auth/passkeys/${form.data.passkeyId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({
deviceName: form.data.deviceName,
}),
});
if (resp.status < 400) {
return { form };
} else {
let error = "Unknown error";
try {
const body = await resp.json();
error = body.error;
} catch (e) {
log.error("Failed to parse edit passkey error response", { error: e });
}
return fail(400, {
form: { ...form, data: { ...form.data } },
error,
});
}
},
};
@@ -4,13 +4,14 @@
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { List, ListItem } from "$lib/components/templates/list";
import { LoadingList } from "$lib/components/templates/loading";
import { openDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { closeDialog, openDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { Headline, Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import type { RedactedPasskeyHydrated } from "$lib/types/passkeys";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { getLocalTimeZone } from "@internationalized/date";
import { Pen, PlusIcon, Trash2 } from "@lucide/svelte";
import { EditPasskeyForm } from "./(components)/edit-passkey-form";
const { data } = $props();
let curItem: RedactedPasskeyHydrated | null = $state(null);
@@ -116,7 +117,13 @@
triggerHidden={true}
>
{#if curItem}
Edit
<EditPasskeyForm
entity={curItem}
done={() => {
closeDialog("edit");
curItem = null;
}}
/>
{/if}
</ResponsiveDialog>
<ResponsiveDialog
@@ -14,6 +14,102 @@ import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/auth/passkeys/{passkeyId}", "PUT", {
summary: "Update WebAuthn passkey",
description:
"Allows authenticated users to change the deviceName of one of their WebAuthn passkeys.",
tags: ["Authentication"],
parameters: [
{
name: "passkeyId",
in: "path",
required: true,
schema: { type: "string" },
description: "The ID of the passkey",
},
],
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
deviceName: {
type: "string",
description: "New recognizable name for the passkey",
},
},
required: ["deviceName"],
},
},
},
},
responses: {
"200": {
description: "Passkey updated successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
deviceName: { type: "string", description: "New name" },
},
required: ["deviceName"],
},
example: {
deviceName: "My new name",
},
},
},
},
"400": {
description: "Cannot update passkey",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Cannot update passkey" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Authentication required" },
},
},
},
"403": {
description: "Not authorized to update this passkey",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "You can only update your own passkeys" },
},
},
},
"404": {
description: "Passkey not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Passkey not found" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Internal server error" },
},
},
},
},
});
registerOpenAPIRoute("/auth/passkeys/{passkeyId}", "DELETE", {
summary: "Delete a WebAuthn passkey from user account",
description:
@@ -96,6 +192,70 @@ registerOpenAPIRoute("/auth/passkeys/{passkeyId}", "DELETE", {
},
});
export async function PUT({ params, request, locals }: RequestEvent) {
const log = logger.setContext("API.PutPasskey");
const { passkeyId } = params;
try {
// Check authentication
if (!locals.user) {
throw new AuthorizationError("Authentication required");
}
if (!passkeyId) {
throw new ValidationError("Passkey ID is required");
}
const userId = locals.user.id;
log.debug("Attempting to update passkey", {
passkeyId,
userId,
});
// Get all passkeys for the user
const userPasskeys = await WebAuthnService.getUserPasskeys(userId);
// Check if passkey exists and belongs to the user
const passkeyToRename = userPasskeys.find((p) => p.id === passkeyId);
if (!passkeyToRename) {
throw new NotFoundError("Passkey not found or does not belong to you");
}
const body = await request.json();
if (!body.deviceName) {
throw new ValidationError("deviceName is required");
}
// Update the passkey
const updatedPasskey = await UserService.updatePasskey(passkeyId, {
deviceName: body.deviceName,
});
if (!updatedPasskey) {
throw new NotFoundError("Failed to update passkey");
}
log.info("Passkey updated successfully", {
passkeyId,
userId,
});
return json({
deviceName: updatedPasskey.deviceName,
});
} catch (error) {
logError(log)("Failed to update passkey", error, locals.user?.id, params.passkeyId);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
}
export async function DELETE({ params, locals }: RequestEvent) {
const log = logger.setContext("API.DeletePasskey");
const { passkeyId } = params;
@@ -0,0 +1,179 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { RequestEvent } from "@sveltejs/kit";
const mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
vi.mock("$lib/server/auth/webauthn-service", () => ({
WebAuthnService: {
getUserPasskeys: vi.fn(),
},
}));
vi.mock("$lib/server/services/user-service", () => ({
UserService: {
updatePasskey: vi.fn(),
},
}));
vi.mock("$lib/server/services/staff-crypto.service", () => ({
StaffCryptoService: vi.fn(),
}));
vi.mock("$lib/server/openapi", () => ({
registerOpenAPIRoute: vi.fn(),
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => mockLogger),
},
}));
import { PUT } from "../+server";
import { UserService } from "$lib/server/services/user-service";
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
import type { SelectUserPasskey } from "$lib/server/db/central-schema";
describe("Edit Passkey API", () => {
const mockUserId = "456e7890-e89b-12d3-a456-426614174001";
const mockPasskeyId = "passkey_abc123def456";
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockOwnedPasskey: SelectUserPasskey = {
id: mockPasskeyId,
userId: mockUserId,
deviceName: "Original Device",
createdAt: new Date(),
updatedAt: new Date(),
lastUsedAt: new Date(),
counter: 0,
publicKey: "public-key",
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(WebAuthnService.getUserPasskeys).mockResolvedValue([mockOwnedPasskey]);
vi.mocked(UserService.updatePasskey).mockResolvedValue({
...mockOwnedPasskey,
deviceName: "Renamed Device",
});
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { passkeyId: mockPasskeyId },
locals: {
user: {
id: mockUserId,
tenantId: mockTenantId,
passkeyId: mockPasskeyId,
},
},
request: {
json: vi.fn().mockResolvedValue({
deviceName: "Renamed Device",
}),
} as any,
...overrides,
} as RequestEvent;
}
describe("PUT /api/auth/passkeys/[passkeyId]", () => {
it("updates a passkey device name for an authenticated user who owns it", async () => {
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ deviceName: "Renamed Device" });
expect(WebAuthnService.getUserPasskeys).toHaveBeenCalledWith(mockUserId);
expect(UserService.updatePasskey).toHaveBeenCalledWith(mockPasskeyId, {
deviceName: "Renamed Device",
});
});
it("rejects unauthenticated requests with authentication required", async () => {
const event = createMockRequestEvent({
locals: { user: null } as any,
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("Authentication required");
expect(UserService.updatePasskey).not.toHaveBeenCalled();
});
it("rejects requests that do not include a passkeyId path parameter", async () => {
const event = createMockRequestEvent({
params: { passkeyId: undefined as unknown as string },
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Passkey ID is required");
});
it("rejects requests where deviceName is missing from the body", async () => {
const event = createMockRequestEvent({
request: {
json: vi.fn().mockResolvedValue({}),
} as any,
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("deviceName is required");
expect(UserService.updatePasskey).not.toHaveBeenCalled();
});
it("returns 404 when the passkey does not belong to the authenticated user", async () => {
vi.mocked(WebAuthnService.getUserPasskeys).mockResolvedValue([]);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Passkey not found or does not belong to you");
expect(UserService.updatePasskey).not.toHaveBeenCalled();
});
it("returns 404 when the update service cannot persist the rename", async () => {
vi.mocked(UserService.updatePasskey).mockResolvedValue(null as any);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Failed to update passkey");
});
it("returns 500 when an unexpected error occurs during the update flow", async () => {
vi.mocked(WebAuthnService.getUserPasskeys).mockRejectedValue(new Error("db unavailable"));
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
});