diff --git a/README.md b/README.md index d117620..9644933 100644 --- a/README.md +++ b/README.md @@ -354,9 +354,10 @@ and helpers rather than declaring a schema and getting magic. The vocabulary is - **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions), pagination, form fields, badges, menus, auth cards. - **Helpers:** `composeNav` (menu from config), `parseListQuery` - (`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math). Auth - guards — `requireSession` (validate the JWT), `can(role)` (read a claim, in-process), - `check(relation, object)` (a live Keto call) — land with §4. + (`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the auth + guards a handler calls to authorize (`src/guards.ts`): `requireSession` (assert a session — a + `GuardError` the host turns into a redirect to sign in), `can(role)` (a coarse JWT-claim check, + zero I/O), `check(relation, object)` (the one live Keto call, for relationship rules). ## Interactivity: zero-JS spine, opt-in enhancement diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index 79d1618..f1cc708 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -130,6 +130,12 @@ export async function listShifts(ctx: RequestContext) { like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()` the core building-block partials (app shell, nav tree, data table, …) and its own partials/subfolders to render a full page — exactly as the built-in screens do. +- **Finer authorization than the route `permission`** uses the guards in `src/guards.ts`: + `requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect + to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx, + {namespace, object, relation})` (a live Keto check for relationship rules — the subject is the + signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check` + to render the 403 page. - The handler **fetches its own data** from upstream and renders it; plugins hold no state (see the README's *Stateless* section). The partials only need rows. - `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`. diff --git a/src/app.test.ts b/src/app.test.ts index 3790dd4..65f0fee 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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[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((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 = { diff --git a/src/app.ts b/src/app.ts index bf2f9ce..f8b444c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 { diff --git a/src/guards.test.ts b/src/guards.test.ts new file mode 100644 index 0000000..0218c35 --- /dev/null +++ b/src/guards.test.ts @@ -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); +}); diff --git a/src/guards.ts b/src/guards.ts new file mode 100644 index 0000000..77daf69 --- /dev/null +++ b/src/guards.ts @@ -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 { + if (!ctx.user) return false; + return keto.check({ ...tuple, subject_id: `user:${ctx.user.id}` }); +} diff --git a/todo.md b/todo.md index b2272cd..0f92106 100644 --- a/todo.md +++ b/todo.md @@ -83,7 +83,7 @@ everything via Docker. - [x] Login completion: read roles from Keto → write `metadata_public` projection → tokenize → set JWT cookie. → `src/login.ts` (`completeLogin`/`readRoles`/`sessionCookie`, `SESSION_COOKIE`), wired into `app.ts` at `GET /auth/complete` — where `kratos.yml` now lands the browser after a successful login (`login.after.default_browser_return_url`). The route: `whoami(cookie)` → identity (id/email; no session ⇒ 303 `/login`); `readRoles` lists `Role:*#members@user:` from Keto (one paged read, sorted/de-duped; group→role transitivity is §5); projects `{roles}` onto the identity; then `whoami(tokenize_as: plainpages)` → the signed JWT, stored as `plainpages_jwt` (HttpOnly + SameSite=Lax + 30d, `secure` deferred to §9). `server.ts` builds the kratos-admin + keto clients and passes all three to `createApp`. **Design bug caught in live boot-verify + fixed:** the projection had to move `metadata_admin` → `metadata_public` — Kratos *strips admin metadata* from the session the tokenizer reads, so `metadata_admin` yielded `roles:[]`; `metadata_public` is carried (and the user already reads these coarse roles in their own JWT, so nothing leaks). Touched `kratos-admin.ts` (`updateMetadataAdmin`→`updateMetadataPublic`, `/metadata_public` patch), the tokenizer jsonnet, and the kratos.yml/README rationale. Tests-first: `login.test.ts` (readRoles paging/dedup; completeLogin order whoami→project→tokenize; no-session⇒null; missing email⇒null; no-JWT⇒throw; cookie flags) + `app.test.ts` integration (`/auth/complete` projects roles, sets `plainpages_jwt`, 303→`/`; no session ⇒ 303 `/login`, no cookie) + `kratos.test.ts` (after-login URL + jsonnet metadata_public). Boot-verified the whole chain live: real admin login → `/auth/complete` → JWT `{sub, email, roles:["admin"], exp−iat=600}`, identity re-projected `metadata_public:{roles:["admin"]}` from Keto (wiped first to prove the write); no-session ⇒ 303 `/login`; torn down. The full-stack login Playwright E2E is owned by §8. typecheck + 189 units green. - [x] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles). → `src/jwt-middleware.ts` (`authenticate`/`verifyToken`/`validateClaims`/`claimsToUser`) is the per-request hot path that never calls Ory: read the `plainpages_jwt` cookie → `decodeJws` the `kid` → resolve the verify key from the cached JWKS → `verifyJws` (§0 signature/alg-confusion guards) → validate claims → project the `User` (`sub`→id, email, roles). `src/jwks.ts` (`JwksProvider`, `loadJwks`, `staticJwks`) is the key-by-`kid` seam: `loadJwks` reads the mounted `file://` tokenizer key (dev default + prod mount) or a `base64://` inline set; `staticJwks` picks by `kid`, falling back to the sole key when a token carries none — **HTTP fetch + TTL cache + rotation-on-miss is the next §4 item (line 85)**; the interface lets it drop in without touching callers. Claim checks: `exp` required + `nbf` honoured, both with a 60s clock-skew leeway; `iss`/`aud` are **opt-in** — validated only when `JWT_ISSUER`/`JWT_AUDIENCE` are pinned (new optional `config.ts` fields), because the Kratos tokenizer sets neither (a clean clone must still verify). `authenticate` **fails closed**: any bad/expired/malformed token ⇒ `null` (anonymous), so the route renders signed-out and the §2 permission gate denies. Wired into `app.ts` — verify once per request (after the static short-circuit, before routing/hooks), thread `user` into both the base and route `RequestContext`, and feed `ctx.roles` (was `[]`) into the dashboard nav; `server.ts` loads the mounted JWKS at boot + passes the pinned iss/aud. Tests-first: `jwt-middleware.test.ts` (key-by-kid across a rotated set, exp/nbf + skew, iss/aud only-when-configured, bad-sig/unknown-kid, claimsToUser sub/email/roles, authenticate fail-closed matrix), `jwks.test.ts` (kid select/sole-key/miss + file/base64/reject-http), `config.test.ts` (iss/aud optional), `app.test.ts` (a verified cookie authorizes the gated `/demo/secret`; no-cookie/expired ⇒ 403). typecheck + 199 units + 7 E2E green; boot-smoked server.ts loading the mounted key. The live-stack token-refresh/timeout E2E is the §4 line 90 item; the full login E2E is §8. - [x] JWKS fetch + cache + rotation handling. → `src/jwks.ts`: `cachingJwks(load, opts)` self-refreshing provider behind the existing `JwksProvider.getKey` seam (drop-in, callers untouched) — holds keys for `ttlMs` (5m), reloads on the next lookup past TTL, and on a `kid` miss reloads **once more** (rotation-on-miss → a freshly-prepended key verifies without a restart, README zero-downtime rotation), throttled by `minRefetchMs` (60s) so a stream of bogus kids can't hammer the source. A reload failure keeps the last-good set (transient resilience); only a cold cache propagates the error (→ middleware fails closed). Concurrent loads coalesce on one in-flight promise. `createJwksProvider(jwksUrl)` routes by scheme + primes at boot (fail loud): `base64://` → immutable `staticJwks`; `file://` → re-readable cache (rotation by remount/edit); `http(s)://` → new `fetchJwks` (Accept JSON, non-2xx throws). `server.ts` now `await createJwksProvider(config.jwksUrl)` (top-level await already present) — replaces `staticJwks(loadJwks(...))`. Tests-first (`jwks.test.ts`: TTL cache+expiry, rotation-on-miss + throttle, last-good-on-error vs cold-load-propagates, scheme routing + http prime/cache + fail-loud on non-2xx/missing-file/bad-scheme). README **Layout** line updated; the **JWT signing key & rotation** + flow-diagram cache notes already described this. typecheck + 203 units green; boot-smoked the file:// prime path. Guards/re-mint/logout/CSRF are the next §4 items. -- [ ] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto). +- [x] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto). → `src/guards.ts`: in-handler authorization (imperative counterpart to the §2 declarative route `permission` gate; the JWT was already verified once by the §4 middleware → `ctx.user`/`ctx.roles`, so these never call Ory for the coarse tiers). `requireSession(ctx)` asserts a session → returns the `User`, else throws `GuardError(401, location:/login)`; `can(ctx, role)` is the coarse zero-I/O JWT-claim predicate (anonymous ⇒ false); `check(keto, ctx, {namespace, object, relation})` is the one live Keto call (fine-grained relationship tier, README) — subject = `user:`, anonymous ⇒ false fail-closed (no call). New `GuardError {status, location?}`; `app.ts`'s request catch maps it (location ⇒ 303 redirect, else render the 403 page) **before** the 500 path, so a guard thrown anywhere in handling becomes the right response, never a 500. Tests-first: `guards.test.ts` (requireSession return/throw, `can` matrix, `check` subject + fail-closed) + an `app.test.ts` HTTP integration (anonymous → `/login`, `can`/`check` pass → 200 / fail → 403). README **Building blocks** + `docs/plugin-contract.md` Routes document them (dropped the "land with §4" marker). typecheck + 207 units green. Session re-mint / logout / CSRF are the next §4 items. - [ ] Session re-mint on TTL expiry (re-read roles from Keto). - [ ] Logout: revoke Kratos session + clear cookie. - [ ] Secure cookie flags; CSRF for our own POST forms.