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