From dd6c59f2d00fc62ae665e8a4c4c7a1fc6d09e4e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 27 Mar 2026 21:18:14 +0100 Subject: [PATCH] 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) --- apps/journal/app/lib/auth.server.ts | 4 +- apps/journal/app/routes/auth.login.tsx | 7 +- e2e/auth.test.ts | 112 +++++++++++++++++++++++++ playwright.config.ts | 8 ++ 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 e2e/auth.test.ts diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 8905332..a85a55e 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -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, diff --git a/apps/journal/app/routes/auth.login.tsx b/apps/journal/app/routes/auth.login.tsx index 9698f19..eaa0ed1 100644 --- a/apps/journal/app/routes/auth.login.tsx +++ b/apps/journal/app/routes/auth.login.tsx @@ -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); } diff --git a/e2e/auth.test.ts b/e2e/auth.test.ts new file mode 100644 index 0000000..ce19e79 --- /dev/null +++ b/e2e/auth.test.ts @@ -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); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index b8a79dc..ff32d8b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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",