JWT session middleware (todo §4); authenticate(): verify the session cookie via cached JWKS (key by kid) → exp/nbf/iss/aud claims (clock skew) → ctx.user/roles; iss/aud opt-in; fail-closed
This commit is contained in:
@@ -149,6 +149,7 @@ auto-merged by `docker compose up`) turns them back off for live editing.
|
||||
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
||||
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write |
|
||||
| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT (§4) |
|
||||
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
|
||||
| `COOKIE_SECRET` / `CSRF_SECRET` | dev throwaways | enforced by `REQUIRE_SECURE_SECRETS` |
|
||||
|
||||
### What you must supply (the only manual prep)
|
||||
@@ -499,7 +500,9 @@ mid-response, so container restarts are clean.
|
||||
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
|
||||
src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes, §4)
|
||||
src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
|
||||
src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4
|
||||
src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
|
||||
src/jwt-middleware.ts authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles (§4)
|
||||
src/jwks.ts JwksProvider — loadJwks() (file://, base64://) + staticJwks(): resolve the verify key by kid; HTTP fetch/cache/rotation is the next §4 item
|
||||
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, whoami, session→JWT tokenize (§4)
|
||||
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
|
||||
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -6,9 +7,11 @@ 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 { staticJwks } from "./jwks.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "./kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "./login.ts";
|
||||
import type { Plugin } from "./plugin.ts";
|
||||
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
||||
|
||||
@@ -179,6 +182,33 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
|
||||
assert.equal((await fetch(url + "/demo/nope")).status, 404);
|
||||
});
|
||||
|
||||
// JWT middleware (§4): a verified session cookie populates ctx.user/roles, which the gate reads.
|
||||
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
||||
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
||||
function mintJwt(payload: Record<string, unknown>): string {
|
||||
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
|
||||
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
|
||||
}
|
||||
|
||||
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → 403", async (t) => {
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||
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 secret = (cookie?: string) => fetch(url + "/demo/secret", cookie ? { headers: { cookie } } : {});
|
||||
|
||||
// Token carrying the gating role → the handler runs (200).
|
||||
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" })}`);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(await ok.text(), "secret");
|
||||
|
||||
// No cookie and an expired token both render anonymous → the gate denies (403).
|
||||
assert.equal((await secret()).status, 403);
|
||||
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).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 = {
|
||||
|
||||
20
src/app.ts
20
src/app.ts
@@ -7,6 +7,8 @@ import { buildDashboardModel } from "./dashboard.ts";
|
||||
import { PLUGINS_DIR } from "./discovery.ts";
|
||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
||||
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
|
||||
import type { JwksProvider } from "./jwks.ts";
|
||||
import { authenticate, type VerifyOptions } from "./jwt-middleware.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||
import { KratosError, type KratosPublic } from "./kratos-public.ts";
|
||||
@@ -20,9 +22,11 @@ import { renderPluginView } from "./view-resolver.ts";
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export interface AppOptions {
|
||||
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||
// Off by default so edits show live; the app itself never inspects the environment.
|
||||
cache?: boolean;
|
||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
|
||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
|
||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4)
|
||||
@@ -34,7 +38,9 @@ export interface AppOptions {
|
||||
}
|
||||
|
||||
export function createApp(options: AppOptions = {}): Server {
|
||||
const authOptions = options.auth ?? {};
|
||||
const cache = options.cache ?? false;
|
||||
const jwks = options.jwks;
|
||||
const keto = options.keto;
|
||||
const kratos = options.kratos;
|
||||
const kratosAdmin = options.kratosAdmin;
|
||||
@@ -63,16 +69,20 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return createServer(async (req, res) => {
|
||||
try {
|
||||
const method = req.method ?? "GET";
|
||||
const ctx = buildContext(req, res); // base context (no route params yet); reused for onRequest
|
||||
const pathname = ctx.url.pathname;
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
|
||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||
// /public/<id>/… serves a plugin's public/; everything else the core public/.
|
||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||
await serveStatic(dir, subPath, res, method === "HEAD");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
|
||||
const user = jwks ? await authenticate(req.headers.cookie, jwks, authOptions) : null;
|
||||
const ctx = buildContext(req, res, { user }); // base context (no route params yet); reused for onRequest
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
const short = await runRequestHooks(plugins, ctx);
|
||||
@@ -85,7 +95,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// Plugin routes (any method): gate on the route's permission, then run the handler.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = buildContext(req, res, { params: match.params });
|
||||
const routeCtx = buildContext(req, res, { params: match.params, user });
|
||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||
return;
|
||||
@@ -134,8 +144,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
|
||||
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
||||
// Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts.
|
||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, [], menu) }));
|
||||
// Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts.
|
||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu) }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,15 @@ test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an ht
|
||||
assert.match(url.pathname, /tokenizer\/jwks\.json$/);
|
||||
});
|
||||
|
||||
test("JWT issuer/audience are optional: unset by default, pinned from the env", () => {
|
||||
const def = loadConfig({});
|
||||
assert.equal(def.jwtIssuer, undefined);
|
||||
assert.equal(def.jwtAudience, undefined);
|
||||
const c = loadConfig({ JWT_AUDIENCE: "plainpages", JWT_ISSUER: "https://id.example.com" });
|
||||
assert.equal(c.jwtIssuer, "https://id.example.com");
|
||||
assert.equal(c.jwtAudience, "plainpages");
|
||||
});
|
||||
|
||||
test("parses explicit boolean toggles and rejects non-boolean values", () => {
|
||||
assert.equal(loadConfig({ CACHE_TEMPLATES: "true" }).cacheTemplates, true);
|
||||
assert.equal(loadConfig({ CACHE_TEMPLATES: "false" }).cacheTemplates, false);
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface Config {
|
||||
cookieSecret: string;
|
||||
csrfSecret: string;
|
||||
jwksUrl: string;
|
||||
jwtAudience: string | undefined;
|
||||
jwtIssuer: string | undefined;
|
||||
ketoReadUrl: string;
|
||||
ketoWriteUrl: string;
|
||||
kratosAdminUrl: string;
|
||||
@@ -41,6 +43,12 @@ function readBool(env: Env, key: string, devDefault: boolean): boolean {
|
||||
throw new Error(`config: ${key} must be "true" or "false", got "${value}"`);
|
||||
}
|
||||
|
||||
// An optional pinned value: present only when set non-empty. Unset ⇒ the matching claim
|
||||
// check is skipped (clean clone — the dev tokenizer sets no iss/aud; §4 verifier).
|
||||
function readOptional(env: Env, key: string): string | undefined {
|
||||
return env[key] || undefined;
|
||||
}
|
||||
|
||||
// An absolute URL: defaults to the Ory service; validated so a typo fails at boot.
|
||||
function readUrl(env: Env, key: string, devDefault: string): string {
|
||||
const value = env[key] ?? devDefault;
|
||||
@@ -72,6 +80,9 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
// Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS
|
||||
// mounted into web (compose.yml). Prod overrides with a real key (README: rotation).
|
||||
jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"),
|
||||
// Optional, off by default: pin the session-JWT issuer/audience for a hardened deploy.
|
||||
jwtAudience: readOptional(env, "JWT_AUDIENCE"),
|
||||
jwtIssuer: readOptional(env, "JWT_ISSUER"),
|
||||
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
|
||||
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
|
||||
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
|
||||
|
||||
29
src/jwks.test.ts
Normal file
29
src/jwks.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync, type JsonWebKey } from "node:crypto";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { loadJwks, staticJwks } from "./jwks.ts";
|
||||
|
||||
const jwk = (kid: string): JsonWebKey => ({ ...(generateKeyPairSync("ec", { namedCurve: "P-256" }).publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid });
|
||||
const committed = join(dirname(fileURLToPath(import.meta.url)), "..", "ory/kratos/tokenizer/jwks.json");
|
||||
|
||||
test("staticJwks selects by kid, falls back to the sole key when none, misses cleanly", async () => {
|
||||
const [a, b] = [jwk("k1"), jwk("k2")];
|
||||
const set = staticJwks([a, b]);
|
||||
assert.equal(await set.getKey("k2"), b);
|
||||
assert.equal(await set.getKey("nope"), null);
|
||||
assert.equal(await set.getKey(undefined), null); // ambiguous with >1 key
|
||||
assert.equal(await staticJwks([a]).getKey(undefined), a); // single-key dev default
|
||||
});
|
||||
|
||||
test("loadJwks reads a file:// set and a base64:// inline set, rejects http", () => {
|
||||
// The committed dev tokenizer key.
|
||||
const fromFile = loadJwks(pathToFileURL(committed).href);
|
||||
assert.equal(fromFile[0]?.kid, "42634591-3e04-49d5-a818-284d7021a85f");
|
||||
|
||||
const inline = JSON.stringify({ keys: [jwk("inline")] });
|
||||
assert.equal(loadJwks(`base64://${Buffer.from(inline).toString("base64")}`)[0]?.kid, "inline");
|
||||
|
||||
assert.throws(() => loadJwks("http://keto:4466/keys"), /unsupported/);
|
||||
});
|
||||
37
src/jwks.ts
Normal file
37
src/jwks.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { JsonWebKey } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// JWKS provider: resolve the JWT verify key by the JWS `kid` (todo §4). The middleware
|
||||
// calls `getKey` per request. `staticJwks` holds a fixed set loaded once at boot from the
|
||||
// mounted/dev key; HTTP fetch + TTL refresh + rotation-on-miss is the next §4 item.
|
||||
export interface JwksProvider {
|
||||
getKey(kid: string | undefined): Promise<JsonWebKey | null>;
|
||||
}
|
||||
|
||||
function parseJwks(text: string): JsonWebKey[] {
|
||||
const parsed = JSON.parse(text) as { keys?: unknown };
|
||||
if (!Array.isArray(parsed.keys)) throw new Error("JWKS: missing `keys` array");
|
||||
return parsed.keys as JsonWebKey[];
|
||||
}
|
||||
|
||||
// Load a JWKS from the configured location: `file://` reads the mounted tokenizer key (the
|
||||
// dev default + prod mount), `base64://` decodes an inline set (README rotation). `http(s)://`
|
||||
// is the rotating-cache's job (next §4 item) — fail loud rather than silently no-fetch.
|
||||
export function loadJwks(jwksUrl: string): JsonWebKey[] {
|
||||
if (jwksUrl.startsWith("base64://")) return parseJwks(Buffer.from(jwksUrl.slice("base64://".length), "base64").toString("utf8"));
|
||||
const url = new URL(jwksUrl);
|
||||
if (url.protocol === "file:") return parseJwks(readFileSync(fileURLToPath(url), "utf8"));
|
||||
throw new Error(`loadJwks: unsupported JWKS URL scheme (HTTP fetch lands with the §4 JWKS cache): ${jwksUrl}`);
|
||||
}
|
||||
|
||||
// A fixed in-memory key set. Pick by `kid`; with no `kid` fall back to the sole key (single-
|
||||
// key dev default). Async so the §4 cache can drop in refetch-on-miss without touching callers.
|
||||
export function staticJwks(keys: JsonWebKey[]): JwksProvider {
|
||||
return {
|
||||
getKey: async (kid) => {
|
||||
if (kid === undefined) return keys.length === 1 ? keys[0]! : null;
|
||||
return keys.find((k) => k.kid === kid) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
74
src/jwt-middleware.test.ts
Normal file
74
src/jwt-middleware.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import { staticJwks } from "./jwks.ts";
|
||||
import { authenticate, claimsToUser, verifyToken } from "./jwt-middleware.ts";
|
||||
import { SESSION_COOKIE } from "./login.ts";
|
||||
|
||||
const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url");
|
||||
|
||||
// Mint an ES256 session JWT the way the Kratos tokenizer would (kid in the header).
|
||||
function mint(privateKey: KeyObject, kid: string, payload: Record<string, unknown>): string {
|
||||
const head = b64url(JSON.stringify({ alg: "ES256", kid, typ: "JWT" }));
|
||||
const body = b64url(JSON.stringify(payload));
|
||||
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { key: privateKey, dsaEncoding: "ieee-p1363" });
|
||||
return `${head}.${body}.${b64url(sig)}`;
|
||||
}
|
||||
|
||||
const k1 = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const k2 = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const jwk1: JsonWebKey = { ...(k1.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "k1" };
|
||||
const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "k2" };
|
||||
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
|
||||
|
||||
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
|
||||
const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" };
|
||||
|
||||
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
|
||||
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
|
||||
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
|
||||
});
|
||||
|
||||
test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||
const opts = { clockSkewSec: 60, now: NOW };
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
|
||||
// exp 30s in the past but inside the 60s skew → still accepted.
|
||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, nbf: NOW + 120 }), jwks, opts), /not yet valid/);
|
||||
});
|
||||
|
||||
test("verifyToken checks issuer/audience only when configured", async () => {
|
||||
const tok = (extra: Record<string, unknown>) => mint(k1.privateKey, "k1", { ...valid, ...extra });
|
||||
// No iss/aud in the token and none expected (the dev tokenizer sets neither) → fine.
|
||||
await verifyToken(tok({}), jwks, { now: NOW });
|
||||
// Issuer pinned: must match; absent or wrong → reject.
|
||||
await verifyToken(tok({ iss: "https://id" }), jwks, { issuer: "https://id", now: NOW });
|
||||
await assert.rejects(verifyToken(tok({}), jwks, { issuer: "https://id", now: NOW }), /issuer/);
|
||||
await assert.rejects(verifyToken(tok({ iss: "other" }), jwks, { issuer: "https://id", now: NOW }), /issuer/);
|
||||
// Audience pinned: matches a string or an array membership; mismatch → reject.
|
||||
await verifyToken(tok({ aud: "pp" }), jwks, { audience: "pp", now: NOW });
|
||||
await verifyToken(tok({ aud: ["x", "pp"] }), jwks, { audience: "pp", now: NOW });
|
||||
await assert.rejects(verifyToken(tok({ aud: "x" }), jwks, { audience: "pp", now: NOW }), /audience/);
|
||||
});
|
||||
|
||||
test("verifyToken rejects a bad signature and an unknown kid", async () => {
|
||||
// Signed with k1 but the header claims kid k2 → wrong verify key → bad signature.
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k2", valid), jwks, { now: NOW }), /invalid signature/);
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
|
||||
});
|
||||
|
||||
test("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => {
|
||||
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
|
||||
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
|
||||
assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
|
||||
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
||||
});
|
||||
|
||||
test("authenticate: a valid cookie → User; no cookie / invalid / expired → null (fail-closed)", async () => {
|
||||
const cookie = `${SESSION_COOKIE}=${mint(k1.privateKey, "k1", valid)}`;
|
||||
assert.deepEqual(await authenticate(cookie, jwks, { now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] });
|
||||
assert.equal(await authenticate(undefined, jwks, { now: NOW }), null);
|
||||
assert.equal(await authenticate("other=1", jwks, { now: NOW }), null);
|
||||
assert.equal(await authenticate(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), null);
|
||||
assert.equal(await authenticate(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, exp: NOW - 999 })}`, jwks, { now: NOW }), null);
|
||||
});
|
||||
84
src/jwt-middleware.ts
Normal file
84
src/jwt-middleware.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// JWT session middleware (todo §4): verify our session cookie in-process on every request —
|
||||
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
|
||||
// check the signature (src/jwt.ts), validate the time/issuer/audience claims, project the
|
||||
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
|
||||
// (anonymous), so the route renders signed-out and the permission gate denies.
|
||||
import type { User } from "./context.ts";
|
||||
import { parseCookies } from "./cookie.ts";
|
||||
import { decodeJws, verifyJws } from "./jwt.ts";
|
||||
import type { JwksProvider } from "./jwks.ts";
|
||||
import { SESSION_COOKIE } from "./login.ts";
|
||||
|
||||
// Leeway on exp/nbf for small clock drift between Kratos and web.
|
||||
const DEFAULT_CLOCK_SKEW_SEC = 60;
|
||||
|
||||
export interface VerifyOptions {
|
||||
audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
|
||||
clockSkewSec?: number | undefined;
|
||||
issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped)
|
||||
now?: number | undefined; // unix seconds; injectable for tests
|
||||
}
|
||||
|
||||
// A rejected token (bad signature, expired, wrong iss/aud, malformed claims). `authenticate`
|
||||
// swallows it to anonymous; a caller wanting the reason can catch it.
|
||||
export class TokenError extends Error {}
|
||||
|
||||
function num(payload: Record<string, unknown>, claim: string): number | undefined {
|
||||
const v = payload[claim];
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
// Validate the time/issuer/audience claims of an already signature-verified payload.
|
||||
export function validateClaims(payload: Record<string, unknown>, options: VerifyOptions = {}): void {
|
||||
const skew = options.clockSkewSec ?? DEFAULT_CLOCK_SKEW_SEC;
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
|
||||
const exp = num(payload, "exp");
|
||||
if (exp === undefined) throw new TokenError("token missing exp");
|
||||
if (now > exp + skew) throw new TokenError("token expired");
|
||||
|
||||
const nbf = num(payload, "nbf");
|
||||
if (nbf !== undefined && now < nbf - skew) throw new TokenError("token not yet valid");
|
||||
|
||||
if (options.issuer !== undefined && payload["iss"] !== options.issuer) throw new TokenError("token issuer mismatch");
|
||||
|
||||
if (options.audience !== undefined) {
|
||||
const aud = payload["aud"];
|
||||
const ok = typeof aud === "string" ? aud === options.audience : Array.isArray(aud) && aud.includes(options.audience);
|
||||
if (!ok) throw new TokenError("token audience mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
// Map verified claims → the request User. sub/email are required (the tokenizer always sets
|
||||
// them); roles defaults to [] and keeps only string entries (defensive).
|
||||
export function claimsToUser(payload: Record<string, unknown>): User {
|
||||
const sub = payload["sub"];
|
||||
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
|
||||
const email = payload["email"];
|
||||
if (typeof email !== "string") throw new TokenError("token missing email");
|
||||
const roles = payload["roles"];
|
||||
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
|
||||
}
|
||||
|
||||
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
|
||||
// claims, project the User. Throws TokenError / the underlying verify error on any failure.
|
||||
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User> {
|
||||
const { header } = decodeJws(token); // unverified — only to read `kid` for key selection
|
||||
const jwk = await jwks.getKey(header.kid);
|
||||
if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`);
|
||||
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
|
||||
validateClaims(verified.payload, options);
|
||||
return claimsToUser(verified.payload);
|
||||
}
|
||||
|
||||
// The request middleware: read our session cookie, verify it → the User, or null for no
|
||||
// cookie / any invalid token (fail-closed; the route then renders anonymous and gates deny).
|
||||
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User | null> {
|
||||
const token = parseCookies(cookieHeader)[SESSION_COOKIE];
|
||||
if (!token) return null;
|
||||
try {
|
||||
return await verifyToken(token, jwks, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createApp } from "./app.ts";
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { discoverPlugins } from "./discovery.ts";
|
||||
import { runBootHooks } from "./hooks.ts";
|
||||
import { loadJwks, staticJwks } from "./jwks.ts";
|
||||
import { createKetoClient } from "./keto-client.ts";
|
||||
import { createKratosAdmin } from "./kratos-admin.ts";
|
||||
import { createKratosPublic } from "./kratos-public.ts";
|
||||
@@ -13,12 +14,24 @@ const menu = await loadMenuConfig(); // config/menu.ts override + branding — f
|
||||
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl });
|
||||
const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl });
|
||||
const keto = createKetoClient({ readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl });
|
||||
// Session-JWT verify key, loaded once from the mounted tokenizer JWKS (§4). HTTP fetch +
|
||||
// TTL refresh + rotation-on-miss replace this static set in the next §4 item.
|
||||
const jwks = staticJwks(loadJwks(config.jwksUrl));
|
||||
|
||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
||||
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
|
||||
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
|
||||
|
||||
const server = createApp({ cache: config.cacheTemplates, keto, kratos, kratosAdmin, menu, plugins }).listen(config.port, () => {
|
||||
const server = createApp({
|
||||
auth: { audience: config.jwtAudience, issuer: config.jwtIssuer },
|
||||
cache: config.cacheTemplates,
|
||||
jwks,
|
||||
keto,
|
||||
kratos,
|
||||
kratosAdmin,
|
||||
menu,
|
||||
plugins,
|
||||
}).listen(config.port, () => {
|
||||
console.log(`Listening on http://localhost:${config.port}`);
|
||||
});
|
||||
|
||||
|
||||
2
todo.md
2
todo.md
@@ -81,7 +81,7 @@ everything via Docker.
|
||||
- [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. → `src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=<id>`; `?flow=<id>` ⇒ `getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8.
|
||||
- [x] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only. → `flow-view.ts` now collects the login/registration flow's `oidc`-group submit nodes into `FlowView.sso` (`{label, logo, name, value}` per provider; `logo` = provider initial, lucide ships no brand marks) instead of skipping them — so the button list *is* Kratos' live provider list (none configured ⇒ `sso: []` ⇒ no section; activate/remove a provider purely via the §3 OIDC env). `auth-card.ejs` gained a submit-provider branch: a provider with `name`/`value` renders `<button type="submit" name=… value=…>` (posts `provider=<id>` to the same Kratos form, sharing its csrf hidden input); `href` still ⇒ `<a>`, neither ⇒ inert button. `auth.ejs` forwards `sso: { providers: flow.sso }`. Removed the mockup-only `body:not(:has(#sso-toggle:checked)) .sso{display:none}` rule from `auth.css` (`#sso-toggle` is a "remove for production" preview control in `html-css-foundation/Auth.html`) — visibility is now purely server-side. Tests-first: `flow-view.test.ts` (oidc→sso matrix + `sso:[]` when none), `auth-card.test.ts` (submit-provider markup), `app.test.ts` (live `/login` renders the SSO submit button in the form). README **Social sign-in (SSO)** updated (dropped the §4 forward-ref). typecheck + 181 units green. Boot-verified end-to-end: a real Kratos with the OIDC env emitted `{group:oidc, name:provider, value:google}` → `buildFlowView` derived `[{label:"Sign in with google", logo:"G", name:"provider", value:"google"}]`; clean-clone `/login` renders no `.sso` section; torn down.
|
||||
- [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:<id>` 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.
|
||||
- [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles).
|
||||
- [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.
|
||||
- [ ] JWKS fetch + cache + rotation handling.
|
||||
- [ ] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto).
|
||||
- [ ] Session re-mint on TTL expiry (re-read roles from Keto).
|
||||
|
||||
Reference in New Issue
Block a user