feat: add browser auth shell
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m0s

This commit is contained in:
ekklesia
2026-08-23 22:01:26 -05:00
parent 91aa9c2930
commit a8c7c50b4d
26 changed files with 2702 additions and 194 deletions
+293 -4
View File
@@ -1,8 +1,10 @@
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import net from "node:net";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
import {
bootstrapPlatformOwner,
@@ -15,8 +17,12 @@ import {
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import {
bootstrapHost,
type EkklesiaHostConfig,
signInWithEmail,
signOutHost,
} from "@ekklesia/shell";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest";
import type { ClientAddressConfigInput } from "../src/client-address.js";
@@ -26,7 +32,7 @@ import {
MODULE_UI_CONTENT_TYPE,
moduleUiArtifactUrl,
} from "../src/module-ui-routes.js";
import { startApi, type NodeServerTimeouts, type RunningApi } from "../src/server.js";
import { type NodeServerTimeouts, type RunningApi, startApi } from "../src/server.js";
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
@@ -602,6 +608,139 @@ function firstCookiePair(setCookieHeader: string): string {
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
}
interface SafeCookieObservation {
readonly domain: string | undefined;
readonly httpOnly: boolean;
readonly path: string | undefined;
readonly sameSite: string | undefined;
readonly secure: boolean;
}
interface BrowserAuthResponseObservation {
readonly accessControlAllowCredentials: string | null;
readonly accessControlAllowOrigin: string | null;
readonly body: string;
readonly contentLength: string | null;
readonly cookie: SafeCookieObservation;
readonly issuedSetCookie: boolean;
readonly status: number;
}
interface OriginLockedBrowserCookieJar {
readonly fetch: typeof fetch;
hasSessionCookie(): boolean;
signInResponse(): BrowserAuthResponseObservation;
signOutResponse(): BrowserAuthResponseObservation;
}
function observeCookie(setCookie: string | undefined): SafeCookieObservation {
const attributes = (setCookie ?? "")
.split(";")
.slice(1)
.map((part) => part.trim())
.filter(Boolean);
const attributeValue = (name: string): string | undefined => {
const prefix = `${name.toLowerCase()}=`;
const attribute = attributes.find((part) => part.toLowerCase().startsWith(prefix));
return attribute?.slice(prefix.length);
};
const hasAttribute = (name: string): boolean =>
attributes.some((part) => part.toLowerCase() === name.toLowerCase());
return Object.freeze({
domain: attributeValue("domain"),
httpOnly: hasAttribute("httponly"),
path: attributeValue("path"),
sameSite: attributeValue("samesite"),
secure: hasAttribute("secure"),
});
}
/**
* Node deliberately has no ambient browser cookie store. This origin-locked test transport models
* the one host-only session cookie explicitly: it accepts Set-Cookie only from the configured API,
* sends only the cookie pair back to that same origin, clears it on an expiry response, and never
* exposes the stored value to assertions or error output. The observable cookie data contains only
* flags needed to prove the browser contract.
*/
function createOriginLockedBrowserCookieJar(apiBaseUrl: string): OriginLockedBrowserCookieJar {
const apiOrigin = new URL(apiBaseUrl).origin;
let cookiePair: string | undefined;
let signInObservation: BrowserAuthResponseObservation | undefined;
let signOutObservation: BrowserAuthResponseObservation | undefined;
const browserFetch: typeof fetch = async (input, init) => {
const request = new Request(input, init);
const requestUrl = new URL(request.url);
if (requestUrl.origin !== apiOrigin) {
throw harnessError("The browser integration transport refused a cross-origin request.");
}
// A real cross-origin browser supplies Origin itself and its cookie jar only when
// `credentials: include` is selected. Node's fetch has neither ambient behavior, so this
// tightly scoped harness supplies exactly those two transport facts.
request.headers.set("origin", dashboardOrigin);
request.headers.delete("cookie");
if (cookiePair !== undefined && request.credentials === "include") {
request.headers.set("cookie", cookiePair);
}
const response = await fetch(request);
const setCookie = setCookieValue(response);
if (setCookie !== undefined && request.credentials === "include") {
const nextCookiePair = firstCookiePair(setCookie);
const separator = nextCookiePair.indexOf("=");
const nextName = separator < 0 ? "" : nextCookiePair.slice(0, separator);
const nextValue = separator < 0 ? "" : nextCookiePair.slice(separator + 1);
const expiresCookie = /(?:^|;)\s*max-age=0(?:;|$)/iu.test(setCookie);
if (nextName !== "" && nextValue !== "" && !expiresCookie) {
cookiePair = nextCookiePair;
} else if (cookiePair?.startsWith(`${nextName}=`)) {
cookiePair = undefined;
}
}
const pathname = requestUrl.pathname;
if (
request.method === "POST" &&
(pathname === "/api/auth/sign-in/email" || pathname === "/api/auth/sign-out")
) {
const observation = Object.freeze({
accessControlAllowCredentials: response.headers.get("access-control-allow-credentials"),
accessControlAllowOrigin: response.headers.get("access-control-allow-origin"),
body: await response.clone().text(),
contentLength: response.headers.get("content-length"),
cookie: observeCookie(setCookie),
issuedSetCookie: setCookie !== undefined,
status: response.status,
});
if (pathname === "/api/auth/sign-in/email") {
signInObservation = observation;
} else {
signOutObservation = observation;
}
}
return response;
};
return Object.freeze({
fetch: browserFetch,
hasSessionCookie: () => cookiePair !== undefined,
signInResponse: () => {
if (signInObservation === undefined) {
throw harnessError("The browser integration did not observe a sign-in response.");
}
return signInObservation;
},
signOutResponse: () => {
if (signOutObservation === undefined) {
throw harnessError("The browser integration did not observe a sign-out response.");
}
return signOutObservation;
},
});
}
interface SignUpResult {
readonly response: Response;
readonly body: { token: string | null; user: { id: string; email: string } };
@@ -839,12 +978,20 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
const { cookiePair, response } = await signIn(origin, emailOne, password);
const setCookie = setCookieValue(response);
const browserVisibleBody = await response.clone().text();
expect(response.status).toBe(200);
expect(JSON.parse(browserVisibleBody)).toEqual({ ok: true });
expect(browserVisibleBody).not.toContain("token");
expect(browserVisibleBody).not.toContain("user");
expect(browserVisibleBody).not.toContain("session");
expect(browserVisibleBody).not.toContain(emailOne);
expect(setCookie).toBeDefined();
expect(cookiePair).toBeDefined();
sessionCookie = cookiePair as string;
const cookieText = setCookie as string;
expect(cookieText).toContain("Path=/");
expect(cookieText).toContain("HttpOnly");
expect(cookieText).toContain("SameSite=Lax");
expect(cookieText).toContain("Secure");
@@ -1118,6 +1265,148 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
});
});
describe.sequential("live API — exported browser auth shell against PostgreSQL", () => {
it("signs a real platform owner in, bootstraps protected Dashboard navigation, and signs out", async () => {
const modulesDirectory = await createFixtureModulesDirectory();
await createUiModule(
modulesDirectory,
"browser-shell",
"1.0.0",
'export default { id: "browser-shell", version: "1.0.0", mount() {} };\n',
);
const database = await createDisposableDatabase("browser_auth");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url, modulesDirectory);
try {
const origin = apiOrigin(running);
const email = `browser-owner-${randomBytes(6).toString("hex")}@example.test`;
const password = "correct horse battery staple";
const created = await signUp(origin, email, password);
expect(created.response.status).toBe(200);
const authorizationDatabase = createAuthorizationDrizzleDatabase(database.url);
try {
await bootstrapPlatformOwner(authorizationDatabase.db, {
userId: parseEkklesiaUserId(created.body.user.id),
});
} finally {
await authorizationDatabase.close();
}
const cookieJar = createOriginLockedBrowserCookieJar(origin);
const hostConfig: EkklesiaHostConfig = Object.freeze({
apiBaseUrl: origin,
fetch: cookieJar.fetch,
});
// This is the public framework-free helper the browser shell itself calls — no test-side
// reimplementation of its URL, payload, credentials mode, or response handling.
await signInWithEmail(hostConfig, { email, password });
const signIn = cookieJar.signInResponse();
expect(signIn.status).toBe(200);
expect(JSON.parse(signIn.body)).toEqual({ ok: true });
expect(
signIn.contentLength === null ||
signIn.contentLength === String(Buffer.byteLength(signIn.body)),
).toBe(true);
for (const forbidden of ["token", "user", "session", email]) {
expect(signIn.body).not.toContain(forbidden);
}
expect(signIn.issuedSetCookie).toBe(true);
expect(cookieJar.hasSessionCookie()).toBe(true);
expect(signIn.cookie).toEqual({
domain: undefined,
httpOnly: true,
path: "/",
sameSite: "Lax",
secure: true,
});
expect(signIn.accessControlAllowOrigin).toBe(dashboardOrigin);
expect(signIn.accessControlAllowCredentials).toBe("true");
// The pinned Better Auth build would return the raw session token from the first three
// resources, and from change-password when `revokeOtherSessions` is true. Ekklesia has no
// native session/password-management UI, so the outer API boundary must answer before Better
// Auth can read these authenticated requests. The valid change-password payload makes this a
// meaningful leak regression: if the denylist disappeared it could mint and expose a new
// token, not merely fail validation before reaching the vulnerable response.
const deniedNativeEndpoints = [
{ method: "GET", path: "/api/auth/get-session?source=browser-shell" },
{ method: "GET", path: "/api/auth/%67et-session" },
{ method: "GET", path: "/api/auth/get-session%2F" },
{ body: {}, method: "POST", path: "/api/auth/get-session" },
{ method: "GET", path: "/api/auth/list-sessions" },
{ body: { arbitrary: "value" }, method: "POST", path: "/api/auth/update-session" },
{
body: {
currentPassword: password,
newPassword: "replacement horse battery staple",
revokeOtherSessions: true,
},
method: "POST",
path: "/api/auth/change-password",
},
] as const;
for (const endpoint of deniedNativeEndpoints) {
const response = await cookieJar.fetch(`${origin}${endpoint.path}`, {
credentials: "include",
method: endpoint.method,
...("body" in endpoint
? {
body: JSON.stringify(endpoint.body),
headers: { "content-type": "application/json" },
}
: {}),
});
const visibleBody = await response.text();
expect(response.status).toBe(404);
expect(JSON.parse(visibleBody)).toEqual({ code: "NOT_FOUND" });
for (const forbidden of ["token", "session", email, password]) {
expect(visibleBody).not.toContain(forbidden);
}
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
}
// `bootstrapHost` resolves the principal first, then calls the protected Dashboard endpoint.
// A real module in the result proves the latter request was authenticated and authorized.
const authenticated = await bootstrapHost(hostConfig);
expect(authenticated.principal).toEqual({
kind: "authenticated",
userId: created.body.user.id,
});
expect(authenticated.navigation.groups).toEqual([]);
expect(authenticated.navigation.ungrouped.map((entry) => entry.descriptor.id)).toEqual([
"browser-shell",
]);
await signOutHost(hostConfig);
const signOut = cookieJar.signOutResponse();
expect(signOut.status).toBe(200);
expect(JSON.parse(signOut.body)).toEqual({ success: true });
expect(signOut.issuedSetCookie).toBe(true);
expect(signOut.accessControlAllowOrigin).toBe(dashboardOrigin);
expect(signOut.accessControlAllowCredentials).toBe("true");
expect(cookieJar.hasSessionCookie()).toBe(false);
// Reload through the same exported principal-first shell bootstrap. The expired cookie is no
// longer sent, protected navigation is not probed, and the host returns its frozen empty view.
const reloaded = await bootstrapHost(hostConfig);
expect(reloaded.principal).toEqual({ kind: "anonymous" });
expect(reloaded.navigation).toEqual({ groups: [], ungrouped: [] });
} finally {
await running.shutdown().catch(() => undefined);
await dropDatabase(database.name);
}
});
});
describe.sequential("live API — startup and migration guarantees", () => {
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
const database = await createDisposableDatabase("unmigrated");