// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // Gate J-auth (joint contract ยง14) in a browser: real Better Auth sessions on the platform's pinned // self-hosted Convex backend, a production build of this app (`next start`), headless Chromium. // // - sign-up and sign-in through the app's own pages; reload; a second device without a second login // - server rendering of a signed-in page, with and without JavaScript, and client hydration // - Support's widget-session mint and Guides' bootstrap resolving the one stable app user // - sign-out of one device ending only its derived sessions; a revocation made at Better Auth // itself reaching them through the relay; an administrator's disablement (admin plugin) refusing // the user; Better Auth's account deletion and the app's own both erasing through the account flow // - a Better Auth outage as a typed unavailable answer, never a signed-out or another person // - network and CSP inspection: one origin, no policy violation, no server secret and no bearer // in anything the browser received // // The fake platform answers the platform API (Support, Guides); everything about the sign-in is real. import { execFileSync } from "node:child_process" import { readdirSync, readFileSync, statSync } from "node:fs" import { join } from "node:path" import { type Browser, type BrowserContext, expect, type Page, test } from "@playwright/test" import { call, callRaw, makeOrganization, unique, violations } from "../support/app" import { APP_ROOT, type JauthStack } from "./stack" const stack = (): JauthStack => JSON.parse(process.env.JAUTH_STACK ?? "{}") as JauthStack const PASSWORD = "correct horse battery staple" /** Everything the browsers of this run received, for the inspection at the end. */ const seen = { requests: [] as string[], bodies: [] as Array<{ url: string; text: string }>, headers: [] as Array<{ url: string; headers: Record }>, setCookies: [] as string[], pageErrors: [] as string[], } async function device(browser: Browser, index: number): Promise<{ context: BrowserContext; page: Page }> { // A distinct client address per device, as the platform's ingress would send it (Better Auth keys // its rate limits by it). const context = await browser.newContext({ extraHTTPHeaders: { "x-forwarded-for": `10.20.0.${index}` } }) await context.addInitScript(() => { const target = globalThis as unknown as { __csp?: string[] } target.__csp = [] document.addEventListener("securitypolicyviolation", (event) => target.__csp?.push(`${event.violatedDirective} ${event.blockedURI}`), ) }) context.on("request", (request) => seen.requests.push(request.url())) context.on("response", async (response) => { const headers = await response.allHeaders().catch(() => ({}) as Record) seen.headers.push({ url: response.url(), headers }) for (const header of await response.headersArray().catch(() => [])) { if (header.name.toLowerCase() === "set-cookie") seen.setCookies.push(header.value) } const type = headers["content-type"] ?? "" if (/json|html|javascript|text|event-stream/.test(type) && !type.includes("event-stream")) { const text = await response.text().catch(() => "") seen.bodies.push({ url: response.url(), text }) } }) const page = await context.newPage() page.on("pageerror", (error) => seen.pageErrors.push(error.message)) return { context, page } } async function cspViolations(page: Page): Promise { return page.evaluate(() => (globalThis as unknown as { __csp?: string[] }).__csp ?? []) } /** The fake platform's record of the Support and Guides calls the app made. */ async function platform(): Promise<{ support: Array> guides: Array> }> { const response = await fetch(`${stack().platformUrl}/__e2e/state`) return (await response.json()) as { support: Array>; guides: Array> } } async function eventually( label: string, probe: () => Promise, ms = 15_000, ): Promise<{ value: T; after: number }> { const started = Date.now() for (;;) { const value = await probe() if (value !== undefined) return { value, after: Date.now() - started } if (Date.now() - started > ms) throw new Error(`j-auth: ${label} did not happen within ${ms} ms`) await new Promise((resolve) => setTimeout(resolve, 100)) } } const signedIn = (page: Page) => expect(page.locator('meta[name="csrf-token"]')).toHaveCount(1) /** Signs up through the app's sign-up page, and lands signed in. */ async function signUp(page: Page, name: string, email: string): Promise { await page.goto("/auth/sign-up") await page.getByLabel("Name").fill(name) await page.getByLabel("E-mail address").fill(email) await page.getByLabel("Password").fill(PASSWORD) const answer = page.waitForResponse((response) => response.url().endsWith("/api/auth/sign-up/email")) await page.getByRole("button", { name: "Create the account" }).click() expect((await answer).status()).toBe(200) await page.waitForURL((url) => url.pathname === "/" || url.pathname === "/onboarding") await signedIn(page) } /** * A sign-in made the way Better Auth's own client makes it, from inside the page: answers the Better * Auth user the answer names, and proves the answer carried no bearer. */ async function betterAuthUserOf(page: Page, email: string): Promise { const answer = await authPost(page, "/sign-in/email", { email, password: PASSWORD }) expect(answer.status).toBe(200) const body = JSON.parse(answer.body) as { user: { id: string }; token?: unknown } // Better Auth's session token is in the HTTP-only cookie only: the proxy removed it from the body. expect(body.token).toBeUndefined() expect(answer.body).not.toMatch(/"token"\s*:/) // That sign-in replaced the page's session: the page's CSRF token is the new session's after a reload. await page.reload() await signedIn(page) return body.user.id } async function signInPage(page: Page, email: string, password = PASSWORD): Promise { await page.goto("/auth/sign-in") await page.getByLabel("E-mail address").fill(email) await page.getByLabel("Password").fill(password) const answer = page.waitForResponse((response) => response.url().endsWith("/api/auth/sign-in/email")) await page.locator("form").getByRole("button", { name: "Sign in", exact: true }).click() return (await answer).status() } /** A procedure call as the page's own forms make it; answers the status, for a refusal. */ async function statusOf(page: Page, procedure: string, input: unknown): Promise { const csrf = (await page .locator('meta[name="csrf-token"]') .getAttribute("content") .catch(() => null)) ?? "" return (await callRaw(page.request, csrf, procedure, input)).status() } /** A POST to the app's /api/auth from inside the page, as Better Auth's own client would make it. */ const authPost = (page: Page, path: string, body: unknown = {}) => page.evaluate( async ({ path, body }) => { const response = await fetch(`/api/auth${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }) return { status: response.status, body: await response.text() } }, { path, body }, ) const sessionKey = (mint: Record) => String((mint.body as Record).identity_session) const subjectOf = (mint: Record) => String((mint.body as Record).subject) const mints = async () => (await platform()).support.filter((entry) => entry.route === "mint") // The Support and Guides rows below use these; an app pruned of both modules keeps the helpers unused. void [platform, eventually, sessionKey, subjectOf, mints] test.describe .serial("J-auth: Better Auth on the self-hosted Convex runtime, in a browser", () => { const ada = { email: `${unique("ada")}@example.test`, name: "Ada Lovelace" } let a: { context: BrowserContext; page: Page } let b: { context: BrowserContext; page: Page } let org = "" let adaSubject = "" test("sign-up, server rendering, hydration and reload", async ({ browser }) => { a = await device(browser, 1) const { page, context } = a // Signed out: a protected page sends the visitor to the sign-in page, which offers Better Auth's form. await page.goto("/settings/account") await expect(page).toHaveURL(/\/auth\/sign-in/) await expect(page.getByLabel("Password")).toBeVisible() await signUp(page, ada.name, ada.email) // A sign-in answer names the user and carries no bearer (Better Auth's own body has its token). await betterAuthUserOf(page, ada.email) // The one cookie Better Auth leaves in the browser: its HTTP-only session token. const cookies = await context.cookies() const betterAuth = cookies.filter((cookie) => cookie.name.includes("better-auth")) expect(betterAuth.map((cookie) => cookie.name)).toEqual(["better-auth.session_token"]) expect(betterAuth[0]?.httpOnly).toBe(true) // Server rendering: the signed-in page's HTML names the person before any script runs... const html = await (await page.request.get("/settings/account")).text() expect(html).toContain(ada.email) // ...and with JavaScript off entirely, the same session renders the same person. const noScript = await browser.newContext({ javaScriptEnabled: false, storageState: await context.storageState(), }) const plain = await noScript.newPage() await plain.goto("/settings/account") await expect(plain.getByText(ada.email, { exact: true })).toBeVisible() await noScript.close() // Hydration: the client controls work (a control that is server HTML only would not toggle). await page.goto("/settings/account") const root = page.locator("html") await page.getByRole("button", { name: "Theme: Dark", exact: true }).click() await expect(root).toHaveClass("light") await page.reload() await expect(root).toHaveClass("light") await expect(page.getByText(ada.email, { exact: true })).toBeVisible() await signedIn(page) expect(await violations(page)).toEqual([]) expect(await cspViolations(page)).toEqual([]) }) test("Support and Guides resolve one stable app user, on a second device without a second login", async ({ browser, }) => { const workspace = await makeOrganization(a.page, "jauth") org = workspace.org expect(org).toBeTruthy() adaSubject = (await call<{ userId: string }>(a.page, "tenancy.me.get", undefined)).userId expect(adaSubject).toBeTruthy() // The shell's own modules ask for the help surface and the guides of a project page. const config = a.page.waitForResponse((response) => response.url().endsWith("/api/v1/support.widget.config")) // kit:support // kit:guides:begin const bootstrap = a.page.waitForResponse((response) => response.url().endsWith("/api/v1/guides.runtime.bootstrap"), ) // kit:guides:end await a.page.goto(`/${workspace.org}/${workspace.workspace}/${workspace.project}`) expect(await (await config).json()).toEqual({ mode: "platform", channel: "spk_jauth" }) // kit:support expect((await bootstrap).status()).toBe(200) // kit:guides // kit:support:begin const first = (await call<{ token: string }>(a.page, "support.widgetSession.mint", { org })).token expect(first).toMatch(/^sws_/) // kit:support:end b = await device(browser, 2) expect(await signInPage(b.page, ada.email)).toBe(200) await b.page.waitForURL((url) => !url.pathname.startsWith("/auth/")) await signedIn(b.page) // kit:support:begin await call(b.page, "support.widgetSession.mint", { org }) const [mintA, mintB] = await mints() expect(mintA && mintB).toBeTruthy() expect(subjectOf(mintA as Record)).toBe(adaSubject) // The same app user on both devices, each with its own identity session (one device, one sign-in). expect(subjectOf(mintB as Record)).toBe(adaSubject) expect(sessionKey(mintA as Record)).toMatch(/^18:convex-better-auth:/) expect(sessionKey(mintB as Record)).not.toBe(sessionKey(mintA as Record)) expect(((mintA as Record).body as Record).app_tenant).toBeTruthy() // kit:support:end // kit:guides:begin await call(b.page, "guides.runtime.bootstrap", { org, path: "/" }) const boots = (await platform()).guides.filter((entry) => entry.route === "bootstrap") expect(boots.length).toBeGreaterThanOrEqual(2) for (const boot of boots) expect(JSON.stringify(boot)).toContain(adaSubject) // kit:guides:end }) test("signing out one device ends that device's derived sessions only", async ({ browser }) => { const before = await a.context.storageState() const [mintA] = await mints() // kit:support await a.page.goto("/") await a.page.getByRole("button", { name: "Sign out", exact: true }).click() await expect(a.page).toHaveURL(/\/auth\/signed-out$/) expect((await a.context.cookies()).filter((cookie) => cookie.name.includes("better-auth"))).toEqual([]) // kit:support:begin const revoked = await eventually("the sign-out's revocation", async () => (await platform()).support.find( (entry) => entry.route === "revoke" && (entry.body as Record).identity_session === sessionKey(mintA as Record), ), ) expect(revoked.value.revoked).toBe(1) // kit:support:end // The cookie the device held is dead at Better Auth, not just deleted from the browser. const replay = await browser.newContext({ storageState: before }) const response = await replay.request.post(`/api/v1/tenancy.me.get`, { headers: { "sec-fetch-site": "same-origin" }, }) expect(response.status()).toBe(401) await replay.close() // The other device is untouched. await b.page.reload() await signedIn(b.page) expect(await statusOf(b.page, "tenancy.me.get", undefined)).toBe(200) }) test("a revocation made at Better Auth reaches the derived sessions through the relay", async ({ browser }) => { const c = await device(browser, 3) expect(await signInPage(c.page, ada.email)).toBe(200) await signedIn(c.page) // kit:support:begin await call(c.page, "support.widgetSession.mint", { org }) const mintC = (await mints()).at(-1) as Record // kit:support:end // Device B signs every other device out, at Better Auth (its own endpoint, through the proxy). const revokedAt = Date.now() // kit:support const revokeOthers = await authPost(b.page, "/revoke-other-sessions") expect(revokeOthers.status).toBe(200) expect(await statusOf(c.page, "tenancy.me.get", undefined)).toBe(401) // kit:support:begin await eventually("the relay's revocation of device C", async () => (await platform()).support.find( (entry) => entry.route === "revoke" && (entry.body as Record).identity_session === sessionKey(mintC), ), ) const propagation = Date.now() - revokedAt console.info( `j-auth: a revocation at Better Auth reached Support within ${propagation} ms (relay interval 250 ms)`, ) expect(propagation).toBeLessThan(10_000) // kit:support:end expect(await statusOf(b.page, "tenancy.me.get", undefined)).toBe(200) await c.context.close() }) test("an administrator's disablement (admin plugin) refuses the user and ends their sessions", async ({ browser, }) => { const grace = { email: `${unique("grace")}@example.test`, name: "Grace Hopper" } const g = await device(browser, 4) await signUp(g.page, grace.name, grace.email) const graceId = await betterAuthUserOf(g.page, grace.email) const graceOrg = (await makeOrganization(g.page, "grace")).org // kit:support const graceSubject = (await call<{ userId: string }>(g.page, "tenancy.me.get", undefined)).userId expect(graceSubject).not.toBe(adaSubject) await call(g.page, "support.widgetSession.mint", { org: graceOrg }) // kit:support // The proxy never hands a browser the admin plugin's routes. expect((await authPost(g.page, "/admin/ban-user", { userId: graceId })).status).toBe(404) // An administrator (BETTER_AUTH_ADMIN_USER_IDS of the Better Auth deployment) bans her at Better Auth. const operator = await fetch(`${stack().baseUrl}/api/auth/sign-up/email`, { method: "POST", headers: { "content-type": "application/json", origin: stack().baseUrl, "x-forwarded-for": "10.20.0.99" }, body: JSON.stringify({ name: "Operator", email: `${unique("operator")}@example.test`, password: PASSWORD }), }) expect(operator.status).toBe(200) const operatorId = ((await operator.json()) as { user: { id: string } }).user.id const operatorCookie = operator.headers .getSetCookie() .map((cookie) => cookie.split(";")[0]) .find((cookie) => cookie?.startsWith("better-auth.session_token=")) execFileSync( process.execPath, [join(APP_ROOT, "node_modules/convex/bin/main.js"), "env", "set", "BETTER_AUTH_ADMIN_USER_IDS", operatorId], { cwd: join(APP_ROOT, "better-auth"), env: { ...process.env, CONVEX_SELF_HOSTED_URL: stack().convexUrl, CONVEX_SELF_HOSTED_ADMIN_KEY: stack().adminKey, }, stdio: "ignore", }, ) const ban = await fetch(`${stack().convexSiteUrl}/api/auth/admin/ban-user`, { method: "POST", headers: { "content-type": "application/json", origin: stack().baseUrl, cookie: operatorCookie ?? "" }, body: JSON.stringify({ userId: graceId, banReason: "j-auth" }), }) expect(ban.status, await ban.text()).toBe(200) // Her session is gone at once: the app's next request is signed out. expect(await statusOf(g.page, "tenancy.me.get", undefined)).toBe(401) // kit:support:begin // The relay hands the disablement to the account flow, which revokes every derived session of hers. await eventually("the disablement's revocation", async () => (await platform()).support.find( (entry) => entry.route === "revoke" && (entry.body as Record).subject === graceSubject, ), ) // kit:support:end // And she cannot sign in again: Better Auth refuses, and the page says so in her language. expect(await signInPage(g.page, grace.email)).toBe(403) await expect(g.page.locator("form").getByRole("alert")).toHaveText( "This account is disabled. Ask the people who run this app.", ) await g.context.close() }) test("Better Auth's own account deletion erases the person through the account flow", async ({ browser }) => { const linus = { email: `${unique("linus")}@example.test`, name: "Linus" } const l = await device(browser, 5) await signUp(l.page, linus.name, linus.email) const linusOrg = (await makeOrganization(l.page, "linus")).org const linusSubject = (await call<{ userId: string }>(l.page, "tenancy.me.get", undefined)).userId expect(linusSubject).not.toBe(adaSubject) await call(l.page, "support.widgetSession.mint", { org: linusOrg }) // kit:support const deleted = await authPost(l.page, "/delete-user") expect(deleted.status, deleted.body).toBe(200) expect(await statusOf(l.page, "tenancy.me.get", undefined)).toBe(401) // kit:support:begin await eventually("the deletion's Support erasure", async () => (await platform()).support.find((entry) => entry.route === "erase" && entry.subject === linusSubject), ) // kit:support:end // kit:guides:begin await eventually("the deletion's Guides erasure", async () => (await platform()).guides.find((entry) => entry.route === "erase" && entry.subject === linusSubject), ) // kit:guides:end // No resurrection: the same address signs up again as a new person with nothing. await l.context.clearCookies() await signUp(l.page, linus.name, linus.email) await l.page.goto("/") await expect(l.page.getByText(`Org ${linusOrg}`)).toHaveCount(0) await l.context.close() }) test("the app's own account deletion ends the Better Auth account too", async ({ browser }) => { const mary = { email: `${unique("mary")}@example.test`, name: "Mary" } const m = await device(browser, 6) await signUp(m.page, mary.name, mary.email) await m.page.goto("/settings/account") const view = await call<{ state: string }>(m.page, "account.deletion.request", { confirm: mary.email }) expect(view.state).toBe("completed") expect(await statusOf(m.page, "tenancy.me.get", undefined)).toBe(401) // The Better Auth account is gone with it: the password signs nobody in. expect(await signInPage(m.page, mary.email)).toBe(401) await m.context.close() }) test("a Better Auth outage is a typed unavailable answer, never signed out or somebody else", async () => { execFileSync("docker", ["pause", stack().backend]) try { const response = await b.page.request.post("/api/v1/tenancy.me.get", { headers: { "sec-fetch-site": "same-origin" }, }) expect(response.status()).toBe(503) expect(((await response.json()) as { code?: string }).code).toBe("auth_provider_unavailable") } finally { execFileSync("docker", ["unpause", stack().backend]) } expect(await statusOf(b.page, "tenancy.me.get", undefined)).toBe(200) }) test("network and CSP: one origin, no violation, no server secret or bearer in the browser", async () => { const origin = new URL(stack().baseUrl).origin const foreign = seen.requests.filter( (url) => !url.startsWith(origin) && !url.startsWith("data:") && !url.startsWith("blob:"), ) expect(foreign).toEqual([]) expect(await cspViolations(b.page)).toEqual([]) expect(seen.pageErrors).toEqual([]) const documents = seen.headers.filter((entry) => (entry.headers["content-type"] ?? "").includes("text/html")) expect(documents.length).toBeGreaterThan(5) for (const entry of documents) { const policy = entry.headers["content-security-policy"] ?? "" expect(policy, entry.url).toContain("connect-src 'self'") expect(policy, entry.url).toMatch(/script-src 'self' 'nonce-[^']+' 'strict-dynamic'/) expect(policy, entry.url).not.toContain("unsafe-eval") expect(policy, entry.url).not.toMatch(/\*/) } for (const secret of stack().secrets) { expect(seen.bodies.filter((body) => body.text.includes(secret)).map((body) => body.url)).toEqual([]) expect( seen.headers.filter((entry) => JSON.stringify(entry.headers).includes(secret)).map((entry) => entry.url), ).toEqual([]) } // No bearer of Better Auth's: no token field from /api/auth, no cached Convex JWT cookie, no set-auth-token. for (const body of seen.bodies.filter((entry) => entry.url.includes("/api/auth/"))) { expect(body.text, body.url).not.toMatch(/"token"\s*:/) } expect(seen.setCookies.filter((cookie) => cookie.includes("convex_jwt") && !/Max-Age=0/i.test(cookie))).toEqual( [], ) expect(seen.headers.filter((entry) => "set-auth-token" in entry.headers)).toEqual([]) // The production build the browser loaded carries no server variable of Better Auth's. const statics = join(APP_ROOT, "apps", "web", ".next", "static") const files = (function walk(dir: string): string[] { return readdirSync(dir).flatMap((name) => { const path = join(dir, name) return statSync(path).isDirectory() ? walk(path) : [path] }) })(statics) const leaking = files.filter((file) => { const text = readFileSync(file, "utf8") return ( /BETTER_AUTH_CONVEX_ADMIN_KEY|BETTER_AUTH_SECRET|CONUSAI_API_TOKEN/.test(text) || stack().secrets.some((secret) => text.includes(secret)) ) }) expect(leaking).toEqual([]) }) })