From e1d36872714ba66457817ed6530af35db297dcbf Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 5 Sep 2026 11:40:57 -0400 Subject: [PATCH] fix(init): write the signing key id a managed instance publishes Managed scaffolds hardcoded JWKS_KID=dev-main while instances pin their kid per tier (trialkey1, paidkey1), so the value was never the instance's own. Nothing verifies against it: adapters mint service tokens with HS256 and a shared secret, where kid is a decorative header the API never reads, and user access tokens go through createRemoteJWKSet, which selects the key by the token header's kid. But the adapters warn on boot while jwksKid is the dev default, so every managed scaffold shipped an app reporting itself misconfigured. init now reads the kid from /.well-known/jwks.json. An unreachable instance keeps the old default with a note explaining that only the warning is affected, rather than failing the scaffold over a cosmetic value. Closes #124 Closes #122 --- .changeset/managed-scaffold-real-jwks-kid.md | 15 ++++ src/commands/init.test.ts | 68 +++++++++++--- src/commands/init.ts | 49 +++++++--- src/core/jwksKid.test.ts | 94 ++++++++++++++++++++ src/core/jwksKid.ts | 42 +++++++++ 5 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 .changeset/managed-scaffold-real-jwks-kid.md create mode 100644 src/core/jwksKid.test.ts create mode 100644 src/core/jwksKid.ts diff --git a/.changeset/managed-scaffold-real-jwks-kid.md b/.changeset/managed-scaffold-real-jwks-kid.md new file mode 100644 index 0000000..ad1c58e --- /dev/null +++ b/.changeset/managed-scaffold-real-jwks-kid.md @@ -0,0 +1,15 @@ +--- +'seamless-cli': patch +--- + +Write the signing key id a managed instance actually publishes. + +A managed scaffold hardcoded `JWKS_KID=dev-main`. Managed instances pin their kid per tier +(`trialkey1` for trials, `paidkey1` for paid), so the value was never the instance's own. +Nothing verifies against it, adapters resolve the key from the token header through the +remote JWKS, but they do warn on boot while it is the dev default, so every managed +scaffold produced an app that reported itself misconfigured. + +`init` now reads the kid from the instance's `/.well-known/jwks.json`. If the instance +cannot be reached it keeps the old default, says so, and explains that nothing breaks +except the warning. diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 7997f7e..7e741e6 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -33,6 +33,7 @@ import { confirmLocalFallback, } from "../prompts/initMode.js"; import { parseEnv, writeEnv } from "../core/env.js"; +import { fetchActiveJwksKid } from "../core/jwksKid.js"; import { CancelledError } from "../core/cancel.js"; import { runCLI } from "./init.js"; @@ -126,6 +127,9 @@ vi.mock("../core/env.js", () => ({ vi.mock("../core/secrets.js", () => ({ generateSecret: vi.fn(() => "generated-secret"), })); +vi.mock("../core/jwksKid.js", () => ({ + fetchActiveJwksKid: vi.fn(async () => undefined), +})); const CWD = "/work"; @@ -1624,23 +1628,25 @@ describe("the admin console source mode", () => { }); }); +// A managed run against one application. `over` patches the app record the portal +// returns, so a test can say what the control plane reported for it. +function managedRun(over: Record = {}) { + vi.mocked(createPortalClient).mockResolvedValue({} as never); + vi.mocked(listApplications).mockResolvedValue([app(over)] as never); + vi.mocked(selectApplication).mockResolvedValue(app(over) as never); + vi.mocked(rotateServiceToken).mockResolvedValue("svc-token" as never); + vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); + vi.mocked(runManagedTemplatePrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + } as never); + return runCLI(undefined, [], { appId: "app-1" }); +} + // The portal serves mvp and business instances at `domain/`, so `domain` is a // stored column that goes stale when a trial is upgraded and its tenant moves zones. // The scaffold has to point at the server-computed instanceUrl instead. describe("managed scaffold instance URL", () => { - function managedRun(over: Record) { - vi.mocked(createPortalClient).mockResolvedValue({} as never); - vi.mocked(listApplications).mockResolvedValue([app(over)] as never); - vi.mocked(selectApplication).mockResolvedValue(app(over) as never); - vi.mocked(rotateServiceToken).mockResolvedValue("svc-token" as never); - vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); - vi.mocked(runManagedTemplatePrompts).mockResolvedValue({ - webTemplateId: "web-basic", - apiTemplateId: "api-express", - } as never); - return runCLI(undefined, [], { appId: "app-1" }); - } - it("prefers instanceUrl over the stale domain column", async () => { await managedRun({ instanceUrl: "https://zone-b.example.com/inf-42", @@ -1682,3 +1688,39 @@ describe("managed scaffold instance URL", () => { ); }); }); + +// Instances pin their signing kid per tier (trialkey1, paidkey1). The scaffold used +// to write the dev default regardless, so every managed app booted warning that it +// was misconfigured. +describe("managed scaffold JWKS kid", () => { + it("writes the kid the instance is publishing", async () => { + vi.mocked(fetchActiveJwksKid).mockResolvedValue("paidkey1"); + + await managedRun(); + + expect(fetchActiveJwksKid).toHaveBeenCalledWith("norm:https://acme.example.com"); + expect(applyTemplateEnv).toHaveBeenCalledWith( + "/work/api", + expect.anything(), + expect.objectContaining({ jwksKid: "paidkey1" }), + ); + }); + + it("falls back to the dev default and says so when the instance cannot be read", async () => { + vi.mocked(fetchActiveJwksKid).mockResolvedValue(undefined); + + await managedRun(); + + expect(applyTemplateEnv).toHaveBeenCalledWith( + "/work/api", + expect.anything(), + expect.objectContaining({ jwksKid: "dev-main" }), + ); + expect(out()).toContain("Could not read the signing key id"); + }); + + it("does not fail the scaffold when the kid cannot be read", async () => { + vi.mocked(fetchActiveJwksKid).mockResolvedValue(undefined); + await expect(managedRun()).resolves.not.toThrow(); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index c6ab0d6..123f4b6 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -49,6 +49,7 @@ import { getPortalSession, normalizeInstanceUrl } from "../core/config.js"; import { parseEnv, writeEnv } from "../core/env.js"; import { generateAdminSource } from "../generators/admin/admin.js"; import { generateSecret } from "../core/secrets.js"; +import { fetchActiveJwksKid } from "../core/jwksKid.js"; import { buildScaffoldDatabaseUrl, getApplicationDatabase, @@ -62,10 +63,10 @@ import { selectApplication } from "../prompts/appSelect.js"; const AUTH_SERVER_URL = "http://localhost:5312"; const API_URL = "http://localhost:3000"; -// Managed auth instances resolve signing keys from the token header and do not -// expose a per-application JWKS kid, so the scaffolded backend uses the SDK's -// default. This matches the portal's own "Get connected" guidance. -const MANAGED_JWKS_KID = "dev-main"; +// Only reached when the instance cannot be asked. Adapters resolve the signing key +// from the token header through the remote JWKS, so this value verifies nothing, but +// they warn on boot while it is the dev default, so the real kid is worth fetching. +const FALLBACK_JWKS_KID = "dev-main"; export interface InitOptions { profileFlag?: string; @@ -408,6 +409,7 @@ async function scaffoldManaged( const serviceToken = await issueServiceToken(client, app, opts); const authServerUrl = normalizeInstanceUrl(requireInstanceUrl(app)); + const jwksKid = await resolveJwksKid(authServerUrl); // Everything past rotation is guarded: if it throws, the freshly issued token is // printed so a deployed app can be re-wired rather than left bricked (the control @@ -417,7 +419,7 @@ async function scaffoldManaged( authServerUrl, apiUrl: API_URL, apiToken: serviceToken, - jwksKid: MANAGED_JWKS_KID, + jwksKid, // A managed instance hosts its own dashboard, so the app API does not proxy // the console. Keeps the template's SERVE_ADMIN_CONSOLE gate off. serveAdminConsole: "false", @@ -458,7 +460,7 @@ async function scaffoldManaged( "\nScaffolding failed after a new service token was issued. The token below is valid — set it on your backend to recover:", ), ); - printManagedValues(authServerUrl, serviceToken); + printManagedValues(authServerUrl, serviceToken, jwksKid); throw err; } } @@ -603,10 +605,11 @@ async function integrateExistingProject( const serviceToken = await issueServiceToken(client, app, opts); const authServerUrl = normalizeInstanceUrl(requireInstanceUrl(app)); + const jwksKid = await resolveJwksKid(authServerUrl); const apiDir = path.join(root, "api"); if (!fs.existsSync(apiDir)) { - printManagedValues(authServerUrl, serviceToken); + printManagedValues(authServerUrl, serviceToken, jwksKid); return; } @@ -620,7 +623,7 @@ async function integrateExistingProject( "\nFailed to write api/.env after issuing a new service token. Set it by hand to recover:", ), ); - printManagedValues(authServerUrl, serviceToken); + printManagedValues(authServerUrl, serviceToken, jwksKid); throw err; } @@ -644,7 +647,7 @@ function wireApiEnv( values.AUTH_SERVER_URL = authServerUrl; values.API_SERVICE_TOKEN = serviceToken; - values.JWKS_KID = values.JWKS_KID || MANAGED_JWKS_KID; + values.JWKS_KID = values.JWKS_KID || FALLBACK_JWKS_KID; values.COOKIE_SIGNING_KEY = values.COOKIE_SIGNING_KEY || generateSecret(32); // Never overwritten: an existing project may already hold a working @@ -657,11 +660,35 @@ function wireApiEnv( writeEnv(envPath, values); } -function printManagedValues(authServerUrl: string, serviceToken: string) { +// Asked of the instance rather than assumed: instances pin their kid per tier +// (trialkey1, paidkey1), so writing the dev default made every managed scaffold boot +// an app warning that it was misconfigured. +async function resolveJwksKid(authServerUrl: string): Promise { + const kid = await fetchActiveJwksKid(authServerUrl); + if (kid) return kid; + + console.log( + kleur.yellow( + `Could not read the signing key id from ${authServerUrl}, so JWKS_KID is set to "${FALLBACK_JWKS_KID}".`, + ), + ); + console.log( + kleur.dim( + " Nothing verifies against it, the SDK resolves the key from the token, but your adapter will warn on boot until it matches. Read it from /.well-known/jwks.json once the instance is up.", + ), + ); + return FALLBACK_JWKS_KID; +} + +function printManagedValues( + authServerUrl: string, + serviceToken: string, + jwksKid: string, +) { console.log(kleur.green("\nManaged connection values:\n")); console.log(kleur.dim(" AUTH_SERVER_URL ") + authServerUrl); console.log(kleur.dim(" API_SERVICE_TOKEN ") + serviceToken); - console.log(kleur.dim(" JWKS_KID ") + MANAGED_JWKS_KID); + console.log(kleur.dim(" JWKS_KID ") + jwksKid); console.log( kleur.yellow( "\nCopy the service token now. The control plane will not show it again.", diff --git a/src/core/jwksKid.test.ts b/src/core/jwksKid.test.ts new file mode 100644 index 0000000..cd8a943 --- /dev/null +++ b/src/core/jwksKid.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchActiveJwksKid } from "./jwksKid.js"; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchActiveJwksKid", () => { + it("reads the kid from the instance's JWKS", async () => { + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calls.push(url); + return json({ keys: [{ kid: "paidkey1", alg: "RS256", use: "sig" }] }); + }), + ); + + expect(await fetchActiveJwksKid("https://auth.example.com")).toBe("paidkey1"); + expect(calls).toEqual([ + "https://auth.example.com/.well-known/jwks.json", + ]); + }); + + // A set carrying a retired key alongside the active one lists the active first. + it("takes the first signing key when several are published", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + json({ + keys: [ + { kid: "trialkey1", alg: "RS256", use: "sig" }, + { kid: "oldkey", alg: "RS256", use: "sig" }, + ], + }), + ), + ); + + expect(await fetchActiveJwksKid("https://auth.example.com")).toBe("trialkey1"); + }); + + it("skips a key that is not for signing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + json({ + keys: [ + { kid: "enckey", alg: "RSA-OAEP", use: "enc" }, + { kid: "signing", alg: "RS256", use: "sig" }, + ], + }), + ), + ); + + expect(await fetchActiveJwksKid("https://auth.example.com")).toBe("signing"); + }); + + it("accepts a key that omits the optional use and alg hints", async () => { + vi.stubGlobal("fetch", vi.fn(async () => json({ keys: [{ kid: "bare" }] }))); + expect(await fetchActiveJwksKid("https://auth.example.com")).toBe("bare"); + }); + + // The kid is cosmetic today, so an instance that is slow to come up must not + // fail the scaffold over it. + it.each([ + ["a non-ok response", () => json({ error: "nope" }, 503)], + ["a body with no keys", () => json({})], + ["an empty key set", () => json({ keys: [] })], + ["keys that is not an array", () => json({ keys: "nope" })], + ["keys with no usable kid", () => json({ keys: [{ alg: "RS256" }, null, 42] })], + ["a non-JSON body", () => new Response("502", { status: 200 })], + ])("returns undefined for %s", async (_label, responder) => { + vi.stubGlobal("fetch", vi.fn(async () => responder())); + expect(await fetchActiveJwksKid("https://auth.example.com")).toBeUndefined(); + }); + + it("returns undefined when the instance is unreachable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("fetch failed"); + }), + ); + + expect(await fetchActiveJwksKid("https://auth.example.com")).toBeUndefined(); + }); +}); diff --git a/src/core/jwksKid.ts b/src/core/jwksKid.ts new file mode 100644 index 0000000..c0f73ab --- /dev/null +++ b/src/core/jwksKid.ts @@ -0,0 +1,42 @@ +import { apiRequest, joinUrl } from "./http.js"; + +/** + * The signing key id a managed instance is actually publishing. + * + * Instances pin their kid per tier (`trialkey1`, `paidkey1`), so a scaffold that + * hardcodes the dev default writes a value the instance never uses. Nothing verifies + * against it, adapters resolve the key from the token header's `kid` through the + * remote JWKS, but they also warn on boot when it is left at the dev default, so a + * managed scaffold otherwise ships an app that complains it is misconfigured. + * + * Returns undefined rather than throwing: the kid is cosmetic today, and an instance + * that is slow to come up should not fail a scaffold over it. + */ +export async function fetchActiveJwksKid( + instanceUrl: string, +): Promise { + let res; + try { + res = await apiRequest<{ keys?: unknown }>( + joinUrl(instanceUrl, "/.well-known/jwks.json"), + { method: "GET" }, + ); + } catch { + return undefined; + } + + if (!res.ok || !Array.isArray(res.data?.keys)) return undefined; + + // Take the first RS256 signing key. The endpoint lists the active key first, and + // a set carrying a retired key alongside it keeps that one later in the array. + for (const key of res.data.keys) { + if (!key || typeof key !== "object") continue; + const record = key as Record; + if (typeof record.kid !== "string" || !record.kid) continue; + if (record.use !== undefined && record.use !== "sig") continue; + if (record.alg !== undefined && record.alg !== "RS256") continue; + return record.kid; + } + + return undefined; +}