Skip to content
Open
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
196 changes: 196 additions & 0 deletions packages/cli/src/lib/host-jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";

// The minted-JWT cache is module-level and shared across tests in this file;
// reset it by importing fresh in each test is not enough (Bun caches modules),
// so we drive assertions via fetch call counts instead of the cache internals.
// Every test uses a DISTINCT fake key so a JWT cached by an earlier test can't
// short-circuit the fetch this test is asserting on.
mock.module("./config", () => ({
getApiUrl: () => "https://api.example.com",
}));

const { getHostJwt } = await import("./host-jwt");

// Fake, obviously-non-secret API keys used only to exercise the exchange path.
// The `sk_live_` prefix is assembled from separate literals so secret scanners
// (Betterleaks) don't flag these test fixtures as real Stripe access tokens —
// the runtime value is unchanged.
const SK_LIVE = ["sk", "live"].join("_") + "_";
const LIVE_API_KEY = SK_LIVE + "4f9e3a2b1c";
const TEST_API_KEY = "sk_test_xyz";
const CACHE_KEY = SK_LIVE + "cache";
const SHARE_KEY_A = SK_LIVE + "key_a";
const SHARE_KEY_B = SK_LIVE + "key_b";
const FAIL_KEY = SK_LIVE + "fail";
const NO_TOKEN_KEY = SK_LIVE + "no_token";
const TYPED_KEY = SK_LIVE + "typed";
const WHITESPACE_KEY = SK_LIVE + "whitespace";
const ABORT_KEY = SK_LIVE + "abort";
const BOUNDARY_KEY = SK_LIVE + "boundary";

const realFetch = globalThis.fetch;
let fetchCalls: Array<{ url: string; init?: RequestInit }> = [];

beforeEach(() => {
fetchCalls = [];
});

afterEach(() => {
globalThis.fetch = realFetch;
});

function stubFetch(ok: boolean, body: unknown = { token: "minted-jwt" }): void {
globalThis.fetch = (async (url: string, init?: RequestInit) => {
fetchCalls.push({ url, init });
return {
ok,
status: ok ? 200 : 401,
json: async () => body,
} as Response;
}) as typeof fetch;
}

function apiKeyHeaderOf(url: string): string | undefined {
const call = fetchCalls.find((c) => c.url === url);
const headers = call?.init?.headers as Record<string, string> | undefined;
return headers?.["x-api-key"];
}

describe("getHostJwt", () => {
it("passes an OAuth JWT through without an exchange", async () => {
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.sig";
const result = await getHostJwt(jwt);
expect(result).toBe(jwt);
expect(fetchCalls).toHaveLength(0);
});

it("exchanges an sk_live_ API key for a JWT via x-api-key", async () => {
stubFetch(true);
const result = await getHostJwt(LIVE_API_KEY);
expect(result).toBe("minted-jwt");
expect(fetchCalls).toHaveLength(1);
const url = fetchCalls[0]!.url;
expect(url).toBe("https://api.example.com/api/auth/token");
expect(apiKeyHeaderOf(url)).toBe(LIVE_API_KEY);
});

it("exchanges an sk_test_ API key the same way", async () => {
stubFetch(true);
const result = await getHostJwt(TEST_API_KEY);
expect(result).toBe("minted-jwt");
expect(fetchCalls).toHaveLength(1);
const url = fetchCalls[0]!.url;
expect(url).toContain("/api/auth/token");
expect(apiKeyHeaderOf(url)).toBe(TEST_API_KEY);
});

it("caches the minted JWT per key and reuses it", async () => {
stubFetch(true);
await getHostJwt(CACHE_KEY);
await getHostJwt(CACHE_KEY);
expect(fetchCalls).toHaveLength(1);
});

it("does not share a minted JWT across different keys", async () => {
stubFetch(true);
await getHostJwt(SHARE_KEY_A);
await getHostJwt(SHARE_KEY_B);
expect(fetchCalls).toHaveLength(2);
});

it("throws when the exchange fails", async () => {
stubFetch(false);
await expect(getHostJwt(FAIL_KEY)).rejects.toThrow(
/Failed to authenticate API key/,
);
});

it("throws without caching when a 2xx response has no token", async () => {
stubFetch(true, {});
await expect(getHostJwt(NO_TOKEN_KEY)).rejects.toThrow(
/without a token value/,
);
// The bad response must not be cached: a retry hits the endpoint again.
stubFetch(true, { token: "minted-jwt" });
const result = await getHostJwt(NO_TOKEN_KEY);
expect(result).toBe("minted-jwt");
expect(fetchCalls).toHaveLength(2);
});

it("throws when the token field is not a string", async () => {
stubFetch(true, { token: 12345 });
await expect(getHostJwt(TYPED_KEY)).rejects.toThrow(
/without a token value/,
);
});

it("throws on a whitespace-only token so a malformed 2xx is not cached", async () => {
stubFetch(true, { token: " " });
await expect(getHostJwt(WHITESPACE_KEY)).rejects.toThrow(
/without a token value/,
);
// Not cached: a retry hits the endpoint again instead of reusing the
// whitespace token for up to 55 minutes.
stubFetch(true, { token: "minted-jwt" });
const result = await getHostJwt(WHITESPACE_KEY);
expect(result).toBe("minted-jwt");
expect(fetchCalls).toHaveLength(2);
});

it("passes an AbortSignal.timeout so a stalled fetch cannot hang", () => {
// Verify the exchange uses AbortSignal.timeout(TOKEN_FETCH_TIMEOUT_MS)
// so a stalled control plane aborts instead of hanging the command.
// Intercept AbortSignal.timeout to capture the configured duration —
// avoids a real 10s wall-clock wait. Use a fresh key so a cached token
// from an earlier test can't skip the fetch.
let capturedMs: number | undefined;
const originalTimeout = AbortSignal.timeout;
const timeoutSpy = (ms: number) => {
capturedMs = ms;
return originalTimeout.call(AbortSignal, ms);
};
AbortSignal.timeout = timeoutSpy as typeof AbortSignal.timeout;
try {
stubFetch(true);
// Wait for the exchange so the fetch is issued.
return getHostJwt(ABORT_KEY).then(() => {
const init = fetchCalls[0]!.init;
expect(init?.signal).toBeInstanceOf(AbortSignal);
expect(capturedMs).toBeGreaterThan(0);
expect(init?.signal).not.toBeUndefined();
});
} finally {
AbortSignal.timeout = originalTimeout;
}
});

it("reuses the cached JWT for the full 55-minute window, then re-mints", async () => {
// The cache must serve the minted token for the full JWT_CACHE_DURATION_MS
// (55 min) with no second expiry buffer subtracted, then re-mint on the
// first call past the boundary. Mock Date.now so no real-time wait is
// needed (#6346 review).
stubFetch(true);
const originalNow = Date.now;
const start = 1_000_000_000_000;
let now = start;
Date.now = () => now;
try {
await getHostJwt(BOUNDARY_KEY);
expect(fetchCalls).toHaveLength(1);

// Inside the 55-minute window: cached, no second fetch.
now = start + 54 * 60 * 1000;
const within = await getHostJwt(BOUNDARY_KEY);
expect(within).toBe("minted-jwt");
expect(fetchCalls).toHaveLength(1);

// Just past 55 minutes: expires, so the next call re-mints.
now = start + 55 * 60 * 1000 + 1;
const past = await getHostJwt(BOUNDARY_KEY);
expect(past).toBe("minted-jwt");
expect(fetchCalls).toHaveLength(2);
} finally {
Date.now = originalNow;
}
});
});
72 changes: 72 additions & 0 deletions packages/cli/src/lib/host-jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { getApiUrl } from "./config";

const JWT_CACHE_DURATION_MS = 55 * 60 * 1000;
/** Hard cap on a single token-exchange attempt so a stalled control plane
* can't hang a CLI command that targets a remote host. */
const TOKEN_FETCH_TIMEOUT_MS = 10 * 1000;

function looksLikeJwt(token: string): boolean {
const parts = token.split(".");
return parts.length === 3 && parts.every(Boolean);
}

/**
* In-process cache of minted JWTs, keyed by the credential that produced them.
* A CLI invocation normally uses a single credential, but keying by bearer is
* both more correct and safer than a single global slot (two API keys in one
* process must not share a minted JWT).
*/
const jwtCache = new Map<string, { jwt: string; expiresAt: number }>();

/**
* Mint a JWKS-signed JWT that the relay will accept for the `--host <remote>`
* path, given the CLI's raw credential.
*
* The relay authenticates host-service traffic only by verifying a JWT against
* its JWKS. An `sk_live_…` API key is not a JWT, so sending it raw in the
* `Authorization` header (as every call site of `resolveHostTarget` does) makes
* the relay return `UNAUTHORIZED` — which the CLI's generic handler then
* renders as the misleading "Session expired" (#6315).
*
* This mirrors the exchange already performed by
* `packages/host-service/.../JwtAuthProvider.getJwt()` and `packages/sdk/src/client.ts`:
* - tokens that already look like JWTs (CLI OAuth access tokens are
* JWKS-signed) pass straight through, no exchange needed;
* - `sk_live_` / `sk_test_` API keys are exchanged for a JWT via
* `GET {api}/api/auth/token` with the key in the `x-api-key` header
* (better-auth's apiKey plugin reads that header, not `Authorization`).
* The minted JWT is cached in-process for ~55 minutes.
*/
export async function getHostJwt(bearer: string): Promise<string> {
if (looksLikeJwt(bearer)) return bearer;
const cached = jwtCache.get(bearer);
if (cached && Date.now() < cached.expiresAt) {
return cached.jwt;
}
const response = await fetch(`${getApiUrl()}/api/auth/token`, {
headers: {
"x-api-key": bearer,
},
signal: AbortSignal.timeout(TOKEN_FETCH_TIMEOUT_MS),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!response.ok) {
throw new Error(
`Failed to authenticate API key with the control plane (${response.status})`,
);
}
const data = (await response.json()) as { token?: unknown };
// A 2xx without a usable token must not be cached — sending `Bearer
// undefined` (or a whitespace-only string) later would surface as a
// misleading relay auth failure. A malformed 2xx must not poison the cache
// for the full 55 minutes, so keep the retry behaviour of other failures.
if (typeof data?.token !== "string" || data.token.trim().length === 0) {
throw new Error(
"Control plane returned a token response without a token value",
);
}
jwtCache.set(bearer, {
jwt: data.token,
expiresAt: Date.now() + JWT_CACHE_DURATION_MS,
});
return data.token;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
14 changes: 11 additions & 3 deletions packages/cli/src/lib/host-target/resolveHostTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import SuperJSON from "superjson";
import type { ApiClient } from "../api-client";
import { isProcessAlive, readManifest } from "../host/manifest";
import { getRelayUrl } from "../host/relay-url";
import { getHostJwt } from "../host-jwt";

export type HostServiceClient = ReturnType<
typeof createTRPCClient<HostServiceRouter>
Expand Down Expand Up @@ -85,9 +86,16 @@ export async function resolveHostTarget(
httpBatchLink({
url: `${relayUrl}/hosts/${routingKey}/trpc`,
transformer: SuperJSON,
headers: {
Authorization: `Bearer ${options.userJwt}`,
"x-superset-client-machine-id": localHostId,
// The relay only verifies a JWKS-signed JWT. The raw bearer
// may be an `sk_live_…` API key (from --api-key /
// SUPERSET_API_KEY), which must first be exchanged for a JWT
// — sending it raw makes the relay return UNAUTHORIZED, which
// the CLI renders as the misleading "Session expired" (#6315).
async headers() {
return {
Authorization: `Bearer ${await getHostJwt(options.userJwt)}`,
"x-superset-client-machine-id": localHostId,
};
},
}),
],
Expand Down