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:
2026-06-18 09:53:37 +02:00
parent 38157605d0
commit c8b56b85eb
11 changed files with 308 additions and 8 deletions

View File

@@ -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 = {

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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
View 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
View 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;
},
};
}

View 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
View 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;
}
}

View File

@@ -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}`);
});