+ 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/docs/authentication.md b/docs/authentication.md
index 25e342523..4ffedd9d6 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -3,10 +3,11 @@
The Epic Stack manages its own authentication using web standards and
established libraries and tools.
-By default, the Epic Stack offers you two mechanisms for authentication:
+By default, the Epic Stack offers you three mechanisms for authentication:
1. Username and password authentication
2. Provider authentication
+3. Passkey authentication
## Username and password authentication
@@ -92,6 +93,53 @@ Also make sure to register separate additional OAuth apps for each of your
deployed environments (e.g. `staging` and `production`) and specify
corresponding homepage and redirect urls in there.
+## Passkey Authentication
+
+The Epic Stack includes support for passkey authentication using the WebAuthn
+standard. Passkeys provide a more secure, phishing-resistant alternative to
+traditional passwords. They can be stored in your device's secure hardware (like
+Touch ID, Face ID, or Windows Hello) or in external security keys.
+
+Users can register multiple passkeys for their account through the passkeys
+settings page. Each passkey can be either device-bound (platform authenticator)
+or portable (cross-platform authenticator like a security key). The
+implementation uses the
+[@simplewebauthn/server](https://npm.im/simplewebauthn/server) and
+[@simplewebauthn/browser](https://npm.im/simplewebauthn/browser) packages to
+handle the WebAuthn protocol.
+
+When a user attempts to log in with a passkey:
+
+1. The server generates a challenge
+2. The browser prompts the user to authenticate using one of their registered
+ passkeys
+3. Upon successful authentication, the user is logged in without needing to
+ enter a password
+
+Passkeys offer several advantages:
+
+- No passwords to remember or type
+- Phishing-resistant (tied to specific domains)
+- Biometric authentication when available
+- Can be synced across devices (if the user is using a manager like
+ [1Password](https://1password.com/))
+- Support for both built-in authenticators (like Touch ID) and external security
+ keys
+
+The passkey data is stored in the database using a `Passkey` model which tracks:
+
+- A unique identifier (`id`) for each passkey
+- The authenticator's AAGUID (a unique identifier for the make and model of the
+ authenticator). This can be used to help the user identify which managers
+ their passkeys are from if they have multiple managers.
+- The public key used for verification
+- A counter to prevent replay attacks
+- The device type (platform or cross-platform)
+- Whether the credential is backed up
+- Optional transport methods (USB, NFC, etc.)
+- Creation and update timestamps
+- The relationship to the user who owns the passkey
+
## TOTP and Two-Factor Authentication
Two factor authentication is built-into the Epic Stack. It's managed using a the
diff --git a/docs/decisions/039-passkeys.md b/docs/decisions/039-passkeys.md
new file mode 100644
index 000000000..af37ca62e
--- /dev/null
+++ b/docs/decisions/039-passkeys.md
@@ -0,0 +1,159 @@
+# Passkeys
+
+Date: 2025-02-19
+
+Status: accepted
+
+## Context
+
+The Epic Stack has traditionally supported two primary authentication methods:
+username/password and OAuth providers. While these methods are widely used, they
+come with various security challenges:
+
+1. Password-based authentication:
+
+ - Users often reuse passwords across services
+ - Passwords can be phished or stolen
+ - Password management is a burden for users
+ - Password reset flows are complex and potential security vectors
+
+2. OAuth providers:
+ - Dependency on third-party services
+ - Privacy concerns with data sharing
+ - Not all users have or want to use social accounts
+ - Service outages can affect authentication
+
+The web platform now supports WebAuthn, a standard for passwordless
+authentication that enables the use of passkeys. Passkeys represent a
+significant advancement in authentication security and user experience.
+
+WebAuthn (Web Authentication) is a web standard published by the W3C that
+enables strong authentication using public key cryptography instead of
+passwords. The standard allows websites to register and authenticate users
+using:
+
+1. Platform authenticators built into devices (like Touch ID, Face ID,
+ 1Password, etc.)
+2. Roaming authenticators (security keys, phones acting as security keys)
+
+The authentication flow works as follows:
+
+1. Registration:
+
+ - Server generates a challenge and sends registration options
+ - Client creates a new key pair and signs the challenge with the private key
+ - Public key and metadata are sent to the server for storage
+ - Private key remains securely stored in the authenticator
+
+2. Authentication:
+
+ - Server generates a new challenge
+ - Client signs it with the stored private key
+ - Server verifies the signature using the stored public key
+
+This provides several security benefits:
+
+- Private keys never leave the authenticator
+- Each credential is unique to the website (preventing phishing)
+- Biometric/PIN verification happens locally
+- No shared secrets are stored on servers
+
+### Multiple Authentication Strategies
+
+While passkeys represent the future of authentication, we maintain support for
+password and OAuth authentication because:
+
+1. Adoption and Transition:
+
+ - Passkey support is still rolling out across platforms and browsers
+ - Users need time to become comfortable with the new technology
+ - Organizations may have existing requirements for specific auth methods
+
+2. Fallback Options:
+
+ - Some users may not have compatible devices
+ - Enterprise environments might restrict biometric authentication
+ - Backup authentication methods provide reliability
+
+3. User Choice:
+
+ - Different users have different security/convenience preferences
+ - Some scenarios may require specific authentication types
+ - Supporting multiple methods maximizes accessibility
+
+By supporting all three methods, we provide a smooth transition path to passkeys
+while ensuring no users are left behind.
+
+## Decision
+
+We will implement passkey support in the Epic Stack using the SimpleWebAuthn
+libraries (@simplewebauthn/server and @simplewebauthn/browser) which provide a
+robust implementation of the WebAuthn standard. The implementation will:
+
+1. Allow users to register multiple passkeys for their account
+2. Support both platform authenticators (built into devices) and cross-platform
+ authenticators (security keys)
+3. Store passkey data in a dedicated Prisma model that tracks:
+ - Authenticator metadata (AAGUID, device type, transports)
+ - Security information (public key, counter)
+ - User relationship and timestamps
+4. Provide a clean UI for managing passkeys in the user settings
+5. Support passkey-based login as a first-class authentication method
+
+We chose SimpleWebAuthn because:
+
+- It's well-maintained and widely used
+- It provides type-safe implementations for both client and server
+- It handles the complexity of the WebAuthn specification
+- It supports all major browsers and platforms
+
+## Consequences
+
+### Positive:
+
+1. Enhanced Security for Users:
+
+ - Phishing-resistant authentication adds protection against common attacks
+ - Hardware-backed security provides stronger guarantees than passwords alone
+ - Biometric authentication reduces risk of credential sharing
+
+2. Improved User Experience Options:
+
+ - Users can choose between password, OAuth, or passkey based on their needs
+ - Native biometric flows provide fast and familiar authentication
+ - Password manager integration enables seamless cross-device access
+ - Multiple authentication methods increase accessibility
+
+3. Future-Proofing Authentication:
+
+ - Adoption of web standard
+ - Gradual transition path as passkey support grows
+ - Meeting evolving security best practices
+
+### Negative:
+
+1. Implementation Complexity:
+
+ - WebAuthn is a complex specification
+ - Need to handle various device capabilities
+ - Must maintain backward compatibility
+ - Need to maintain password-based auth as fallback
+
+2. User Education:
+
+ - New technology requires user education
+ - Some users may be hesitant to adopt
+ - Need clear documentation and UI guidance
+
+### Neutral:
+
+1. Data Storage:
+
+ - New database model for passkeys
+ - Additional storage requirements per user
+ - Migration path for existing users
+
+2. Testing:
+ - New test infrastructure for WebAuthn
+ - Mock authenticator support for development
+ - Additional e2e test scenarios
diff --git a/other/svg-icons/passkey.svg b/other/svg-icons/passkey.svg
new file mode 100644
index 000000000..8a2f6add9
--- /dev/null
+++ b/other/svg-icons/passkey.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 2c3e0aaa0..acaeb7eaf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,6 +36,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",
@@ -2622,6 +2636,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",
@@ -4702,6 +4774,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",
@@ -6673,6 +6769,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 cfab0ed19..035ce4069 100644
--- a/package.json
+++ b/package.json
@@ -73,6 +73,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/server/utils/monitoring.ts b/server/utils/monitoring.ts
index 4a49dc696..d8bbbe7ce 100644
--- a/server/utils/monitoring.ts
+++ b/server/utils/monitoring.ts
@@ -28,6 +28,8 @@ export function init() {
Sentry.httpIntegration(),
nodeProfilingIntegration(),
],
+ // https://github.com/getsentry/sentry-javascript/issues/12996
+ registerEsmLoaderHooks: { onlyIncludeInstrumentedModules: true },
tracesSampler(samplingContext) {
// ignore healthcheck transactions by other services (consul, etc.)
if (samplingContext.request?.url?.includes('/resources/healthcheck')) {
diff --git a/tests/e2e/passkey.test.ts b/tests/e2e/passkey.test.ts
new file mode 100644
index 000000000..9b221bf1f
--- /dev/null
+++ b/tests/e2e/passkey.test.ts
@@ -0,0 +1,155 @@
+import { faker } from '@faker-js/faker'
+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 }
+}
+
+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 = new Promise((resolve) => {
+ client.once('WebAuthn.credentialAdded', () => resolve())
+ })
+ 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 = new Promise((resolve) => {
+ client.once('WebAuthn.credentialAsserted', () => resolve())
+ })
+
+ 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')
+ const deletedPasskeyAssertedPromise = new Promise((resolve) => {
+ client.once('WebAuthn.credentialAsserted', () => resolve())
+ })
+
+ await page.getByRole('button', { name: /login with a passkey/i }).click()
+
+ await deletedPasskeyAssertedPromise
+
+ // 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 })
+ const { client, authenticatorId } = await setupWebAuthn(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)
+})