import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, parsePermissionId, } from "@ekklesia/contracts"; import { type AuthorizationEvaluator, createPermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { Hono } from "hono"; import { describe, expect, it, vi } from "vitest"; import { type AuthorizationMiddlewareOptions, getAuthorizedPrincipal, requirePermission, requireTrustedOrigin, } from "../src/authorization-middleware.js"; const testPermission = parsePermissionId("core.test.restricted-thing"); const unregisteredPermission = parsePermissionId("core.test.unregistered-thing"); const registry = createPermissionRegistry([ { baseline: "restricted", description: "A restricted test permission.", id: testPermission }, ]); function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { return { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), ...overrides, }; } function fakeEvaluator(overrides: Partial = {}): AuthorizationEvaluator { return { authorize: vi.fn().mockResolvedValue({ allowed: true }), ...overrides, }; } function buildTestApp(options: Partial = {}) { const runtime = options.runtime ?? fakeRuntime(); const evaluator = options.evaluator ?? fakeEvaluator(); const middlewareOptions: AuthorizationMiddlewareOptions = { evaluator, registry, runtime }; const app = new Hono(); app.get("/protected", requirePermission(middlewareOptions, testPermission), (c) => { const principal = getAuthorizedPrincipal(c); return c.json({ principalKind: principal.kind }); }); app.get("/unregistered", requirePermission(middlewareOptions, unregisteredPermission), (c) => c.json({ ok: true }), ); app.get("/unprotected", (c) => { let threw = false; try { getAuthorizedPrincipal(c); } catch { threw = true; } return c.json({ threw }); }); return { app, evaluator, runtime }; } describe("requirePermission", () => { it("allows the request through and stores the resolved principal on context when the decision is allowed", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")), }); const { app } = buildTestApp({ runtime }); const response = await app.request("/protected"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ principalKind: "authenticated" }); }); it("returns a sanitized 403 when the evaluator denies", async () => { const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) }); const { app } = buildTestApp({ evaluator }); const response = await app.request("/protected"); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(response.headers.get("cache-control")).toBe("no-store"); }); it("returns a sanitized 403 for an unregistered permission without ever resolving the principal", async () => { const runtime = fakeRuntime(); const { app } = buildTestApp({ runtime }); const response = await app.request("/unregistered"); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(runtime.resolvePrincipal).not.toHaveBeenCalled(); }); it("returns a sanitized 503, not a 403, when principal resolution fails", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")), }); const { app } = buildTestApp({ runtime }); const response = await app.request("/protected"); expect(response.status).toBe(503); expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" }); }); it("returns a sanitized 503, not a 403, when evaluation itself fails", async () => { const evaluator = fakeEvaluator({ authorize: vi.fn().mockRejectedValue(new Error("connection terminated")), }); const { app } = buildTestApp({ evaluator }); const response = await app.request("/protected"); expect(response.status).toBe(503); expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" }); }); it("never leaks a raw infrastructure error message through either sanitized 503", async () => { const hostileMessage = "password=hunter2 host=db.internal.example port=5432"; const evaluator = fakeEvaluator({ authorize: vi.fn().mockRejectedValue(new Error(hostileMessage)), }); const { app } = buildTestApp({ evaluator }); const response = await app.request("/protected"); const text = await response.text(); expect(text).not.toContain("hunter2"); expect(text).not.toContain("db.internal.example"); }); it("passes the registry's own baseline and permission id to the evaluator, never a client-supplied value", async () => { const evaluator = fakeEvaluator(); const { app } = buildTestApp({ evaluator }); // A hostile client cannot even express "use this baseline instead" — there is no request // field the middleware reads for it — but sending an unrelated header proves it is ignored. await app.request("/protected", { headers: { "x-baseline": "public" } }); expect(evaluator.authorize).toHaveBeenCalledWith( expect.objectContaining({ baseline: "restricted", permission: testPermission }), ); }); }); describe("getAuthorizedPrincipal", () => { it("throws when called on a route requirePermission never protected", async () => { const { app } = buildTestApp(); const response = await app.request("/unprotected"); expect(await response.json()).toEqual({ threw: true }); }); }); describe("requireTrustedOrigin", () => { const trustedOrigins = new Set(["https://dashboard.example.test"]); function buildOriginApp() { const app = new Hono(); app.post("/mutate", requireTrustedOrigin(trustedOrigins), (c) => c.json({ ok: true })); return app; } it("allows a request from a trusted origin", async () => { const app = buildOriginApp(); const response = await app.request("/mutate", { headers: { origin: "https://dashboard.example.test" }, method: "POST", }); expect(response.status).toBe(200); }); it("rejects a request with no Origin header at all", async () => { const app = buildOriginApp(); const response = await app.request("/mutate", { method: "POST" }); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); }); it("rejects a request from an untrusted origin", async () => { const app = buildOriginApp(); const response = await app.request("/mutate", { headers: { origin: "https://evil.example.test" }, method: "POST", }); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); }); });