Fix passkey credential ID encoding and improve error handling

Bug: @simplewebauthn v13 changed credential.id from Uint8Array to
base64url string. Buffer.from(string) without encoding stored the
ASCII text instead of decoded bytes. Authentication then failed to
match because it correctly decoded with "base64url".

Fix: Buffer.from(credential.id, "base64url") in both registration
and add-passkey flows.

Also: catch WebAuthn "not allowed" errors and show a friendly message
instead of the raw browser error.

Existing passkeys must be re-registered after deploy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-27 21:18:14 +01:00
parent 3180017f16
commit dd6c59f2d0
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
4 changed files with 128 additions and 3 deletions

View file

@ -81,7 +81,7 @@ export async function finishRegistration(
await db.insert(credentials).values({
id: randomUUID(),
userId,
credentialId: Buffer.from(credential.id),
credentialId: Buffer.from(credential.id, "base64url"),
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: response.response.transports,
@ -137,7 +137,7 @@ export async function addPasskeyFinish(
await db.insert(credentials).values({
id: randomUUID(),
userId,
credentialId: Buffer.from(credential.id),
credentialId: Buffer.from(credential.id, "base64url"),
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: response.response.transports,

View file

@ -47,7 +47,12 @@ export default function LoginPage() {
window.location.href = "/";
}
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
if (message.includes("timed out") || message.includes("not allowed")) {
setError("No passkey found for this site. Register a new account or use a magic link instead.");
} else {
setError(message);
}
} finally {
setLoading(false);
}

112
e2e/auth.test.ts Normal file
View file

@ -0,0 +1,112 @@
import { test, expect, type CDPSession, type Page } from "@playwright/test";
// Virtual authenticator helpers
async function setupVirtualAuthenticator(cdp: CDPSession) {
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
return authenticatorId;
}
async function removeVirtualAuthenticator(cdp: CDPSession, authenticatorId: string) {
await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
await cdp.send("WebAuthn.disable");
}
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await page.getByLabel("Email").click();
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").click();
await page.getByLabel("Username").fill(username);
// Verify both fields retained values before submitting
await expect(page.getByLabel("Email")).toHaveValue(email);
await expect(page.getByLabel("Username")).toHaveValue(username);
await page.getByRole("button", { name: /Register with Passkey/ }).click();
}
async function logout(page: Page) {
await page.getByRole("navigation").getByRole("button", { name: "Log Out" }).click();
await expect(page.getByRole("navigation").getByRole("link", { name: "Sign In" })).toBeVisible({ timeout: 5000 });
}
test.describe("Passkey Authentication", () => {
test("register with passkey and sign in", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
const email = `test-${Date.now()}@example.com`;
const username = `testuser${Date.now()}`;
// Register
await registerUser(page, email, username);
await expect(page).toHaveURL("/", { timeout: 10000 });
await expect(page.getByRole("navigation").getByText(username)).toBeVisible({ timeout: 5000 });
// Log out
await logout(page);
// Sign in with passkey
await page.goto("/auth/login");
await page.getByRole("button", { name: /Sign in with Passkey/ }).click();
await expect(page).toHaveURL("/", { timeout: 10000 });
await expect(page.getByRole("navigation").getByText(username)).toBeVisible({ timeout: 5000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("passkey login fails with no registered credential", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
await page.goto("/auth/login");
await page.getByRole("button", { name: /Sign in with Passkey/ }).click();
await expect(page.getByText(/No passkey found/i)).toBeVisible({ timeout: 10000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("register rejects duplicate email", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
const email = `dup-${Date.now()}@example.com`;
// Register first user
await registerUser(page, email, `first${Date.now()}`);
await expect(page).toHaveURL("/", { timeout: 10000 });
await logout(page);
// Try to register with same email
await registerUser(page, email, `second${Date.now()}`);
await expect(page.getByText(/already in use/i)).toBeVisible({ timeout: 10000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
test("register rejects duplicate username", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
const authenticatorId = await setupVirtualAuthenticator(cdp);
const username = `uniq${Date.now()}`;
// Register first user
await registerUser(page, `first-${Date.now()}@example.com`, username);
await expect(page).toHaveURL("/", { timeout: 10000 });
await logout(page);
// Try to register with same username
await registerUser(page, `second-${Date.now()}@example.com`, username);
await expect(page.getByText(/already taken/i)).toBeVisible({ timeout: 10000 });
await removeVirtualAuthenticator(cdp, authenticatorId);
});
});

View file

@ -31,6 +31,14 @@ export default defineConfig({
baseURL: "http://localhost:3001",
},
},
{
name: "auth",
testMatch: "auth.test.ts",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
},
},
{
name: "integration",
testMatch: "integration.test.ts",