Files
Ekklesia/apps/api/tests/app.test.ts
T
ekklesiaandClaude Sonnet 5 cafd513019 fix: secure authentication request identity
Replace Better Auth's default trust of a caller-controlled X-Forwarded-For
with a platform-owned client-address resolution boundary: direct mode (the
safe default) trusts only the real socket peer, and an explicit trusted-proxy
mode requires a header name, a concrete trusted-proxy list, and only honors a
forwarded chain from an actual trusted peer. This closes a rate-limit bypass
where rotating a spoofed header let a caller choose a fresh bucket on every
request. Public sign-up is also disabled by default and requires an explicit
typed opt-in, using Better Auth's own disableSignUp gate so no email-existence
leak or second signup path is introduced.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 12:20:51 -05:00

398 lines
15 KiB
TypeScript

import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
CORE_PERMISSION_REGISTRY,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest";
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
...overrides,
};
}
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
* never real database behavior, so no PostgreSQL is required. */
function fakeModuleServices(): ModuleRuntimeServices {
const database = Object.freeze({
delete: vi.fn(),
execute: vi.fn(),
insert: vi.fn(),
select: vi.fn(),
selectDistinct: vi.fn(),
selectDistinctOn: vi.fn(),
transaction: vi.fn(),
update: vi.fn(),
}) as unknown as ModuleDatabase;
return Object.freeze({ database });
}
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
* specific decision or store behavior override the relevant method explicitly. The registry
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
* mounted grants routes require) rather than an app-composed one — these tests exercise
* `createApiApp`'s wiring, not permission composition itself. */
function fakeAuthorization(overrides: {
readonly evaluator?: Partial<AuthorizationEvaluator>;
readonly registry?: PermissionRegistry;
readonly store?: Partial<AuthorizationStore>;
}): CreateApiAppAuthorizationOptions {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
...overrides.evaluator,
};
const store: AuthorizationStore = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue([]),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
...overrides.store,
};
return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
}
describe("Ekklesia API Hono application", () => {
it("mounts Better Auth exactly under /api/auth/*", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
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(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
it("does not route requests outside /api/auth/* to Better Auth", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/sign-in/email", { method: "POST" });
expect(response.status).toBe(404);
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it("returns the anonymous principal shape with no-store caching", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ kind: "anonymous" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("vary")).toContain("Cookie");
});
it("never duplicates Origin in Vary even though the route sets it directly and Hono's own CORS middleware also appends it", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: { origin: trustedOrigin },
});
const vary = response.headers.get("vary") ?? "";
const tokens = vary.split(",").map((token) => token.trim().toLowerCase());
expect(tokens.filter((token) => token === "origin")).toHaveLength(1);
expect(tokens).toContain("cookie");
});
it("returns the authenticated principal shape containing only kind and userId", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal");
const body = (await response.json()) as Record<string, unknown>;
expect(response.status).toBe(200);
expect(body).toEqual({ kind: "authenticated", userId: "user_123" });
expect(Object.keys(body).sort()).toEqual(["kind", "userId"]);
});
it("returns a stable sanitized 503 when principal resolution fails, never a false anonymous 200", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi
.fn()
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal");
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("returns a stable sanitized 500 for an unexpected auth-handler failure, never a raw message", async () => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
const body = await response.json();
expect(response.status).toBe(500);
expect(body).toEqual({ code: "INTERNAL_ERROR" });
expect(JSON.stringify(body)).not.toContain("secret");
expect(JSON.stringify(body)).not.toContain("postgresql://");
});
it("forces Cache-Control: no-store on every Better Auth response, including a successful one", async () => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
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(response.headers.get("cache-control")).toBe("no-store");
});
it("has deterministic 404 behavior for an unknown route", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/does-not-exist");
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
});
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
});
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: { origin: untrustedOrigin },
});
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
it("supports requests with no Origin header at all (native/server clients)", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal");
expect(response.status).toBe(200);
});
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: {
"access-control-request-method": "GET",
origin: trustedOrigin,
},
method: "OPTIONS",
});
expect(response.status).toBe(204);
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
});
it("never wildcards Access-Control-Allow-Origin", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
});
// Unlike Better Auth's own documented Hono example (which hydrates `c.set("user", session.user)`
// on every request), createApiApp only ever calls `c.set` once, for the narrow EkklesiaPrincipal
// (kind + userId only), and only on routes `requirePermission` actually protects — proven by the
// grants-routes tests, not by a black-box context probe here. `/api/v1/principal` itself still
// never touches `c.set`; its response shape is asserted directly above.
describe("grants routes mounting (see grants-routes.test.ts for full coverage)", () => {
it("mounts the v1 grants routes under /api/v1/grants", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/grants?userId=user_123");
// Reaching the permission check (and being denied by it) proves the route is mounted and
// wired to the injected evaluator, rather than falling through to the 404 handler.
expect(response.status).toBe(403);
expect(evaluatorAuthorize).toHaveBeenCalledTimes(1);
});
it("uses exactly the injected registry, never an internal Core-only fallback", async () => {
// An evaluator that would allow anything, paired with a registry that does not register
// core.authorization.grants.manage at all — if createApiApp silently fell back to some
// internal registry containing it, this request would reach the evaluator and succeed
// (empty grant list, 200). It must instead fail closed on the registry lookup itself.
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
const emptyRegistry: PermissionRegistry = {
declarations: [],
get: () => undefined,
has: () => false,
};
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize },
registry: emptyRegistry,
}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/grants?userId=user_123");
expect(response.status).toBe(403);
expect(evaluatorAuthorize).not.toHaveBeenCalled();
});
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/grants?userId=user_123", {
headers: { origin: untrustedOrigin },
});
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
});
});