Found during a targeted post-audit review: sanitizeModuleResponse's Vary fix
(bf76f4e) canonicalized the module response's own Vary before Hono's own
cors() middleware (apps/api/src/app.ts) ran, but cors() unconditionally
appends its own "Vary: Origin" afterward, outside module-http-routes.ts's
reach. The wire-level response app.fetch actually returns could therefore
still read "*, Origin" for a module handler that sets Vary: *, or carry a
case-different duplicate Origin token — reproduced first via a new app-level
regression test hitting the final response, not sanitizeModuleResponse in
isolation.
Fixed by wrapping the shared corsMiddleware in withCanonicalVary
(apps/api/src/app.ts), which re-canonicalizes whatever Vary cors() leaves
behind only after it has fully finished, handling both the normal
next()-driven path (via c.header(), since reassigning c.res triggers Hono's
own header-preserving merge and would silently undo the fix) and the
OPTIONS-preflight path (cors() returns a Response directly without touching
c.res). Neither path reads or clones the response body.
Refactored module-http-routes.ts's Vary logic into one shared parsing/dedup
primitive with two policy-specific callers: mergedVaryHeader (module
responses; always forces canonical Origin/Cookie, unchanged behavior) and
the new canonicalizeVaryHeader (the app-level CORS boundary; only
deduplicates and collapses "*", never injects a token that was not already
present — an OPTIONS preflight has no Cookie/personalization concept to
force). This also incidentally fixes the same pre-existing Origin
duplication on /api/v1/principal (present since Phase 4B, flagged as a
remaining concern in the prior pass), since it wraps the one shared
middleware every /api/v1/* and /api/auth/* response already passes through.
ADR 0015 required no changes: its Vary/CORS ownership statements are
architectural, not tied to a specific function, and remain accurate.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } 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 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([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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("has deterministic 404 behavior for an unknown route", async () => {
|
|
const app = createApiApp({
|
|
moduleRoutes: Object.freeze([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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([]),
|
|
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();
|
|
});
|
|
});
|
|
});
|