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:
@@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user