feat: add browser auth shell
This commit is contained in:
+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