Files

492 lines
24 KiB
TypeScript

// 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<string, string> }>,
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<string, string>)
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<string[]> {
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<Record<string, unknown>>
guides: Array<Record<string, unknown>>
}> {
const response = await fetch(`${stack().platformUrl}/__e2e/state`)
return (await response.json()) as { support: Array<Record<string, unknown>>; guides: Array<Record<string, unknown>> }
}
async function eventually<T>(
label: string,
probe: () => Promise<T | undefined>,
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<void> {
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<string> {
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<number> {
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<number> {
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, unknown>) => String((mint.body as Record<string, unknown>).identity_session)
const subjectOf = (mint: Record<string, unknown>) => String((mint.body as Record<string, unknown>).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<string, unknown>)).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<string, unknown>)).toBe(adaSubject)
expect(sessionKey(mintA as Record<string, unknown>)).toMatch(/^18:convex-better-auth:/)
expect(sessionKey(mintB as Record<string, unknown>)).not.toBe(sessionKey(mintA as Record<string, unknown>))
expect(((mintA as Record<string, unknown>).body as Record<string, unknown>).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<string, unknown>).identity_session === sessionKey(mintA as Record<string, unknown>),
),
)
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<string, unknown>
// 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<string, unknown>).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<string, unknown>).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([])
})
})