Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/managed-scaffold-real-jwks-kid.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 55 additions & 13 deletions src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, any> = {}) {
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/<infraId>`, 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<string, any>) {
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",
Expand Down Expand Up @@ -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();
});
});
49 changes: 38 additions & 11 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
}

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

Expand All @@ -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
Expand All @@ -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<string> {
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.",
Expand Down
94 changes: 94 additions & 0 deletions src/core/jwksKid.test.ts
Original file line number Diff line number Diff line change
@@ -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("<html>502</html>", { 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();
});
});
42 changes: 42 additions & 0 deletions src/core/jwksKid.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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<string, unknown>;
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;
}
Loading