Auth guards (todo §4); guards.ts: requireSession/can/check + GuardError, app.ts maps GuardError → 303 /login or 403 (never 500)
This commit is contained in:
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
|
||||
import { after, before, test, type TestContext } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApp } from "./app.ts";
|
||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||
import { staticJwks } from "./jwks.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||
@@ -209,6 +210,41 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
||||
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 403);
|
||||
});
|
||||
|
||||
test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => {
|
||||
const keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters<typeof check>[0];
|
||||
const guarded: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "guarded",
|
||||
routes: [
|
||||
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
|
||||
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
||||
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
||||
],
|
||||
};
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const auth = (roles: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles, sub: "u1" })}` } });
|
||||
|
||||
// requireSession: anonymous bounces to /login; a signed-in user reaches the handler.
|
||||
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
||||
assert.equal(anon.status, 303);
|
||||
assert.equal(anon.headers.get("location"), "/login");
|
||||
const me = await fetch(url + "/guarded/me", auth([]));
|
||||
assert.equal(me.status, 200);
|
||||
assert.match(await me.text(), /hi a@b\.c/);
|
||||
|
||||
// can: signed-in but lacking the role → 403 page; carrying it → 200.
|
||||
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
|
||||
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
|
||||
|
||||
// check (live Keto): the keto verdict gates the handler.
|
||||
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
|
||||
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
||||
});
|
||||
|
||||
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
|
||||
const seen: string[] = [];
|
||||
const hooked: Plugin = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as ejs from "ejs";
|
||||
import { buildContext } from "./context.ts";
|
||||
import { buildDashboardModel } from "./dashboard.ts";
|
||||
import { PLUGINS_DIR } from "./discovery.ts";
|
||||
import { GuardError } from "./guards.ts";
|
||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
||||
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
|
||||
import type { JwksProvider } from "./jwks.ts";
|
||||
@@ -157,6 +158,13 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
sendHtml(res, 404, await render("404", { title: "Not found" }));
|
||||
} catch (err) {
|
||||
// A guard thrown anywhere in handling maps to a response (not a 500): a `location` ⇒ a
|
||||
// redirect (requireSession → /login), otherwise the status renders the error page.
|
||||
if (err instanceof GuardError) {
|
||||
if (res.headersSent) return void res.end();
|
||||
if (err.location) return void res.writeHead(303, { location: err.location }).end();
|
||||
return void sendHtml(res, err.status, await render("403", { title: "Forbidden" }));
|
||||
}
|
||||
console.error(err);
|
||||
if (res.headersSent) return void res.end(); // a partial body is already on the wire
|
||||
try {
|
||||
|
||||
47
src/guards.test.ts
Normal file
47
src/guards.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
||||
|
||||
function ctxFor(user: User | null): RequestContext {
|
||||
const req = new IncomingMessage(new Socket());
|
||||
req.url = "/";
|
||||
return buildContext(req, new ServerResponse(req), { user });
|
||||
}
|
||||
|
||||
const alice: User = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
|
||||
|
||||
test("requireSession returns the user, or throws GuardError(401)→/login when anonymous", () => {
|
||||
assert.equal(requireSession(ctxFor(alice)), alice);
|
||||
|
||||
assert.throws(() => requireSession(ctxFor(null)), (err: unknown) => {
|
||||
assert.ok(err instanceof GuardError);
|
||||
assert.equal(err.status, 401);
|
||||
assert.equal(err.location, "/login"); // app.ts turns this into a 303 to sign in
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("can reads a coarse role from the JWT claims; anonymous has none", () => {
|
||||
assert.equal(can(ctxFor(alice), "admin"), true);
|
||||
assert.equal(can(ctxFor(alice), "billing:write"), false);
|
||||
assert.equal(can(ctxFor(null), "admin"), false);
|
||||
});
|
||||
|
||||
test("check asks Keto with the current user as subject; anonymous is denied without a call", async () => {
|
||||
let asked: RelationTuple | undefined;
|
||||
const keto = {
|
||||
check: async (tuple: RelationTuple) => { asked = tuple; return true; },
|
||||
} as unknown as KetoClient;
|
||||
const tuple = { namespace: "Resource", object: "doc1", relation: "view" };
|
||||
|
||||
assert.equal(await check(keto, ctxFor(alice), tuple), true);
|
||||
assert.deepEqual(asked, { ...tuple, subject_id: "user:u1" }); // subject is the signed-in user
|
||||
|
||||
asked = undefined;
|
||||
assert.equal(await check(keto, ctxFor(null), tuple), false); // fail-closed, no Keto call
|
||||
assert.equal(asked, undefined);
|
||||
});
|
||||
43
src/guards.ts
Normal file
43
src/guards.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Auth guards (todo §4): in-handler authorization, the imperative counterpart to the
|
||||
// declarative route `permission` gate. The middleware already verified the session JWT and put
|
||||
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
|
||||
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
|
||||
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
|
||||
import type { RequestContext, User } from "./context.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
|
||||
// Thrown by an asserting guard; app.ts maps it to a response. `location` ⇒ a 303 redirect (an
|
||||
// anonymous browser bounces to /login); otherwise `status` renders an error page (403 Forbidden).
|
||||
// A handler may throw its own (e.g. `new GuardError(403, …)` after a failed `can`/`check`).
|
||||
export class GuardError extends Error {
|
||||
location?: string | undefined;
|
||||
status: number;
|
||||
constructor(status: number, message: string, location?: string) {
|
||||
super(message);
|
||||
this.location = location;
|
||||
this.name = "GuardError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert a signed-in session and return the user. Anonymous ⇒ GuardError → /login.
|
||||
export function requireSession(ctx: RequestContext): User {
|
||||
if (!ctx.user) throw new GuardError(401, "authentication required", "/login");
|
||||
return ctx.user;
|
||||
}
|
||||
|
||||
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
||||
export function can(ctx: RequestContext, role: string): boolean {
|
||||
return ctx.roles.includes(role);
|
||||
}
|
||||
|
||||
// Live Keto relationship check at the point of action. The subject is the current user;
|
||||
// anonymous ⇒ false (fail-closed, no Keto call).
|
||||
export async function check(
|
||||
keto: KetoClient,
|
||||
ctx: RequestContext,
|
||||
tuple: { namespace: string; object: string; relation: string },
|
||||
): Promise<boolean> {
|
||||
if (!ctx.user) return false;
|
||||
return keto.check({ ...tuple, subject_id: `user:${ctx.user.id}` });
|
||||
}
|
||||
Reference in New Issue
Block a user