feat: add browser auth shell
This commit is contained in:
+105
-17
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleSecretSource,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type {
|
||||
@@ -9,31 +9,31 @@ import type {
|
||||
AuthorizationStore,
|
||||
PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type {
|
||||
DiscoveredModule,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleConfigurationSettings,
|
||||
ModuleConfigurationStore,
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleLifecycleStore,
|
||||
ModuleStorageLookup,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleStorageLookup,
|
||||
ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
|
||||
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
|
||||
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
|
||||
import { createModuleUiRoutes } from "./module-ui-routes.js";
|
||||
|
||||
@@ -138,6 +138,87 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
const EMAIL_SIGN_IN_PATH = "/api/auth/sign-in/email";
|
||||
const EMAIL_SIGN_IN_SUCCESS_BODY = JSON.stringify({ ok: true });
|
||||
|
||||
/**
|
||||
* Better Auth publishes general-purpose session-management endpoints even when an application has
|
||||
* no client for them. These four can return the raw bearer-equivalent session token with the
|
||||
* pinned configuration: get/list/update return a session record; change-password returns a newly
|
||||
* minted token when asked to revoke other sessions. Ekklesia deliberately exposes only its narrow
|
||||
* `/api/v1/principal` projection and has no native session/password-management browser UI, so the
|
||||
* supported method/path pairs are closed at the outer HTTP boundary before Better Auth executes.
|
||||
*/
|
||||
const TOKEN_BEARING_AUTH_ENDPOINT_METHODS: Readonly<Record<string, readonly string[]>> =
|
||||
Object.freeze({
|
||||
"/api/auth/change-password": Object.freeze(["POST"]),
|
||||
"/api/auth/get-session": Object.freeze(["GET", "POST"]),
|
||||
"/api/auth/list-sessions": Object.freeze(["GET"]),
|
||||
"/api/auth/update-session": Object.freeze(["POST"]),
|
||||
});
|
||||
|
||||
function decodedRequestPathname(request: Request): string | undefined {
|
||||
const encodedPathname = new URL(request.url).pathname;
|
||||
try {
|
||||
// URL.pathname intentionally preserves percent escapes. Decode exactly once before comparing,
|
||||
// matching the maximum decoding a downstream HTTP router may safely perform; malformed
|
||||
// encodings are denied rather than delegated through an ambiguous parser boundary.
|
||||
return decodeURIComponent(encodedPathname);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isDisabledTokenBearingAuthRequest(request: Request): boolean {
|
||||
const pathname = decodedRequestPathname(request);
|
||||
if (pathname === undefined) {
|
||||
return true;
|
||||
}
|
||||
// Better Auth is configured not to skip trailing slashes, but treating trailing-slash spellings
|
||||
// as the same denied resource prevents a router-option change from opening a bypass. This does
|
||||
// not collapse interior duplicate slashes or otherwise broaden path normalization.
|
||||
const canonicalPath = pathname.replace(/\/+$/u, "") || "/";
|
||||
return TOKEN_BEARING_AUTH_ENDPOINT_METHODS[canonicalPath]?.includes(request.method) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Better Auth's native successful email sign-in response includes the newly created session token
|
||||
* and the complete user object in its JSON body even though the browser already received the
|
||||
* session through an HttpOnly cookie. The browser boundary must never make either value visible to
|
||||
* application JavaScript. Keep this deliberately narrower than a general Better Auth response
|
||||
* adapter: only the exact supported POST endpoint and a body-bearing successful response are
|
||||
* normalized. Every sign-in failure and every other delegated auth endpoint remains Better Auth's
|
||||
* native contract; the explicitly denied token-bearing endpoints above are never delegated.
|
||||
*/
|
||||
function browserSafeAuthResponse(request: Request, response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "no-store");
|
||||
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
decodedRequestPathname(request) === EMAIL_SIGN_IN_PATH &&
|
||||
response.ok &&
|
||||
response.status !== 204 &&
|
||||
response.status !== 205
|
||||
) {
|
||||
// The upstream length describes the deliberately discarded token/user JSON. Retaining it could
|
||||
// truncate or stall the replacement payload once an HTTP adapter serializes this response.
|
||||
headers.delete("Content-Length");
|
||||
headers.set("Content-Type", "application/json; charset=UTF-8");
|
||||
return new Response(EMAIL_SIGN_IN_SUCCESS_BODY, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono's own `cors()` unconditionally appends its own "Vary: Origin" after every response with a
|
||||
* dynamic-function `origin` — including one that already has its own `Vary` (module responses
|
||||
@@ -204,6 +285,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
app.use("/api/auth/*", corsMiddleware);
|
||||
app.use("/api/v1/*", corsMiddleware);
|
||||
|
||||
// This gate intentionally precedes the body limiter and the generic Better Auth delegate. A
|
||||
// denied POST neither reads an attacker-controlled body nor executes any identity-runtime code;
|
||||
// it always has the platform's fixed, non-secret 404 shape. Query strings do not affect the
|
||||
// pathname comparison, and the CORS middleware above still applies the exact trusted-origin
|
||||
// policy to the response.
|
||||
app.on(["POST", "GET"], "/api/auth/*", async (c, next) => {
|
||||
if (!isDisabledTokenBearingAuthRequest(c.req.raw)) {
|
||||
return next();
|
||||
}
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "NOT_FOUND" }, 404);
|
||||
});
|
||||
|
||||
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
|
||||
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
|
||||
try {
|
||||
@@ -212,13 +306,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
|
||||
// only affects `c.res`) guarantees this regardless of how the handler produced its result,
|
||||
// and never reads or buffers the body.
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "no-store");
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return browserSafeAuthResponse(c.req.raw, response);
|
||||
} catch (error) {
|
||||
return sanitizedErrorResponse(c, error, 500);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+233
-6
@@ -11,22 +11,22 @@ import {
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import {
|
||||
createApiApp as createApiAppWithModules,
|
||||
type CreateApiAppAuthorizationOptions,
|
||||
type CreateApiAppOptions,
|
||||
createApiApp as createApiAppWithModules,
|
||||
} from "../src/app.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
@@ -96,6 +96,21 @@ function fakeAuthorization(overrides: {
|
||||
return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
|
||||
}
|
||||
|
||||
function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
||||
return createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
}
|
||||
|
||||
describe("Ekklesia API Hono application", () => {
|
||||
it("mounts Better Auth exactly under /api/auth/*", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
@@ -115,10 +130,222 @@ describe("Ekklesia API Hono application", () => {
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("auth-ok");
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces only a successful email sign-in body without exposing Better Auth's token or user", async () => {
|
||||
const nativeCookie =
|
||||
"__Secure-better-auth.session_token=opaque-cookie-value; Path=/; HttpOnly; Secure; SameSite=Lax";
|
||||
const nativeBody = JSON.stringify({
|
||||
token: "javascript-visible-session-token",
|
||||
user: { email: "[email protected]", id: "user_123", name: "Owner" },
|
||||
});
|
||||
const nativeResponse = new Response(nativeBody, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": trustedOrigin,
|
||||
"Content-Length": "4096",
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": nativeCookie,
|
||||
"X-Native-Auth-Header": "preserved",
|
||||
},
|
||||
status: 201,
|
||||
statusText: "Signed In",
|
||||
});
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(nativeResponse),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const visibleBody = await response.text();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.statusText).toBe("Signed In");
|
||||
expect(JSON.parse(visibleBody)).toEqual({ ok: true });
|
||||
expect(visibleBody).not.toContain("token");
|
||||
expect(visibleBody).not.toContain("user");
|
||||
expect(visibleBody).not.toContain("session");
|
||||
expect(visibleBody).not.toContain("[email protected]");
|
||||
expect(response.headers.get("set-cookie")).toBe(nativeCookie);
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("content-type")).toBe("application/json; charset=UTF-8");
|
||||
expect(response.headers.get("content-length")).not.toBe("4096");
|
||||
expect(response.headers.get("x-native-auth-header")).toBe("preserved");
|
||||
expect(nativeResponse.bodyUsed).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/api/auth/%73ign-in/email",
|
||||
"/api/auth/sign-in%2Femail",
|
||||
])("normalizes the router-equivalent encoded sign-in path %s", async (path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
token: "javascript-visible-session-token",
|
||||
user: { email: "[email protected]" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request(path, { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves a failed email sign-in response body and status unchanged", async () => {
|
||||
const nativeFailure = JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" });
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(
|
||||
new Response(nativeFailure, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.statusText).toBe("Unauthorized");
|
||||
expect(await response.text()).toBe(nativeFailure);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a GET to the sign-in path", "GET", "/api/auth/sign-in/email"],
|
||||
["a sign-out POST", "POST", "/api/auth/sign-out"],
|
||||
["a trailing-slash sign-in POST", "POST", "/api/auth/sign-in/email/"],
|
||||
])("leaves %s on Better Auth's native success contract", async (_label, method, path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("native-auth-body", { status: 200 })),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request(path, { method });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("native-auth-body");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
describe("unused native session-token endpoints", () => {
|
||||
const disabledRequests = [
|
||||
["GET", "/api/auth/get-session"],
|
||||
["POST", "/api/auth/get-session"],
|
||||
["GET", "/api/auth/list-sessions"],
|
||||
["POST", "/api/auth/update-session"],
|
||||
["POST", "/api/auth/change-password"],
|
||||
] as const;
|
||||
|
||||
it.each(
|
||||
disabledRequests,
|
||||
)("returns a fixed 404 for %s %s, including query and trailing-slash spellings, without invoking Better Auth", async (method, path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
session: { token: "javascript-visible-session-token" },
|
||||
token: "another-session-token",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
for (const requestPath of [path, `${path}?include=session`, `${path}/`]) {
|
||||
const response = await app.request(requestPath, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method,
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(JSON.parse(body)).toEqual({ code: "NOT_FOUND" });
|
||||
expect(body).not.toContain("token");
|
||||
expect(body).not.toContain("session");
|
||||
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(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||
}
|
||||
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/api/auth/%67et-session"],
|
||||
["GET", "/api/auth/%6Cist-sessions"],
|
||||
["POST", "/api/auth/%75pdate-session"],
|
||||
["POST", "/api/auth/change-password%2F"],
|
||||
["GET", "/api/auth/get-session%2F"],
|
||||
["GET", "/api/auth%2Fget-session"],
|
||||
["GET", "/api/auth/get-session%"],
|
||||
])("does not delegate the encoded or malformed spelling %s %s", async (method, path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValue(Response.json({ session: { token: "exposed" } })),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request(path, { method });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies an oversized update-session body before the body limiter or Better Auth reads it", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request("/api/auth/update-session", {
|
||||
body: "x",
|
||||
headers: {
|
||||
"content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["POST", "/api/auth/list-sessions"],
|
||||
["GET", "/api/auth/update-session"],
|
||||
["GET", "/api/auth/change-password"],
|
||||
["GET", "/api/auth/list-sessions/export"],
|
||||
])("leaves the neighbouring non-denied request %s %s on the native contract", async (method, path) => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response("native-method-response", { status: 200 })),
|
||||
});
|
||||
const app = createAuthWiringApp(runtime);
|
||||
|
||||
const response = await app.request(path, { method });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("native-method-response");
|
||||
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not route requests outside /api/auth/* to Better Auth", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
@@ -289,7 +516,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("auth-ok");
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user