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
+233 -6
View File
@@ -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");
});