Passkeys,
+}
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const userId = await requireUserId(request)
+ const passkeys = await prisma.passkey.findMany({
+ where: { userId },
+ orderBy: { createdAt: 'desc' },
+ select: {
+ id: true,
+ deviceType: true,
+ createdAt: true,
+ },
+ })
+ return { passkeys }
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ const userId = await requireUserId(request)
+ const formData = await request.formData()
+ const intent = formData.get('intent')
+
+ if (intent === 'delete') {
+ const passkeyId = formData.get('passkeyId')
+ if (typeof passkeyId !== 'string') {
+ return Response.json(
+ { status: 'error', error: 'Invalid passkey ID' },
+ { status: 400 },
+ )
+ }
+
+ await prisma.passkey.delete({
+ where: {
+ id: passkeyId,
+ userId, // Ensure the passkey belongs to the user
+ },
+ })
+ return Response.json({ status: 'success' })
+ }
+
+ return Response.json(
+ { status: 'error', error: 'Invalid intent' },
+ { status: 400 },
+ )
+}
+
+const RegistrationResultSchema = z.object({
+ options: z.object({
+ rp: z.object({
+ id: z.string(),
+ name: z.string(),
+ }),
+ user: z.object({
+ id: z.string(),
+ name: z.string(),
+ displayName: z.string(),
+ }),
+ challenge: z.string(),
+ pubKeyCredParams: z.array(
+ z.object({
+ type: z.literal('public-key'),
+ alg: z.number(),
+ }),
+ ),
+ }),
+}) satisfies z.ZodType<{ options: PublicKeyCredentialCreationOptionsJSON }>
+
+export default function Passkeys({ loaderData }: Route.ComponentProps) {
+ const revalidator = useRevalidator()
+ const [error, setError] = useState
(null)
+
+ async function handlePasskeyRegistration() {
+ try {
+ setError(null)
+ const resp = await fetch('/webauthn/registration')
+ const jsonResult = await resp.json()
+ const parsedResult = RegistrationResultSchema.parse(jsonResult)
+
+ const regResult = await startRegistration({
+ optionsJSON: parsedResult.options,
+ })
+
+ const verificationResp = await fetch('/webauthn/registration', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(regResult),
+ })
+
+ if (!verificationResp.ok) {
+ throw new Error('Failed to verify registration')
+ }
+
+ void revalidator.revalidate()
+ } catch (err) {
+ console.error('Failed to create passkey:', err)
+ setError('Failed to create passkey. Please try again.')
+ }
+ }
+
+ return (
+
+
+
Passkeys
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {loaderData.passkeys.length ? (
+
+ {loaderData.passkeys.map((passkey) => (
+ -
+
+
+
+
+ {passkey.deviceType === 'platform'
+ ? 'Device'
+ : 'Security Key'}
+
+
+
+ Registered {formatDistanceToNow(new Date(passkey.createdAt))}{' '}
+ ago
+
+
+
+
+ ))}
+
+ ) : (
+
+ No passkeys registered yet
+
+ )}
+
+ )
+}
diff --git a/app/routes/settings+/profile.tsx b/app/routes/settings+/profile.tsx
index 8b29472eb..53c296b85 100644
--- a/app/routes/settings+/profile.tsx
+++ b/app/routes/settings+/profile.tsx
@@ -66,7 +66,9 @@ export default function EditUserProfile() {
'text-muted-foreground': i < arr.length - 1,
})}
>
- ▶️ {breadcrumb}
+
+ {breadcrumb}
+
))}
diff --git a/other/svg-icons/passkey.svg b/other/svg-icons/passkey.svg
new file mode 100644
index 000000000..12ab232b9
--- /dev/null
+++ b/other/svg-icons/passkey.svg
@@ -0,0 +1,15 @@
+
diff --git a/package-lock.json b/package-lock.json
index 8236d3db7..5a1b47ba8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,8 @@
"@sentry/node": "^8.54.0",
"@sentry/profiling-node": "^8.54.0",
"@sentry/react": "^8.54.0",
+ "@simplewebauthn/browser": "^13.1.0",
+ "@simplewebauthn/server": "^13.1.1",
"@tusbar/cache-control": "1.0.2",
"address": "^2.0.3",
"bcryptjs": "^2.4.3",
@@ -1460,6 +1462,12 @@
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
"license": "MIT"
},
+ "node_modules/@hexagon/base64": {
+ "version": "1.1.28",
+ "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
+ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
+ "license": "MIT"
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1748,6 +1756,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@levischuck/tiny-cbor": {
+ "version": "0.2.8",
+ "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.8.tgz",
+ "integrity": "sha512-Es+ajyTgqHREY9Fch5xPnZIDiTqgZc3dH3XU1/YWn8UsaOD8G8zhyhDib/UYgx31manKa7ZszKaLtcHKcGNchA==",
+ "license": "MIT"
+ },
"node_modules/@mjackson/form-data-parser": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@mjackson/form-data-parser/-/form-data-parser-0.7.0.tgz",
@@ -2613,6 +2627,64 @@
"@noble/hashes": "^1.1.5"
}
},
+ "node_modules/@peculiar/asn1-android": {
+ "version": "2.3.15",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.15.tgz",
+ "integrity": "sha512-8U2TIj59cRlSXTX2d0mzUKP7whfWGFMzTeC3qPgAbccXFrPNZLaDhpNEdG5U2QZ/tBv/IHlCJ8s+KYXpJeop6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.3.15",
+ "asn1js": "^3.0.5",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-ecc": {
+ "version": "2.3.15",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.15.tgz",
+ "integrity": "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.3.15",
+ "@peculiar/asn1-x509": "^2.3.15",
+ "asn1js": "^3.0.5",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-rsa": {
+ "version": "2.3.15",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.15.tgz",
+ "integrity": "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.3.15",
+ "@peculiar/asn1-x509": "^2.3.15",
+ "asn1js": "^3.0.5",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.3.15",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.15.tgz",
+ "integrity": "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==",
+ "license": "MIT",
+ "dependencies": {
+ "asn1js": "^3.0.5",
+ "pvtsutils": "^1.3.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509": {
+ "version": "2.3.15",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.15.tgz",
+ "integrity": "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.3.15",
+ "asn1js": "^3.0.5",
+ "pvtsutils": "^1.3.6",
+ "tslib": "^2.8.1"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -4693,6 +4765,30 @@
"node": ">= 14"
}
},
+ "node_modules/@simplewebauthn/browser": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz",
+ "integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==",
+ "license": "MIT"
+ },
+ "node_modules/@simplewebauthn/server": {
+ "version": "13.1.1",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.1.1.tgz",
+ "integrity": "sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@hexagon/base64": "^1.1.27",
+ "@levischuck/tiny-cbor": "^0.2.2",
+ "@peculiar/asn1-android": "^2.3.10",
+ "@peculiar/asn1-ecc": "^2.3.8",
+ "@peculiar/asn1-rsa": "^2.3.8",
+ "@peculiar/asn1-schema": "^2.3.8",
+ "@peculiar/asn1-x509": "^2.3.8"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -6664,6 +6760,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/asn1js": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
+ "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.2",
+ "pvutils": "^1.1.3",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -13045,6 +13155,24 @@
"node": ">=6"
}
},
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
+ "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
diff --git a/package.json b/package.json
index 6aef98721..58bb37d49 100644
--- a/package.json
+++ b/package.json
@@ -72,6 +72,8 @@
"@sentry/node": "^8.54.0",
"@sentry/profiling-node": "^8.54.0",
"@sentry/react": "^8.54.0",
+ "@simplewebauthn/browser": "^13.1.0",
+ "@simplewebauthn/server": "^13.1.1",
"@tusbar/cache-control": "1.0.2",
"address": "^2.0.3",
"bcryptjs": "^2.4.3",
diff --git a/prisma/migrations/20230914194400_init/migration.sql b/prisma/migrations/20250207004552_init/migration.sql
similarity index 94%
rename from prisma/migrations/20230914194400_init/migration.sql
rename to prisma/migrations/20250207004552_init/migration.sql
index 5ee0147e9..faae5204d 100644
--- a/prisma/migrations/20230914194400_init/migration.sql
+++ b/prisma/migrations/20250207004552_init/migration.sql
@@ -105,6 +105,22 @@ CREATE TABLE "Connection" (
CONSTRAINT "Connection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
+-- CreateTable
+CREATE TABLE "Passkey" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "aaguid" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ "publicKey" BLOB NOT NULL,
+ "userId" TEXT NOT NULL,
+ "webauthnUserId" TEXT NOT NULL,
+ "counter" BIGINT NOT NULL,
+ "deviceType" TEXT NOT NULL,
+ "backedUp" BOOLEAN NOT NULL,
+ "transports" TEXT,
+ CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
-- CreateTable
CREATE TABLE "_PermissionToRole" (
"A" TEXT NOT NULL,
@@ -157,6 +173,9 @@ CREATE UNIQUE INDEX "Verification_target_type_key" ON "Verification"("target", "
-- CreateIndex
CREATE UNIQUE INDEX "Connection_providerName_providerId_key" ON "Connection"("providerName", "providerId");
+-- CreateIndex
+CREATE INDEX "Passkey_userId_idx" ON "Passkey"("userId");
+
-- CreateIndex
CREATE UNIQUE INDEX "_PermissionToRole_AB_unique" ON "_PermissionToRole"("A", "B");
@@ -169,6 +188,7 @@ CREATE UNIQUE INDEX "_RoleToUser_AB_unique" ON "_RoleToUser"("A", "B");
-- CreateIndex
CREATE INDEX "_RoleToUser_B_index" ON "_RoleToUser"("B");
+
--------------------------------- Manual Seeding --------------------------
-- Hey there, Kent here! This is how you can reliably seed your database with
-- some data. You edit the migration.sql file and that will handle it for you.
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
index e5e5c4705..e1640d1f2 100644
--- a/prisma/migrations/migration_lock.toml
+++ b/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
-# It should be added in your version-control system (i.e. Git)
+# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 975d2431a..68f332d11 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -25,6 +25,7 @@ model User {
roles Role[]
sessions Session[]
connections Connection[]
+ passkey Passkey[]
}
model Note {
@@ -167,3 +168,20 @@ model Connection {
@@unique([providerName, providerId])
}
+
+model Passkey {
+ id String @id
+ aaguid String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ publicKey Bytes
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId String
+ webauthnUserId String
+ counter BigInt
+ deviceType String // 'singleDevice' or 'multiDevice'
+ backedUp Boolean
+ transports String? // Stored as comma-separated values
+
+ @@index(userId)
+}
diff --git a/tests/e2e/passkey.test.ts b/tests/e2e/passkey.test.ts
new file mode 100644
index 000000000..254734c52
--- /dev/null
+++ b/tests/e2e/passkey.test.ts
@@ -0,0 +1,188 @@
+import { faker } from '@faker-js/faker'
+import { type CDPSession } from '@playwright/test'
+import { expect, test } from '#tests/playwright-utils.ts'
+
+async function setupWebAuthn(page: any) {
+ const client = await page.context().newCDPSession(page)
+ // https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/
+ await client.send('WebAuthn.enable', { options: { enableUI: true } })
+ const result = await client.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ transport: 'usb',
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ automaticPresenceSimulation: true,
+ },
+ })
+ return { client, authenticatorId: result.authenticatorId }
+}
+
+async function waitOnce(
+ client: CDPSession,
+ event: Parameters[0],
+) {
+ let resolve: () => void
+ client.once(event, () => resolve())
+ return new Promise((r) => {
+ resolve = r
+ })
+}
+
+async function simulateSuccessfulPasskeyInput(
+ client: CDPSession,
+ operationTrigger: () => Promise,
+) {
+ // initialize event listeners to wait for a successful passkey input event
+ let resolve: () => void
+ const credentialAddedHandler = () => resolve()
+ const credentialAssertedHandler = () => resolve()
+ const operationCompleted = new Promise((r) => {
+ resolve = r
+ client.on('WebAuthn.credentialAdded', credentialAddedHandler)
+ client.on('WebAuthn.credentialAsserted', credentialAssertedHandler)
+ })
+
+ // perform a user action that triggers passkey prompt
+ await operationTrigger()
+
+ // wait to receive the event that the passkey was successfully registered or verified
+ await operationCompleted
+
+ // clean up event listeners
+ client.off('WebAuthn.credentialAdded', credentialAddedHandler)
+ client.off('WebAuthn.credentialAsserted', credentialAssertedHandler)
+}
+
+test('Users can register and use passkeys', async ({ page, login }) => {
+ const user = await login()
+
+ const { client, authenticatorId } = await setupWebAuthn(page)
+
+ const initialCredentials = await client.send('WebAuthn.getCredentials', {
+ authenticatorId,
+ })
+ expect(
+ initialCredentials.credentials,
+ 'No credentials should exist initially',
+ ).toHaveLength(0)
+
+ await page.goto('/settings/profile/passkeys')
+
+ const passkeyRegisteredPromise = waitOnce(client, 'WebAuthn.credentialAdded')
+ await page.getByRole('button', { name: /register new passkey/i }).click()
+ await passkeyRegisteredPromise
+
+ // Verify the passkey appears in the UI
+ await expect(page.getByRole('list', { name: /passkeys/i })).toBeVisible()
+ await expect(page.getByText(/registered .* ago/i)).toBeVisible()
+
+ const afterRegistrationCredentials = await client.send(
+ 'WebAuthn.getCredentials',
+ { authenticatorId },
+ )
+ expect(
+ afterRegistrationCredentials.credentials,
+ 'One credential should exist after registration',
+ ).toHaveLength(1)
+
+ // Logout
+ await page.getByRole('link', { name: user.name ?? user.username }).click()
+ await page.getByRole('menuitem', { name: /logout/i }).click()
+ await expect(page).toHaveURL(`/`)
+
+ // Try logging in with passkey
+ await page.goto('/login')
+ const signCount1 = afterRegistrationCredentials.credentials[0].signCount
+
+ const passkeyAssertedPromise = waitOnce(client, 'WebAuthn.credentialAsserted')
+
+ await page.getByRole('button', { name: /login with a passkey/i }).click()
+
+ // Check for error message before waiting for completion
+ const errorLocator = page.getByText(/failed to authenticate/i)
+ const errorPromise = errorLocator.waitFor({ timeout: 1000 }).then(() => {
+ throw new Error('Passkey authentication failed')
+ })
+
+ await Promise.race([passkeyAssertedPromise, errorPromise])
+
+ // Verify successful login
+ await expect(
+ page.getByRole('link', { name: user.name ?? user.username }),
+ ).toBeVisible()
+
+ // Verify the sign count increased
+ const afterLoginCredentials = await client.send('WebAuthn.getCredentials', {
+ authenticatorId,
+ })
+ expect(afterLoginCredentials.credentials).toHaveLength(1)
+ expect(afterLoginCredentials.credentials[0].signCount).toBeGreaterThan(
+ signCount1,
+ )
+
+ // Go to passkeys page and delete the passkey
+ await page.goto('/settings/profile/passkeys')
+ await page.getByRole('button', { name: /delete/i }).click()
+
+ // Verify the passkey is no longer listed on the page
+ await expect(page.getByText(/no passkeys registered/i)).toBeVisible()
+
+ // But verify it still exists in the authenticator
+ const afterDeletionCredentials = await client.send(
+ 'WebAuthn.getCredentials',
+ { authenticatorId },
+ )
+ expect(afterDeletionCredentials.credentials).toHaveLength(1)
+
+ // Logout again to test deleted passkey
+ await page.getByRole('link', { name: user.name ?? user.username }).click()
+ await page.getByRole('menuitem', { name: /logout/i }).click()
+ await expect(page).toHaveURL(`/`)
+
+ // Try logging in with the deleted passkey
+ await page.goto('/login')
+ await simulateSuccessfulPasskeyInput(client, async () => {
+ await page.getByRole('button', { name: /login with a passkey/i }).click()
+ })
+
+ // Verify error message appears
+ await expect(page.getByText(/passkey not found/i)).toBeVisible()
+
+ // Verify we're still on the login page
+ await expect(page).toHaveURL(`/login`)
+})
+
+test('Failed passkey verification shows error', async ({ page, login }) => {
+ const password = faker.internet.password()
+ await login({ password })
+
+ // Set up WebAuthn
+ const { client, authenticatorId } = await setupWebAuthn(page)
+
+ // Navigate to passkeys page
+ await page.goto('/settings/profile/passkeys')
+
+ // Try to register with failed verification
+ await client.send('WebAuthn.setUserVerified', {
+ authenticatorId,
+ isUserVerified: false,
+ })
+
+ await client.send('WebAuthn.setAutomaticPresenceSimulation', {
+ authenticatorId,
+ enabled: true,
+ })
+
+ await page.getByRole('button', { name: /register new passkey/i }).click()
+
+ // Wait for error message
+ await expect(page.getByText(/failed to create passkey/i)).toBeVisible()
+
+ // Verify no passkey was registered
+ const credentials = await client.send('WebAuthn.getCredentials', {
+ authenticatorId,
+ })
+ expect(credentials.credentials).toHaveLength(0)
+})