| 1 | // End-to-end passkey sign-in: a real `wrangler dev` Worker and a real Chromium |
| 2 | // with a virtual WebAuthn authenticator (Chrome DevTools Protocol). |
| 3 | // |
| 4 | // pnpm --filter @diffinery/app e2e |
| 5 | // |
| 6 | // Covers: signed-out redirects, joining from an invite link with a new passkey |
| 7 | // (which also applies the invite's org membership), adding and removing |
| 8 | // passkeys, signing out and back in, admin-issued one-time sign-in links, |
| 9 | // single-use invites and links, and refusing cross-site and token-driven calls. |
| 10 | import { spawn, execFileSync } from "node:child_process"; |
| 11 | import { createHash } from "node:crypto"; |
| 12 | import { mkdtempSync, rmSync } from "node:fs"; |
| 13 | import { tmpdir } from "node:os"; |
| 14 | import { join, dirname, resolve } from "node:path"; |
| 15 | import { fileURLToPath } from "node:url"; |
| 16 | import { createServer } from "node:net"; |
| 17 | import { test, before, after } from "node:test"; |
| 18 | import assert from "node:assert/strict"; |
| 19 | import { chromium } from "playwright"; |
| 20 | |
| 21 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 22 | const work = mkdtempSync(join(tmpdir(), "diffinery-passkeys-")); |
| 23 | let base; |
| 24 | let dev; |
| 25 | let devLog = ""; |
| 26 | let browser; |
| 27 | |
| 28 | const INVITE = "invite-token-for-pal-0123456789abcdef"; |
| 29 | const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef"; |
| 30 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 31 | |
| 32 | const freePort = () => |
| 33 | new Promise((res) => { |
| 34 | const s = createServer(); |
| 35 | s.listen(0, "127.0.0.1", () => { |
| 36 | const { port } = s.address(); |
| 37 | s.close(() => res(port)); |
| 38 | }); |
| 39 | }); |
| 40 | |
| 41 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 42 | async function until(what, fn, timeoutMs = 60_000) { |
| 43 | const end = Date.now() + timeoutMs; |
| 44 | while (Date.now() < end) { |
| 45 | const v = await fn(); |
| 46 | if (v) return v; |
| 47 | await sleep(500); |
| 48 | } |
| 49 | assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); |
| 50 | } |
| 51 | |
| 52 | /** A fresh browser profile with its own virtual platform authenticator (Touch ID-like, user-verifying). */ |
| 53 | async function person() { |
| 54 | const context = await browser.newContext(); |
| 55 | const page = await context.newPage(); |
| 56 | page.on("dialog", (d) => d.accept()); |
| 57 | const cdp = await context.newCDPSession(page); |
| 58 | await cdp.send("WebAuthn.enable"); |
| 59 | const addAuthenticator = async () => |
| 60 | ( |
| 61 | await cdp.send("WebAuthn.addVirtualAuthenticator", { |
| 62 | options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true }, |
| 63 | }) |
| 64 | ).authenticatorId; |
| 65 | let authenticatorId = await addAuthenticator(); |
| 66 | return { |
| 67 | context, |
| 68 | page, |
| 69 | /** Swap in a new, empty authenticator (another device). */ |
| 70 | async newDevice() { |
| 71 | await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }); |
| 72 | authenticatorId = await addAuthenticator(); |
| 73 | }, |
| 74 | async credentials() { |
| 75 | return (await cdp.send("WebAuthn.getCredentials", { authenticatorId })).credentials; |
| 76 | }, |
| 77 | }; |
| 78 | } |
| 79 | |
| 80 | const text = async (page) => (await page.textContent("main")) ?? ""; |
| 81 | |
| 82 | before(async () => { |
| 83 | const port = await freePort(); |
| 84 | // WebAuthn needs a secure context and a domain RP ID: localhost qualifies, 127.0.0.1 does not. |
| 85 | base = `http://localhost:${port}`; |
| 86 | const persist = join(work, "state"); |
| 87 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 88 | wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); |
| 89 | const at = new Date().toISOString(); |
| 90 | const later = new Date(Date.now() + 86400_000).toISOString(); |
| 91 | wrangler( |
| 92 | "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", |
| 93 | [ |
| 94 | `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('boss1', 'boss', 'user', 'The Boss', 'boss@example.com', 'active', 1, '${at}')`, |
| 95 | `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('crew1', 'crew', 'org', 'Crew', 'active', '${at}')`, |
| 96 | `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('crew1', 'boss1', 'owner', '${at}')`, |
| 97 | `INSERT INTO invites (id, org_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ('inv1', 'crew1', 'pal@example.com', 'member', '${hash(INVITE)}', 'boss1', '${at}', '${later}')`, |
| 98 | `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`, |
| 99 | ].join("; "), |
| 100 | ); |
| 101 | dev = spawn( |
| 102 | "pnpm", |
| 103 | ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"], |
| 104 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 105 | ); |
| 106 | dev.stdout.on("data", (d) => (devLog += d)); |
| 107 | dev.stderr.on("data", (d) => (devLog += d)); |
| 108 | await until( |
| 109 | "wrangler dev", |
| 110 | async () => { |
| 111 | try { |
| 112 | return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; |
| 113 | } catch { |
| 114 | return false; |
| 115 | } |
| 116 | }, |
| 117 | 120_000, |
| 118 | ); |
| 119 | browser = await chromium.launch(); |
| 120 | }); |
| 121 | |
| 122 | after(async () => { |
| 123 | await browser?.close(); |
| 124 | if (dev?.pid) { |
| 125 | try { |
| 126 | process.kill(-dev.pid, "SIGTERM"); |
| 127 | } catch {} |
| 128 | } |
| 129 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 130 | }); |
| 131 | |
| 132 | test("passkey sign-in, invites and sign-in links", async () => { |
| 133 | // ------------------------------------------------------------ signed out |
| 134 | const pal = await person(); |
| 135 | const { page } = pal; |
| 136 | await page.goto(`${base}/settings`); |
| 137 | assert.equal(new URL(page.url()).pathname, "/login"); |
| 138 | assert.equal(new URL(page.url()).searchParams.get("next"), "/settings"); |
| 139 | assert.match(await text(page), /Sign in with a passkey/); |
| 140 | |
| 141 | // A passkey nobody registered is refused. |
| 142 | await page.click("text=Sign in with a passkey"); |
| 143 | await page.waitForSelector("[data-passkey-msg]:not([hidden])"); |
| 144 | assert.match(await page.textContent("[data-passkey-msg]"), /passkey|cancelled|closed/i); |
| 145 | |
| 146 | // ------------------------------------------------------------ join from an invite link |
| 147 | await page.goto(`${base}/invite/${INVITE}`); |
| 148 | assert.match(await text(page), /invited as pal@example\.com/); |
| 149 | await page.fill("#login", "pal"); |
| 150 | await page.fill("#name", "Pal Friend"); |
| 151 | await Promise.all([page.waitForURL(`${base}/`), page.click("text=Create a passkey and join")]); |
| 152 | assert.match(await text(page), /Welcome to Diffinery, pal/); |
| 153 | assert.equal((await pal.credentials()).length, 1); |
| 154 | const cookies = await pal.context.cookies(base); |
| 155 | const session = cookies.find((c) => c.name === "__Host-dfy_session"); |
| 156 | assert.ok(session, `session cookie missing: ${JSON.stringify(cookies.map((c) => c.name))}`); |
| 157 | assert.equal(session.httpOnly, true); |
| 158 | assert.equal(session.sameSite, "Lax"); |
| 159 | // The invite's org membership was applied. |
| 160 | await page.goto(`${base}/settings/orgs`); |
| 161 | assert.match(await text(page), /crew/); |
| 162 | |
| 163 | // Invites work once. |
| 164 | const stranger = await person(); |
| 165 | const reused = await stranger.page.goto(`${base}/invite/${INVITE}`); |
| 166 | assert.equal(reused.status(), 404); |
| 167 | |
| 168 | // ------------------------------------------------------------ manage passkeys |
| 169 | await page.goto(`${base}/settings/security`); |
| 170 | let body = await text(page); |
| 171 | assert.match(body, /Passkeys and sessions/); |
| 172 | assert.match(body, /this browser/); |
| 173 | assert.match(body, /Only passkey/); |
| 174 | await pal.newDevice(); |
| 175 | await page.fill("#pklabel", "Laptop"); |
| 176 | await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Add a passkey')")]); |
| 177 | await page.waitForSelector("text=Passkey \"Laptop\" added."); |
| 178 | body = await text(page); |
| 179 | assert.match(body, /Laptop/); |
| 180 | assert.equal(await page.locator("button:has-text('Remove')").count(), 2); |
| 181 | // Remove the first device's passkey (the one this browser no longer holds). This browser stays signed in. |
| 182 | await Promise.all([page.waitForURL(`${base}/settings/security`), page.locator("li:not(:has-text('Laptop')) button:has-text('Remove')").first().click()]); |
| 183 | await page.waitForSelector("text=Passkey removed."); |
| 184 | body = await text(page); |
| 185 | assert.match(body, /Only passkey/); |
| 186 | assert.match(body, /Laptop/); |
| 187 | |
| 188 | // ------------------------------------------------------------ sign out, then in with the remaining passkey |
| 189 | await Promise.all([page.waitForURL(/\/login$/), page.click("header button:has-text('Sign out')")]); |
| 190 | await page.goto(`${base}/settings`); |
| 191 | assert.equal(new URL(page.url()).pathname, "/login"); |
| 192 | await page.goto(`${base}/login?next=/settings/security`); |
| 193 | await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("text=Sign in with a passkey")]); |
| 194 | assert.match(await text(page), /Laptop/); |
| 195 | |
| 196 | // ------------------------------------------------------------ admin sign-in links |
| 197 | const boss = await person(); |
| 198 | await boss.page.goto(`${base}/signin/${ADMIN_LINK}`); |
| 199 | assert.match(await text(boss.page), /add a passkey to boss/); |
| 200 | await Promise.all([boss.page.waitForURL(`${base}/settings/security`), boss.page.click("button:has-text('Create a passkey')")]); |
| 201 | assert.match(await text(boss.page), /Use it to sign in from now on/); |
| 202 | const usedLink = await stranger.page.goto(`${base}/signin/${ADMIN_LINK}`); |
| 203 | assert.equal(usedLink.status(), 404); |
| 204 | |
| 205 | // The admin issues a link for pal (say pal lost the laptop), and pal uses it on a new device. |
| 206 | await boss.page.goto(`${base}/settings/admin`); |
| 207 | await Promise.all([boss.page.waitForLoadState("load"), boss.page.locator("tr:has-text('pal@example.com') button:has-text('Sign-in link')").click()]); |
| 208 | await boss.page.waitForSelector("text=Sign-in link for pal"); |
| 209 | const link = (await boss.page.textContent(".secret")).trim(); |
| 210 | assert.match(link, new RegExp(`^${base}/signin/`)); |
| 211 | await pal.newDevice(); |
| 212 | await page.goto(link); |
| 213 | await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("button:has-text('Create a passkey')")]); |
| 214 | assert.equal(await page.locator("button:has-text('Remove')").count(), 2); |
| 215 | |
| 216 | // ------------------------------------------------------------ refused requests |
| 217 | const cross = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "{}" }); |
| 218 | assert.equal(cross.status, 403); |
| 219 | const form = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "a=b" }); |
| 220 | assert.equal(form.status, 415); |
| 221 | const noChallenge = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response: { id: "x" } }) }); |
| 222 | assert.equal(noChallenge.status, 400); |
| 223 | const anonymous = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); |
| 224 | assert.equal(anonymous.status, 401); |
| 225 | |
| 226 | // ------------------------------------------------------------ Kumo form controls |
| 227 | // Every select, checkbox and radio is a Kumo component; the forms still post plain fields. |
| 228 | const nativeControls = (p) => p.locator("select:not([aria-hidden]), input[type=checkbox]:not([aria-hidden]), input[type=radio]:not([aria-hidden])").count(); |
| 229 | await page.goto(`${base}/new`); |
| 230 | await page.waitForSelector(".dfy-k[data-mounted]"); |
| 231 | assert.equal(await nativeControls(page), 0); |
| 232 | await page.fill("#name", "kumo"); |
| 233 | await page.getByRole("radio", { name: /Internal/ }).click(); |
| 234 | await Promise.all([page.waitForURL(`${base}/pal/kumo`), page.click("button:has-text('Create repository')")]); |
| 235 | await page.goto(`${base}/pal/kumo/settings`); |
| 236 | await page.waitForSelector(".dfy-k[data-mounted]"); |
| 237 | assert.equal(await page.locator("input[type=hidden][name=visibility]").getAttribute("value"), "internal"); |
| 238 | assert.equal(await page.getByRole("radio", { name: "internal" }).getAttribute("aria-checked"), "true"); |
| 239 | await page.goto(`${base}/settings/tokens`); |
| 240 | await page.fill("#tname", "kumo"); |
| 241 | await page.click("#repos"); |
| 242 | await page.getByRole("option", { name: "pal/kumo" }).click(); |
| 243 | await page.keyboard.press("Escape"); |
| 244 | await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Create token')")]); |
| 245 | await page.waitForSelector("text=pal/kumo"); |
| 246 | assert.equal(await nativeControls(page), 0); |
| 247 | await boss.page.goto(`${base}/settings/admin`); |
| 248 | await boss.page.waitForSelector(".dfy-k[data-mounted]"); |
| 249 | assert.equal(await nativeControls(boss.page), 0); |
| 250 | const kumo = await fetch(`${base}${await page.locator("script[src^='/-/kumo/']").getAttribute("src")}`); |
| 251 | assert.equal(kumo.status, 200); |
| 252 | assert.match(kumo.headers.get("cache-control") ?? "", /immutable/); |
| 253 | |
| 254 | // Screenshots for review (kept with E2E_KEEP=1). |
| 255 | if (process.env.E2E_SCREENSHOTS) { |
| 256 | const out = process.env.E2E_SCREENSHOTS; |
| 257 | await stranger.page.goto(`${base}/login`); |
| 258 | await stranger.page.screenshot({ path: join(out, "login.png"), fullPage: true }); |
| 259 | await page.goto(`${base}/settings/security`); |
| 260 | await page.screenshot({ path: join(out, "security.png"), fullPage: true }); |
| 261 | await boss.page.goto(`${base}/settings/admin`); |
| 262 | await boss.page.screenshot({ path: join(out, "admin.png"), fullPage: true }); |
| 263 | } |
| 264 | }); |