From d7b24b6d2c8e6a015ec4e1834b589ae1d64a3e01 Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 14:47:44 -0700 Subject: [PATCH 01/13] HOST-AUTH-001 atomic config and auth path env --- packages/cli/src/lib/config.test.ts | 101 ++++++++++++++++++ packages/cli/src/lib/config.ts | 31 +++--- packages/cli/src/lib/host/spawn.test.ts | 135 ++++++++++++++++++++++++ packages/cli/src/lib/host/spawn.ts | 2 + packages/host-service/src/env.test.ts | 63 +++++++++++ packages/host-service/src/env.ts | 1 + 6 files changed, 315 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/lib/config.test.ts create mode 100644 packages/cli/src/lib/host/spawn.test.ts create mode 100644 packages/host-service/src/env.test.ts diff --git a/packages/cli/src/lib/config.test.ts b/packages/cli/src/lib/config.test.ts new file mode 100644 index 00000000000..1bf71b9759b --- /dev/null +++ b/packages/cli/src/lib/config.test.ts @@ -0,0 +1,101 @@ +import { afterAll, afterEach, describe, expect, it, spyOn } from "bun:test"; +import type { PathLike } from "node:fs"; +import * as fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; +const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "superset-cli-config-")); +process.env.SUPERSET_HOME_DIR = tempHome; + +const { readConfig, writeConfig } = await import("./config"); + +const configPath = path.join(tempHome, "config.json"); +const tmpPath = `${configPath}.tmp`; + +function removeConfigFiles(): void { + fs.rmSync(configPath, { force: true }); + fs.rmSync(tmpPath, { force: true }); +} + +afterEach(() => { + removeConfigFiles(); +}); + +afterAll(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + if (originalSupersetHomeDir === undefined) { + delete process.env.SUPERSET_HOME_DIR; + } else { + process.env.SUPERSET_HOME_DIR = originalSupersetHomeDir; + } +}); + +describe("writeConfig", () => { + it("writes config to a temp file before renaming it into place", () => { + const config = { + auth: { + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }; + const originalRenameSync = fs.renameSync; + const writeSpy = spyOn(fs, "writeFileSync"); + const renameSpy = spyOn(fs, "renameSync").mockImplementation( + (oldPath: PathLike, newPath: PathLike) => { + expect(oldPath).toBe(tmpPath); + expect(newPath).toBe(configPath); + expect(fs.existsSync(tmpPath)).toBe(true); + originalRenameSync(oldPath, newPath); + }, + ); + + writeConfig(config); + + expect(writeSpy).toHaveBeenCalledWith( + tmpPath, + JSON.stringify(config, null, 2), + { mode: 0o600 }, + ); + expect(renameSpy).toHaveBeenCalledTimes(1); + expect(readConfig()).toEqual(config); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + + writeSpy.mockRestore(); + renameSpy.mockRestore(); + }); + + it("leaves the previous config intact when the process stops before rename", () => { + const originalConfig = { + auth: { + accessToken: "old-access-token", + refreshToken: "old-refresh-token", + expiresAt: Date.now() + 60_000, + }, + }; + const nextConfig = { + auth: { + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 120_000, + }, + }; + + writeConfig(originalConfig); + const renameSpy = spyOn(fs, "renameSync").mockImplementation(() => { + throw new Error("simulated crash before rename"); + }); + + expect(() => writeConfig(nextConfig)).toThrow( + "simulated crash before rename", + ); + + expect(JSON.parse(fs.readFileSync(configPath, "utf-8"))).toEqual( + originalConfig, + ); + expect(JSON.parse(fs.readFileSync(tmpPath, "utf-8"))).toEqual(nextConfig); + + renameSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index c2cf2cd42c9..2134a8a919c 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -1,11 +1,4 @@ -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - statSync, - writeFileSync, -} from "node:fs"; +import * as fs from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { env } from "./env"; @@ -25,32 +18,34 @@ export const SUPERSET_HOME_DIR = const CONFIG_PATH = join(SUPERSET_HOME_DIR, "config.json"); function ensureDir() { - if (!existsSync(SUPERSET_HOME_DIR)) { - mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); + if (!fs.existsSync(SUPERSET_HOME_DIR)) { + fs.mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); } try { - const stat = statSync(SUPERSET_HOME_DIR); - if ((stat.mode & 0o077) !== 0) chmodSync(SUPERSET_HOME_DIR, 0o700); + const stat = fs.statSync(SUPERSET_HOME_DIR); + if ((stat.mode & 0o077) !== 0) fs.chmodSync(SUPERSET_HOME_DIR, 0o700); } catch {} } export function readConfig(): SupersetConfig { - if (!existsSync(CONFIG_PATH)) return {}; + if (!fs.existsSync(CONFIG_PATH)) return {}; try { - const stat = statSync(CONFIG_PATH); - if ((stat.mode & 0o077) !== 0) chmodSync(CONFIG_PATH, 0o600); + const stat = fs.statSync(CONFIG_PATH); + if ((stat.mode & 0o077) !== 0) fs.chmodSync(CONFIG_PATH, 0o600); } catch {} - return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); + return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); } export function writeConfig(config: SupersetConfig): void { ensureDir(); - writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { + const tmpPath = `${CONFIG_PATH}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600, }); try { - chmodSync(CONFIG_PATH, 0o600); + fs.chmodSync(tmpPath, 0o600); } catch {} + fs.renameSync(tmpPath, CONFIG_PATH); } export function getApiUrl(): string { diff --git a/packages/cli/src/lib/host/spawn.test.ts b/packages/cli/src/lib/host/spawn.test.ts new file mode 100644 index 00000000000..8a1b05ea82a --- /dev/null +++ b/packages/cli/src/lib/host/spawn.test.ts @@ -0,0 +1,135 @@ +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import type { SpawnOptions } from "node:child_process"; +import * as fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ApiClient } from "../api-client"; + +const originalEnv = { + SUPERSET_HOME_DIR: process.env.SUPERSET_HOME_DIR, + SUPERSET_HOST_BIN: process.env.SUPERSET_HOST_BIN, + SUPERSET_API_URL: process.env.SUPERSET_API_URL, + RELAY_URL: process.env.RELAY_URL, +}; +const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "superset-cli-spawn-")); +const hostBin = path.join(tempHome, "superset-host"); +fs.writeFileSync(hostBin, ""); + +process.env.SUPERSET_HOME_DIR = tempHome; +process.env.SUPERSET_HOST_BIN = hostBin; +process.env.SUPERSET_API_URL = "https://api.example.com"; +process.env.RELAY_URL = "https://relay.example.com"; + +const childProcess = { + pid: 24_680, + kill: mock(() => true), + unref: mock(() => {}), +}; +const spawnMock = mock( + (_command: string, _args: string[], _options: SpawnOptions) => childProcess, +); +mock.module("node:child_process", () => ({ + spawn: spawnMock, +})); +mock.module("./relay-url", () => ({ + getRelayUrl: mock(async () => "https://relay.example.com"), +})); + +const originalFetch = globalThis.fetch; +const fetchMock = mock(async () => new Response(null, { status: 200 })); +globalThis.fetch = fetchMock as unknown as typeof fetch; + +const { spawnHostService } = await import("./spawn"); +const { writeConfig } = await import("../config"); + +function createApiClient(): ApiClient { + return { + analytics: { + featureFlagPayload: { + query: mock(async () => ({ url: "https://relay.example.com" })), + }, + }, + } as unknown as ApiClient; +} + +function restoreEnvValue(key: keyof typeof originalEnv): void { + const value = originalEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function lastSpawnEnv(): NodeJS.ProcessEnv { + const call = spawnMock.mock.calls.at(-1); + if (!call) throw new Error("expected host service to be spawned"); + const options = call[2]; + if (!options?.env) throw new Error("expected spawn env to be present"); + return options.env; +} + +async function spawnWithToken(sessionToken: string) { + return spawnHostService({ + organizationId: "org_test", + sessionToken, + api: createApiClient(), + port: 49_321, + daemon: false, + }); +} + +afterEach(() => { + spawnMock.mockClear(); + childProcess.kill.mockClear(); + childProcess.unref.mockClear(); + fetchMock.mockClear(); + fs.rmSync(path.join(tempHome, "host"), { recursive: true, force: true }); + fs.rmSync(path.join(tempHome, "config.json"), { force: true }); + fs.rmSync(path.join(tempHome, "config.json.tmp"), { force: true }); +}); + +afterAll(() => { + globalThis.fetch = originalFetch; + restoreEnvValue("SUPERSET_HOME_DIR"); + restoreEnvValue("SUPERSET_HOST_BIN"); + restoreEnvValue("SUPERSET_API_URL"); + restoreEnvValue("RELAY_URL"); + fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +describe("spawnHostService", () => { + it("passes the current auth config path and access token to the host child", async () => { + await spawnWithToken("access-token-for-bootstrap"); + + const env = lastSpawnEnv(); + expect(env.AUTH_TOKEN).toBe("access-token-for-bootstrap"); + expect(env.SUPERSET_AUTH_CONFIG_PATH).toBe( + path.join(tempHome, "config.json"), + ); + }); + + it("does not pass the stored refresh token through the host child env", async () => { + const refreshToken = + "refresh-token-value-that-should-not-leak-HOST-AUTH-001"; + writeConfig({ + auth: { + accessToken: "access-token", + refreshToken, + expiresAt: Date.now() + 60_000, + }, + }); + + await spawnWithToken("access-token"); + + const env = lastSpawnEnv(); + const leakedEntries = Object.entries(env).filter( + ([, value]) => value === refreshToken, + ); + + expect(leakedEntries).toEqual([]); + expect(env.SUPERSET_AUTH_REFRESH_TOKEN).toBeUndefined(); + expect(env.REFRESH_TOKEN).toBeUndefined(); + expect(env.OAUTH_REFRESH).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/lib/host/spawn.ts b/packages/cli/src/lib/host/spawn.ts index 60dc4cce076..0cf55631aa8 100644 --- a/packages/cli/src/lib/host/spawn.ts +++ b/packages/cli/src/lib/host/spawn.ts @@ -4,6 +4,7 @@ import { existsSync } from "node:fs"; import { createServer } from "node:net"; import { dirname, join } from "node:path"; import type { ApiClient } from "../api-client"; +import { SUPERSET_HOME_DIR } from "../config"; import { env } from "../env"; import { type HostServiceManifest, @@ -110,6 +111,7 @@ export async function spawnHostService( ...process.env, ORGANIZATION_ID: options.organizationId, AUTH_TOKEN: options.sessionToken, + SUPERSET_AUTH_CONFIG_PATH: join(SUPERSET_HOME_DIR, "config.json"), SUPERSET_API_URL: env.SUPERSET_API_URL, RELAY_URL: relayUrl, PORT: String(port), diff --git a/packages/host-service/src/env.test.ts b/packages/host-service/src/env.test.ts new file mode 100644 index 00000000000..9adc3592571 --- /dev/null +++ b/packages/host-service/src/env.test.ts @@ -0,0 +1,63 @@ +import { afterAll, describe, expect, it } from "bun:test"; + +const originalEnv = { ...process.env }; + +const requiredEnv = { + ORGANIZATION_ID: "00000000-0000-4000-8000-000000000000", + HOST_DB_PATH: "/tmp/superset-host-test.db", + HOST_MIGRATIONS_FOLDER: "/tmp/superset-host-migrations", + AUTH_TOKEN: "access-token", + SUPERSET_API_URL: "https://api.example.com", +} satisfies Record; + +function restoreOriginalEnv(): void { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, originalEnv); +} + +function setEnv(overrides: Record): void { + restoreOriginalEnv(); + Object.assign(process.env, requiredEnv); + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +async function loadEnv(suffix: string): Promise { + const module = (await import(`./env.ts?${suffix}`)) as typeof import("./env"); + return module.env; +} + +afterAll(() => { + restoreOriginalEnv(); +}); + +describe("env", () => { + it("parses SUPERSET_AUTH_CONFIG_PATH while keeping AUTH_TOKEN required", async () => { + const configPath = "/tmp/superset/config.json"; + setEnv({ + AUTH_TOKEN: "bootstrap-access-token", + SUPERSET_AUTH_CONFIG_PATH: configPath, + }); + + const env = await loadEnv("with-auth-config-path"); + + expect(env.AUTH_TOKEN).toBe("bootstrap-access-token"); + expect(env.SUPERSET_AUTH_CONFIG_PATH).toBe(configPath); + }); + + it("does not require SUPERSET_AUTH_CONFIG_PATH", async () => { + setEnv({ SUPERSET_AUTH_CONFIG_PATH: undefined }); + + const env = await loadEnv("without-auth-config-path"); + + expect(env.AUTH_TOKEN).toBe("access-token"); + expect(env.SUPERSET_AUTH_CONFIG_PATH).toBeUndefined(); + }); +}); diff --git a/packages/host-service/src/env.ts b/packages/host-service/src/env.ts index edd75d471a4..298493e43ff 100644 --- a/packages/host-service/src/env.ts +++ b/packages/host-service/src/env.ts @@ -12,6 +12,7 @@ export const env = createEnv({ HOST_DB_PATH: z.string().min(1), HOST_MIGRATIONS_FOLDER: z.string().min(1), AUTH_TOKEN: z.string().min(1), + SUPERSET_AUTH_CONFIG_PATH: z.string().min(1).optional(), SUPERSET_API_URL: z.string().url(), CORS_ORIGINS: z .string() From b4f9e9c48debbaa885b6184cdceda8f158b99817 Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 14:48:06 -0700 Subject: [PATCH 02/13] test(cli): cover start auth middleware gate --- .../cli/src/commands/start/command.test.ts | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 packages/cli/src/commands/start/command.test.ts diff --git a/packages/cli/src/commands/start/command.test.ts b/packages/cli/src/commands/start/command.test.ts new file mode 100644 index 00000000000..3efd8667269 --- /dev/null +++ b/packages/cli/src/commands/start/command.test.ts @@ -0,0 +1,277 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { CommandTree } from "@superset/cli-framework"; +import type { ApiClient } from "../../lib/api-client"; +import type { LoginResult } from "../../lib/auth"; +import type { SpawnHostOptions, SpawnHostResult } from "../../lib/host/spawn"; + +const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; +const originalSupersetApiKey = process.env.SUPERSET_API_KEY; +const tempHome = fs.mkdtempSync( + path.join(os.tmpdir(), "superset-cli-start-auth-"), +); +process.env.SUPERSET_HOME_DIR = tempHome; +delete process.env.SUPERSET_API_KEY; + +const organization = { id: "org-1", name: "Acme" }; +const analyticsMutateMock = mock(async () => undefined); +const myOrganizationQueryMock = mock(async () => organization); +const fakeApi = { + analytics: { + captureEvent: { + mutate: analyticsMutateMock, + }, + }, + user: { + myOrganization: { + query: myOrganizationQueryMock, + }, + }, +} as unknown as ApiClient; + +const createApiClientMock = mock( + (_options: { bearer: string; organizationId?: string }): ApiClient => fakeApi, +); +const refreshAccessTokenMock = mock( + async (_refreshToken: string): Promise => ({ + accessToken: "refreshed-access-token", + refreshToken: "rotated-refresh-token", + expiresAt: Date.now() + 60 * 60 * 1000, + }), +); +const spawnHostServiceMock = mock( + async (_options: SpawnHostOptions): Promise => ({ + pid: 12_345, + port: 54_321, + secret: "host-secret", + }), +); + +const clackIntroMock = mock(() => undefined); +const clackOutroMock = mock(() => undefined); +const clackSpinnerStartMock = mock(() => undefined); +const clackSpinnerStopMock = mock(() => undefined); +const clackSpinnerMock = mock(() => ({ + start: clackSpinnerStartMock, + stop: clackSpinnerStopMock, +})); +const clackInfoMock = mock(() => undefined); + +mock.module("../../lib/api-client", () => ({ + createApiClient: createApiClientMock, +})); + +mock.module("../../lib/auth", () => ({ + refreshAccessToken: refreshAccessTokenMock, +})); + +mock.module("../../lib/host/spawn", () => ({ + spawnHostService: spawnHostServiceMock, +})); + +mock.module("@clack/prompts", () => ({ + intro: clackIntroMock, + outro: clackOutroMock, + spinner: clackSpinnerMock, + log: { + info: clackInfoMock, + }, +})); + +const { run } = await import("@superset/cli-framework"); +const { readConfig, writeConfig } = await import("../../lib/config"); +const startCommand = (await import("./command")).default; +const cliMiddleware = (await import("../middleware")).default; +const cliConfig = (await import("../../../cli.config")).default; +const commandTree: CommandTree = { + commands: [ + { + path: ["start"], + command: + startCommand as unknown as CommandTree["commands"][number]["command"], + }, + ], + groups: [], + middleware: cliMiddleware, +}; + +class ProcessExit extends Error { + constructor(public readonly code: number | string | null | undefined) { + super(`process.exit(${String(code)})`); + this.name = "ProcessExit"; + } +} + +type RunResult = { + exitCode?: number | string | null; + stderr: string; + stdout: string; +}; + +type WriteCallback = (error?: Error | null) => void; + +async function runStartCommand(args: string[]): Promise { + const originalArgv = process.argv; + const stderrChunks: string[] = []; + const stdoutChunks: string[] = []; + + const stderrSpy = spyOn(process.stderr, "write").mockImplementation((( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | WriteCallback, + callback?: WriteCallback, + ): boolean => { + stderrChunks.push( + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(), + ); + const done = + typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + done?.(); + return true; + }) as typeof process.stderr.write); + const logSpy = spyOn(console, "log").mockImplementation( + (...values: unknown[]): void => { + stdoutChunks.push(values.map(String).join(" ")); + }, + ); + const exitSpy = spyOn(process, "exit").mockImplementation((( + code?: number | string | null, + ): never => { + throw new ProcessExit(code); + }) as typeof process.exit); + + process.argv = ["bun", "superset", ...args]; + try { + await run({ + name: cliConfig.name, + version: cliConfig.version, + globals: cliConfig.globals, + tree: commandTree, + }); + return { stderr: stderrChunks.join(""), stdout: stdoutChunks.join("\n") }; + } catch (error) { + if (error instanceof ProcessExit) { + return { + exitCode: error.code, + stderr: stderrChunks.join(""), + stdout: stdoutChunks.join("\n"), + }; + } + throw error; + } finally { + process.argv = originalArgv; + stderrSpy.mockRestore(); + logSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +function clearConfig(): void { + writeConfig({}); +} + +beforeEach(() => { + clearConfig(); + delete process.env.SUPERSET_API_KEY; +}); + +afterEach(() => { + clearConfig(); + analyticsMutateMock.mockClear(); + myOrganizationQueryMock.mockClear(); + createApiClientMock.mockClear(); + refreshAccessTokenMock.mockClear(); + spawnHostServiceMock.mockClear(); + clackIntroMock.mockClear(); + clackOutroMock.mockClear(); + clackSpinnerStartMock.mockClear(); + clackSpinnerStopMock.mockClear(); + clackSpinnerMock.mockClear(); + clackInfoMock.mockClear(); +}); + +afterAll(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + if (originalSupersetHomeDir === undefined) { + delete process.env.SUPERSET_HOME_DIR; + } else { + process.env.SUPERSET_HOME_DIR = originalSupersetHomeDir; + } + if (originalSupersetApiKey === undefined) { + delete process.env.SUPERSET_API_KEY; + } else { + process.env.SUPERSET_API_KEY = originalSupersetApiKey; + } +}); + +describe("superset start auth middleware", () => { + it("exits non-zero with the login hint before spawning when no session exists", async () => { + const result = await runStartCommand(["start"]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Run: superset auth login"); + expect(createApiClientMock).not.toHaveBeenCalled(); + expect(myOrganizationQueryMock).not.toHaveBeenCalled(); + expect(spawnHostServiceMock).not.toHaveBeenCalled(); + }); + + it("spawns the host with the on-disk access token when the session is valid", async () => { + writeConfig({ + auth: { + accessToken: "on-disk-access-token", + refreshToken: "stored-refresh-token", + expiresAt: Date.now() + 10 * 60 * 1000, + }, + organizationId: organization.id, + }); + + const result = await runStartCommand(["start", "--daemon"]); + + expect(result.exitCode).toBeUndefined(); + expect(refreshAccessTokenMock).not.toHaveBeenCalled(); + expect(myOrganizationQueryMock).toHaveBeenCalledTimes(1); + expect(spawnHostServiceMock).toHaveBeenCalledTimes(1); + expect(spawnHostServiceMock.mock.calls[0]?.[0]).toMatchObject({ + organizationId: organization.id, + sessionToken: "on-disk-access-token", + daemon: true, + }); + }); + + it("refreshes a near-expired session in middleware before spawning", async () => { + writeConfig({ + auth: { + accessToken: "stale-access-token", + refreshToken: "stored-refresh-token", + expiresAt: Date.now() + 60 * 1000, + }, + organizationId: organization.id, + }); + + const result = await runStartCommand(["start", "--daemon"]); + + expect(result.exitCode).toBeUndefined(); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(refreshAccessTokenMock).toHaveBeenCalledWith("stored-refresh-token"); + expect(spawnHostServiceMock).toHaveBeenCalledTimes(1); + expect(spawnHostServiceMock.mock.calls[0]?.[0]).toMatchObject({ + organizationId: organization.id, + sessionToken: "refreshed-access-token", + daemon: true, + }); + expect(readConfig().auth).toMatchObject({ + accessToken: "refreshed-access-token", + refreshToken: "rotated-refresh-token", + }); + }); +}); From 7f2ab1c4286d4033c42416268cd7ad49b11f3b62 Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 15:09:51 -0700 Subject: [PATCH 03/13] HOST-AUTH-002 host-owned token refresh --- packages/cli/src/lib/auth.ts | 48 +-- packages/cli/src/lib/resolve-auth.test.ts | 134 +++++++- packages/cli/src/lib/resolve-auth.ts | 13 +- packages/host-service/src/errors.ts | 26 ++ .../JwtApiAuthProvider.test.ts | 292 ++++++++++++++++++ .../JwtApiAuthProvider/JwtApiAuthProvider.ts | 268 ++++++++++++++++ .../auth/JwtApiAuthProvider/index.ts | 4 + .../JwtAuthProvider/JwtAuthProvider.test.ts | 34 ++ .../auth/JwtAuthProvider/JwtAuthProvider.ts | 83 +---- .../providers/auth/JwtAuthProvider/index.ts | 6 +- .../host-service/src/providers/auth/index.ts | 6 +- packages/host-service/src/serve.ts | 1 + packages/shared/package.json | 4 + packages/shared/src/auth/index.ts | 1 + packages/shared/src/auth/token-refresh.ts | 59 ++++ 15 files changed, 851 insertions(+), 128 deletions(-) create mode 100644 packages/host-service/src/errors.ts create mode 100644 packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts create mode 100644 packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts create mode 100644 packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts create mode 100644 packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts create mode 100644 packages/shared/src/auth/token-refresh.ts diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index ef33f5ec9d4..9442ce547a7 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -1,20 +1,18 @@ import { createHash, randomBytes } from "node:crypto"; import { createServer, type Server } from "node:http"; import { CLIError } from "@superset/cli-framework"; +import type { LoginResult } from "@superset/shared/auth/token-refresh"; import { env } from "./env"; +export type { LoginResult } from "@superset/shared/auth/token-refresh"; +export { refreshAccessToken } from "@superset/shared/auth/token-refresh"; + const CLIENT_ID = "superset-cli"; const PASTE_REDIRECT_PATH = "/cli/auth/code"; const SCOPE = "openid profile email offline_access"; const LOOPBACK_PORTS = [51789, 51790, 51791, 51792, 51793]; const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; -export interface LoginResult { - accessToken: string; - refreshToken?: string; - expiresAt: number; -} - export interface LoginCallbacks { onAuthorizationUrl?: (url: string) => void; promptForPastedCode: (signal: AbortSignal) => Promise; @@ -244,44 +242,6 @@ async function exchangeCodeForToken({ }; } -export async function refreshAccessToken( - refreshToken: string, -): Promise { - const apiUrl = env.SUPERSET_API_URL; - const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - resource: apiUrl, - }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new CLIError( - `Token refresh failed: ${response.status}`, - body || "Run `superset auth login` again.", - ); - } - - const data = (await response.json()) as { - access_token: string; - token_type: string; - expires_in?: number; - refresh_token?: string; - }; - - const expiresIn = data.expires_in ?? 60 * 60; - return { - accessToken: data.access_token, - refreshToken: data.refresh_token ?? refreshToken, - expiresAt: Date.now() + expiresIn * 1000, - }; -} - export async function login( signal: AbortSignal, callbacks: LoginCallbacks, diff --git a/packages/cli/src/lib/resolve-auth.test.ts b/packages/cli/src/lib/resolve-auth.test.ts index 8825d60d555..3aa91c960ea 100644 --- a/packages/cli/src/lib/resolve-auth.test.ts +++ b/packages/cli/src/lib/resolve-auth.test.ts @@ -1,7 +1,8 @@ -import { afterAll, afterEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { LoginResult } from "@superset/shared/auth/token-refresh"; const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; const tempHome = fs.mkdtempSync( @@ -9,8 +10,43 @@ const tempHome = fs.mkdtempSync( ); process.env.SUPERSET_HOME_DIR = tempHome; +interface HostServiceManifest { + pid: number; + endpoint: string; + authToken: string; + startedAt: number; + organizationId: string; +} + +let refreshAccessTokenImpl = async ( + refreshToken: string, +): Promise => ({ + accessToken: "refreshed-access-token", + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, +}); +const refreshAccessTokenMock = mock((refreshToken: string) => + refreshAccessTokenImpl(refreshToken), +); + +let hostManifest: HostServiceManifest | null = null; +const alivePids = new Set(); +const readManifestMock = mock((organizationId: string) => + hostManifest?.organizationId === organizationId ? hostManifest : null, +); +const isProcessAliveMock = mock((pid: number) => alivePids.has(pid)); + +mock.module("@superset/shared/auth/token-refresh", () => ({ + refreshAccessToken: refreshAccessTokenMock, +})); + +mock.module("./host/manifest", () => ({ + readManifest: readManifestMock, + isProcessAlive: isProcessAliveMock, +})); + const { resolveAuth } = await import("./resolve-auth"); -const { writeConfig } = await import("./config"); +const { readConfig, writeConfig } = await import("./config"); function clearConfig(): void { writeConfig({}); @@ -18,6 +54,16 @@ function clearConfig(): void { afterEach(() => { clearConfig(); + refreshAccessTokenMock.mockClear(); + refreshAccessTokenImpl = async (refreshToken: string) => ({ + accessToken: "refreshed-access-token", + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, + }); + hostManifest = null; + alivePids.clear(); + readManifestMock.mockClear(); + isProcessAliveMock.mockClear(); }); afterAll(() => { @@ -62,6 +108,90 @@ describe("resolveAuth", () => { expect(result.authSource).toBe("oauth"); }); + it("defers OAuth refresh to the host while the host process is alive", async () => { + const pid = 24_680; + writeConfig({ + organizationId: "org_1", + auth: { + accessToken: "near-expiry-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + hostManifest = { + pid, + endpoint: "http://127.0.0.1:4879", + authToken: "host-secret", + startedAt: Date.now(), + organizationId: "org_1", + }; + alivePids.add(pid); + + const result = await resolveAuth(undefined); + + expect(result.bearer).toBe("near-expiry-access-token"); + expect(result.authSource).toBe("oauth"); + expect(refreshAccessTokenMock).not.toHaveBeenCalled(); + expect(readManifestMock).toHaveBeenCalledWith("org_1"); + expect(isProcessAliveMock).toHaveBeenCalledWith(pid); + expect(readConfig().auth?.accessToken).toBe("near-expiry-access-token"); + }); + + it("refreshes OAuth credentials when the host manifest process is not alive", async () => { + const pid = 24_681; + const refreshedExpiresAt = Date.now() + 60 * 60 * 1000; + refreshAccessTokenImpl = async () => ({ + accessToken: "refreshed-access-token", + refreshToken: "rotated-refresh-token", + expiresAt: refreshedExpiresAt, + }); + writeConfig({ + organizationId: "org_1", + auth: { + accessToken: "near-expiry-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + hostManifest = { + pid, + endpoint: "http://127.0.0.1:4879", + authToken: "host-secret", + startedAt: Date.now(), + organizationId: "org_1", + }; + + const result = await resolveAuth(undefined); + + expect(result.bearer).toBe("refreshed-access-token"); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(refreshAccessTokenMock).toHaveBeenCalledWith("refresh-token"); + expect(isProcessAliveMock).toHaveBeenCalledWith(pid); + expect(readConfig().auth).toEqual({ + accessToken: "refreshed-access-token", + refreshToken: "rotated-refresh-token", + expiresAt: refreshedExpiresAt, + }); + }); + + it("refreshes OAuth credentials when no host manifest exists", async () => { + writeConfig({ + organizationId: "org_1", + auth: { + accessToken: "near-expiry-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + + const result = await resolveAuth(undefined); + + expect(result.bearer).toBe("refreshed-access-token"); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(readManifestMock).toHaveBeenCalledWith("org_1"); + expect(isProcessAliveMock).not.toHaveBeenCalled(); + }); + it("throws when OAuth session is expired and there is no refresh token", async () => { writeConfig({ auth: { accessToken: "stale", expiresAt: Date.now() - 1000 }, diff --git a/packages/cli/src/lib/resolve-auth.ts b/packages/cli/src/lib/resolve-auth.ts index d979b888bc1..1a831c73c6f 100644 --- a/packages/cli/src/lib/resolve-auth.ts +++ b/packages/cli/src/lib/resolve-auth.ts @@ -1,7 +1,8 @@ import { CLIError } from "@superset/cli-framework"; +import { refreshAccessToken } from "@superset/shared/auth/token-refresh"; import { type ApiClient, createApiClient } from "./api-client"; -import { refreshAccessToken } from "./auth"; import { readConfig, type SupersetConfig, writeConfig } from "./config"; +import { isProcessAlive, readManifest } from "./host/manifest"; export type AuthSource = "override" | "config" | "oauth"; @@ -14,6 +15,12 @@ export type ResolvedAuth = { const REFRESH_LEEWAY_MS = 5 * 60 * 1000; +function isHostAlive(organizationId: string | undefined): boolean { + if (!organizationId) return false; + const manifest = readManifest(organizationId); + return manifest ? isProcessAlive(manifest.pid) : false; +} + export async function resolveAuth( apiKeyOption: string | undefined, ): Promise { @@ -31,7 +38,9 @@ export async function resolveAuth( authSource = "config"; } else if (config.auth) { const auth = config.auth; - if (auth.expiresAt - REFRESH_LEEWAY_MS < Date.now()) { + if (isHostAlive(config.organizationId)) { + bearer = auth.accessToken; + } else if (auth.expiresAt - REFRESH_LEEWAY_MS < Date.now()) { if (!auth.refreshToken) { throw new CLIError("Session expired", "Run: superset auth login"); } diff --git a/packages/host-service/src/errors.ts b/packages/host-service/src/errors.ts new file mode 100644 index 00000000000..e624e111a58 --- /dev/null +++ b/packages/host-service/src/errors.ts @@ -0,0 +1,26 @@ +export const AUTH_REFRESH_FAILED_MESSAGE = + "Superset session expired — run `superset auth login`"; + +export type AuthRefreshFailureReason = + | "invalid_grant" + | "network_error" + | "http_error"; + +export interface AuthRefreshFailedErrorOptions { + reason: AuthRefreshFailureReason; + statusCode?: number; +} + +export class AuthRefreshFailedError extends Error { + readonly reason: AuthRefreshFailureReason; + readonly statusCode?: number; + + constructor(options: AuthRefreshFailedErrorOptions) { + super(AUTH_REFRESH_FAILED_MESSAGE); + this.name = "AuthRefreshFailedError"; + this.reason = options.reason; + if (options.statusCode !== undefined) { + this.statusCode = options.statusCode; + } + } +} diff --git a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts new file mode 100644 index 00000000000..a0e88443ff7 --- /dev/null +++ b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts @@ -0,0 +1,292 @@ +import { + afterAll, + afterEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; +import * as fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { LoginResult } from "@superset/shared/auth/token-refresh"; + +let refreshAccessTokenImpl = async ( + refreshToken: string, +): Promise => ({ + accessToken: jwtWithExp(Date.now() + 60 * 60 * 1000), + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, +}); +const refreshAccessTokenMock = mock((refreshToken: string) => + refreshAccessTokenImpl(refreshToken), +); + +mock.module("@superset/shared/auth/token-refresh", () => ({ + refreshAccessToken: refreshAccessTokenMock, +})); + +const { JwtApiAuthProvider } = await import("./JwtApiAuthProvider"); +const { AUTH_REFRESH_FAILED_MESSAGE, AuthRefreshFailedError } = await import( + "../../../errors" +); + +const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "superset-host-jwt-api-auth-"), +); + +function jwtWithExp(expiresAtMs: number): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString( + "base64url", + ); + const payload = Buffer.from( + JSON.stringify({ exp: Math.floor(expiresAtMs / 1000) }), + ).toString("base64url"); + return `${header}.${payload}.signature`; +} + +function createConfigPath(): string { + const dir = fs.mkdtempSync(path.join(tempRoot, "case-")); + return path.join(dir, "config.json"); +} + +function writeConfig( + configPath: string, + config: { + auth: { + accessToken: string; + refreshToken?: string; + expiresAt: number; + }; + organizationId?: string; + apiKey?: string; + }, +): void { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { + mode: 0o600, + }); +} + +function readConfig(configPath: string): { + auth?: { + accessToken: string; + refreshToken?: string; + expiresAt: number; + }; + organizationId?: string; + apiKey?: string; +} { + return JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + auth?: { + accessToken: string; + refreshToken?: string; + expiresAt: number; + }; + organizationId?: string; + apiKey?: string; + }; +} + +function createProvider( + configPath: string, +): InstanceType { + return new JwtApiAuthProvider({ + getSessionToken: async () => "bootstrap-access-token", + apiUrl: "https://api.example.com", + authConfigPath: configPath, + }); +} + +afterEach(() => { + refreshAccessTokenMock.mockClear(); + refreshAccessTokenImpl = async (refreshToken: string) => ({ + accessToken: jwtWithExp(Date.now() + 60 * 60 * 1000), + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, + }); +}); + +afterAll(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("JwtApiAuthProvider", () => { + it("refreshes a JWT within the leeway and persists the rotated credential atomically", async () => { + const configPath = createConfigPath(); + const oldToken = jwtWithExp(Date.now() + 60_000); + const refreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); + const refreshedExpiresAt = Date.now() + 60 * 60 * 1000; + refreshAccessTokenImpl = async () => ({ + accessToken: refreshedToken, + refreshToken: "rotated-refresh-token", + expiresAt: refreshedExpiresAt, + }); + writeConfig(configPath, { + organizationId: "org_1", + apiKey: "sk_live_existing", + auth: { + accessToken: oldToken, + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const renameSpy = spyOn(fs, "renameSync"); + + const token = await createProvider(configPath).getSessionToken(); + + expect(token).toBe(refreshedToken); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(refreshAccessTokenMock).toHaveBeenCalledWith("refresh-token"); + expect(renameSpy).toHaveBeenCalledWith(`${configPath}.tmp`, configPath); + expect(readConfig(configPath)).toEqual({ + organizationId: "org_1", + apiKey: "sk_live_existing", + auth: { + accessToken: refreshedToken, + refreshToken: "rotated-refresh-token", + expiresAt: refreshedExpiresAt, + }, + }); + + renameSpy.mockRestore(); + }); + + it("returns the in-memory token without refresh or config re-read when the JWT is fresh", async () => { + const configPath = createConfigPath(); + const freshToken = jwtWithExp(Date.now() + 60 * 60 * 1000); + writeConfig(configPath, { + auth: { + accessToken: freshToken, + refreshToken: "refresh-token", + expiresAt: Date.now() + 60 * 60 * 1000, + }, + }); + const provider = createProvider(configPath); + const readSpy = spyOn(fs, "readFileSync"); + + expect(await provider.getSessionToken()).toBe(freshToken); + readSpy.mockClear(); + + expect(await provider.getSessionToken()).toBe(freshToken); + expect(refreshAccessTokenMock).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + + readSpy.mockRestore(); + }); + + it("coalesces concurrent refresh callers into one in-flight refresh", async () => { + const configPath = createConfigPath(); + const oldToken = jwtWithExp(Date.now() + 60_000); + const firstRefreshedToken = jwtWithExp(Date.now() + 60_000); + const secondRefreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); + let refreshCount = 0; + refreshAccessTokenImpl = async (refreshToken: string) => { + refreshCount += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + accessToken: + refreshCount === 1 ? firstRefreshedToken : secondRefreshedToken, + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, + }; + }; + writeConfig(configPath, { + auth: { + accessToken: oldToken, + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath); + + const results = await Promise.all( + Array.from({ length: 50 }, () => provider.getSessionToken()), + ); + + expect(new Set(results)).toEqual(new Set([firstRefreshedToken])); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + + await expect(provider.getSessionToken()).resolves.toBe( + secondRefreshedToken, + ); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + }); + + it("throws invalid_grant AuthRefreshFailedError on a 401 refresh response", async () => { + const configPath = createConfigPath(); + refreshAccessTokenImpl = async () => { + throw new Error("Token refresh failed: 401"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + + await expect( + createProvider(configPath).getSessionToken(), + ).rejects.toMatchObject({ + message: AUTH_REFRESH_FAILED_MESSAGE, + reason: "invalid_grant", + statusCode: 401, + }); + }); + + it("uses the exact refresh failure hint without leaking token, URL, or response body", async () => { + const configPath = createConfigPath(); + const leakedToken = "refresh-token-secret"; + const leakedUrl = + "https://api.example.com/api/auth/oauth2/token?refresh_token=secret"; + const leakedBody = "raw invalid_grant response body"; + refreshAccessTokenImpl = async () => { + throw new Error( + `Token refresh failed: 500 ${leakedToken} ${leakedUrl} ${leakedBody}`, + ); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: leakedToken, + expiresAt: Date.now() + 60_000, + }, + }); + + try { + await createProvider(configPath).getSessionToken(); + throw new Error("expected getSessionToken to throw"); + } catch (error) { + expect(error).toBeInstanceOf(AuthRefreshFailedError); + const refreshError = error as InstanceType; + expect(refreshError.message).toBe(AUTH_REFRESH_FAILED_MESSAGE); + expect(refreshError.reason).toBe("http_error"); + expect(refreshError.statusCode).toBe(500); + expect(refreshError.message).not.toContain(leakedToken); + expect(refreshError.message).not.toContain(leakedUrl); + expect(refreshError.message).not.toContain(leakedBody); + } + }); + + it("classifies thrown fetch failures as network_error", async () => { + const configPath = createConfigPath(); + refreshAccessTokenImpl = async () => { + throw new TypeError("fetch failed"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + + await expect( + createProvider(configPath).getSessionToken(), + ).rejects.toMatchObject({ + message: AUTH_REFRESH_FAILED_MESSAGE, + reason: "network_error", + }); + }); +}); diff --git a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts new file mode 100644 index 00000000000..92ce660f50a --- /dev/null +++ b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts @@ -0,0 +1,268 @@ +import * as fs from "node:fs"; +import { dirname } from "node:path"; +import { refreshAccessToken } from "@superset/shared/auth/token-refresh"; +import { + AuthRefreshFailedError, + type AuthRefreshFailureReason, +} from "../../../errors"; +import type { ApiAuthProvider } from "../types"; + +const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const JWT_CACHE_DURATION_MS = 55 * 60 * 1000; + +interface SupersetAuthConfig { + accessToken: string; + refreshToken?: string; + expiresAt: number; +} + +interface SupersetConfig { + auth?: SupersetAuthConfig; + apiKey?: string; + organizationId?: string; + [key: string]: unknown; +} + +interface RefreshFailureClassification { + reason: AuthRefreshFailureReason; + statusCode?: number; +} + +export interface JwtApiAuthProviderOptions { + /** + * Returns the current session/api-key/JWT token to authenticate with. + * Used directly when no auth config path is available, and as a fallback + * when the config file has not been written yet. + */ + getSessionToken: () => Promise; + apiUrl: string; + authConfigPath?: string; +} + +function looksLikeJwt(token: string): boolean { + const parts = token.split("."); + return parts.length === 3 && parts.every(Boolean); +} + +function readJwtExp(token: string): number | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const payload = parts[1]; + if (!payload) return null; + + try { + const parsed: unknown = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) && + typeof (parsed as { exp?: unknown }).exp === "number" + ) { + return (parsed as { exp: number }).exp * 1000; + } + return null; + } catch { + return null; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSupersetAuthConfig(value: unknown): value is SupersetAuthConfig { + if (!isObject(value)) return false; + return ( + typeof value.accessToken === "string" && + typeof value.expiresAt === "number" && + (value.refreshToken === undefined || typeof value.refreshToken === "string") + ); +} + +function readStatusCode(error: unknown): number | undefined { + if (isObject(error) && typeof error.statusCode === "number") { + return error.statusCode; + } + const message = error instanceof Error ? error.message : String(error); + const match = /Token refresh failed:\s*(\d{3})/.exec(message); + if (!match?.[1]) return undefined; + return Number.parseInt(match[1], 10); +} + +function reasonForRefreshError(error: unknown): RefreshFailureClassification { + const statusCode = readStatusCode(error); + if (statusCode === undefined) { + return { reason: "network_error" }; + } + if (statusCode === 400 || statusCode === 401 || statusCode === 403) { + return { reason: "invalid_grant", statusCode }; + } + return { reason: "http_error", statusCode }; +} + +function readConfigAtPath(configPath: string): SupersetConfig { + if (!fs.existsSync(configPath)) return {}; + const parsed: unknown = JSON.parse(fs.readFileSync(configPath, "utf-8")); + return isObject(parsed) ? parsed : {}; +} + +function writeConfigAtPath(configPath: string, config: SupersetConfig): void { + const configDir = dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + } + try { + const stat = fs.statSync(configDir); + if ((stat.mode & 0o077) !== 0) fs.chmodSync(configDir, 0o700); + } catch {} + + const tmpPath = `${configPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + try { + fs.chmodSync(tmpPath, 0o600); + } catch {} + fs.renameSync(tmpPath, configPath); +} + +export class JwtApiAuthProvider implements ApiAuthProvider { + private readonly loadSessionToken: () => Promise; + private readonly apiUrl: string; + private readonly authConfigPath: string | undefined; + private cachedJwt: string | null = null; + private cachedJwtSessionToken: string | null = null; + private cachedJwtExpiresAt = 0; + private currentCredential: SupersetAuthConfig | null = null; + private inflightRefresh: Promise | null = null; + + constructor(options: JwtApiAuthProviderOptions) { + this.loadSessionToken = options.getSessionToken; + this.apiUrl = options.apiUrl; + this.authConfigPath = options.authConfigPath; + } + + async getHeaders(): Promise> { + const jwt = await this.getJwt(); + return { Authorization: `Bearer ${jwt}` }; + } + + invalidateCache(): void { + this.cachedJwt = null; + this.cachedJwtSessionToken = null; + this.cachedJwtExpiresAt = 0; + this.currentCredential = null; + } + + async getSessionToken(): Promise { + if (!this.authConfigPath) { + return this.loadSessionToken(); + } + + const credential = this.currentCredential ?? this.readCurrentCredential(); + if (!credential) { + return this.loadSessionToken(); + } + this.currentCredential = credential; + + const expiresAt = readJwtExp(credential.accessToken); + if (expiresAt === null || expiresAt - Date.now() > JWT_REFRESH_BUFFER_MS) { + return credential.accessToken; + } + + if (this.inflightRefresh) { + return this.inflightRefresh; + } + + this.inflightRefresh = this.refreshCredential(credential).finally(() => { + this.inflightRefresh = null; + }); + return this.inflightRefresh; + } + + async getJwt(): Promise { + const sessionToken = await this.getSessionToken(); + + // OAuth access tokens are already JWTs. Delegate to getSessionToken so + // host-owned refresh and single-flight behavior run before pass-through. + if (looksLikeJwt(sessionToken)) { + return sessionToken; + } + + if ( + this.cachedJwt && + this.cachedJwtSessionToken === sessionToken && + Date.now() < this.cachedJwtExpiresAt - JWT_REFRESH_BUFFER_MS + ) { + return this.cachedJwt; + } + + // better-auth's apiKey plugin reads `sk_live_…` from x-api-key, not + // Authorization: Bearer; mirror what the CLI's tRPC client does in + // packages/cli/src/lib/api-client.ts. + const response = await fetch(`${this.apiUrl}/api/auth/token`, { + headers: sessionToken.startsWith("sk_live_") + ? { "x-api-key": sessionToken } + : { Authorization: `Bearer ${sessionToken}` }, + }); + if (!response.ok) { + throw new Error(`Failed to mint JWT: ${response.status}`); + } + const data = (await response.json()) as { token: string }; + this.cachedJwt = data.token; + this.cachedJwtSessionToken = sessionToken; + this.cachedJwtExpiresAt = Date.now() + JWT_CACHE_DURATION_MS; + return data.token; + } + + private readCurrentCredential(): SupersetAuthConfig | null { + if (!this.authConfigPath) return null; + const config = readConfigAtPath(this.authConfigPath); + return isSupersetAuthConfig(config.auth) ? config.auth : null; + } + + private async refreshCredential( + credential: SupersetAuthConfig, + ): Promise { + if (!credential.refreshToken) { + throw new AuthRefreshFailedError({ reason: "invalid_grant" }); + } + + const refreshed = await this.runRefresh(credential.refreshToken); + const nextCredential: SupersetAuthConfig = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken ?? credential.refreshToken, + expiresAt: refreshed.expiresAt, + }; + + if (this.authConfigPath) { + const latestConfig = readConfigAtPath(this.authConfigPath); + writeConfigAtPath(this.authConfigPath, { + ...latestConfig, + auth: nextCredential, + }); + } + + this.currentCredential = nextCredential; + this.cachedJwt = null; + this.cachedJwtSessionToken = null; + this.cachedJwtExpiresAt = 0; + return nextCredential.accessToken; + } + + private async runRefresh(refreshToken: string): Promise { + try { + const refreshed = await refreshAccessToken(refreshToken); + return { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken ?? refreshToken, + expiresAt: refreshed.expiresAt, + }; + } catch (error) { + if (error instanceof AuthRefreshFailedError) throw error; + const options = reasonForRefreshError(error); + throw new AuthRefreshFailedError(options); + } + } +} diff --git a/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts b/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts new file mode 100644 index 00000000000..c459e9450a7 --- /dev/null +++ b/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts @@ -0,0 +1,4 @@ +export { + JwtApiAuthProvider, + type JwtApiAuthProviderOptions, +} from "./JwtApiAuthProvider"; diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts new file mode 100644 index 00000000000..6e438991f1b --- /dev/null +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, mock } from "bun:test"; + +const { JwtApiAuthProvider } = await import("./JwtAuthProvider"); + +function jwtWithExp(expiresAtMs: number): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString( + "base64url", + ); + const payload = Buffer.from( + JSON.stringify({ exp: Math.floor(expiresAtMs / 1000) }), + ).toString("base64url"); + return `${header}.${payload}.signature`; +} + +describe("JwtAuthProvider getJwt", () => { + it("delegates the JWT branch to getSessionToken once per invocation without caching", async () => { + const accessToken = jwtWithExp(Date.now() + 60 * 60 * 1000); + const getSessionToken = mock(async () => accessToken); + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => new Response(null, { status: 500 })); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const provider = new JwtApiAuthProvider({ + getSessionToken, + apiUrl: "https://api.example.com", + }); + + expect(await provider.getJwt()).toBe(accessToken); + expect(await provider.getJwt()).toBe(accessToken); + expect(getSessionToken).toHaveBeenCalledTimes(2); + expect(fetchMock).not.toHaveBeenCalled(); + + globalThis.fetch = originalFetch; + }); +}); diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts index f2119bab5f3..9789aa00e9b 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts @@ -1,78 +1,5 @@ -import type { ApiAuthProvider } from "../types"; - -const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000; -const JWT_CACHE_DURATION_MS = 55 * 60 * 1000; - -function looksLikeJwt(token: string): boolean { - const parts = token.split("."); - return parts.length === 3 && parts.every(Boolean); -} - -export interface JwtApiAuthProviderOptions { - /** - * Returns the current session/api-key/JWT token to authenticate with. - * Called whenever a fresh JWT needs to be minted, so token rotations - * (re-login, refresh) are picked up without restarting the host-service. - */ - getSessionToken: () => Promise; - apiUrl: string; -} - -export class JwtApiAuthProvider implements ApiAuthProvider { - private readonly getSessionToken: () => Promise; - private readonly apiUrl: string; - private cachedJwt: string | null = null; - private cachedJwtExpiresAt = 0; - - constructor(options: JwtApiAuthProviderOptions) { - this.getSessionToken = options.getSessionToken; - this.apiUrl = options.apiUrl; - } - - async getHeaders(): Promise> { - const jwt = await this.getJwt(); - return { Authorization: `Bearer ${jwt}` }; - } - - invalidateCache(): void { - this.cachedJwt = null; - this.cachedJwtExpiresAt = 0; - } - - async getJwt(): Promise { - if ( - this.cachedJwt && - Date.now() < this.cachedJwtExpiresAt - JWT_REFRESH_BUFFER_MS - ) { - return this.cachedJwt; - } - - const sessionToken = await this.getSessionToken(); - - // CLI OAuth code+PKCE login stores the OAuth access token directly, - // which is already a JWT signed by the same JWKS the relay verifies - // against and carries `organizationIds` via customAccessTokenClaims. - // Pass it through — no /api/auth/token exchange needed (and the - // better-auth jwt plugin endpoint doesn't accept OAuth tokens - // anyway, only sessions and api keys). - if (looksLikeJwt(sessionToken)) { - return sessionToken; - } - - // better-auth's apiKey plugin reads `sk_live_…` from x-api-key, not - // Authorization: Bearer; mirror what the CLI's tRPC client does in - // packages/cli/src/lib/api-client.ts. - const response = await fetch(`${this.apiUrl}/api/auth/token`, { - headers: sessionToken.startsWith("sk_live_") - ? { "x-api-key": sessionToken } - : { Authorization: `Bearer ${sessionToken}` }, - }); - if (!response.ok) { - throw new Error(`Failed to mint JWT: ${response.status}`); - } - const data = (await response.json()) as { token: string }; - this.cachedJwt = data.token; - this.cachedJwtExpiresAt = Date.now() + JWT_CACHE_DURATION_MS; - return data.token; - } -} +export { + JwtApiAuthProvider, + JwtApiAuthProvider as JwtAuthProvider, + type JwtApiAuthProviderOptions, +} from "../JwtApiAuthProvider"; diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts index 98fa128ff14..d2abced4127 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts @@ -1 +1,5 @@ -export { JwtApiAuthProvider } from "./JwtAuthProvider"; +export { + JwtApiAuthProvider, + JwtApiAuthProvider as JwtAuthProvider, + type JwtApiAuthProviderOptions, +} from "./JwtAuthProvider"; diff --git a/packages/host-service/src/providers/auth/index.ts b/packages/host-service/src/providers/auth/index.ts index 3f509288c27..328f994d7e3 100644 --- a/packages/host-service/src/providers/auth/index.ts +++ b/packages/host-service/src/providers/auth/index.ts @@ -1,3 +1,7 @@ export { DeviceKeyApiAuthProvider } from "./DeviceKeyAuthProvider"; -export { JwtApiAuthProvider } from "./JwtAuthProvider"; +export { + JwtApiAuthProvider, + JwtApiAuthProvider as JwtAuthProvider, + type JwtApiAuthProviderOptions, +} from "./JwtApiAuthProvider"; export type { ApiAuthProvider } from "./types"; diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index 760139a447d..d910f9bbd6c 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -29,6 +29,7 @@ async function main(): Promise { const authProvider = new JwtApiAuthProvider({ getSessionToken: async () => env.AUTH_TOKEN, apiUrl: env.SUPERSET_API_URL, + authConfigPath: env.SUPERSET_AUTH_CONFIG_PATH, }); const { app, injectWebSocket, api } = createApp({ diff --git a/packages/shared/package.json b/packages/shared/package.json index bafa022fca3..f2f2953807f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,10 @@ "types": "./src/auth/index.ts", "default": "./src/auth/index.ts" }, + "./auth/token-refresh": { + "types": "./src/auth/token-refresh.ts", + "default": "./src/auth/token-refresh.ts" + }, "./agent-command": { "types": "./src/agent-command.ts", "default": "./src/agent-command.ts" diff --git a/packages/shared/src/auth/index.ts b/packages/shared/src/auth/index.ts index 3cff44b3858..bfb74e1883f 100644 --- a/packages/shared/src/auth/index.ts +++ b/packages/shared/src/auth/index.ts @@ -1,2 +1,3 @@ export * from "./authorization"; export * from "./roles"; +export * from "./token-refresh"; diff --git a/packages/shared/src/auth/token-refresh.ts b/packages/shared/src/auth/token-refresh.ts new file mode 100644 index 00000000000..f65ae8a7170 --- /dev/null +++ b/packages/shared/src/auth/token-refresh.ts @@ -0,0 +1,59 @@ +const CLIENT_ID = "superset-cli"; + +export interface LoginResult { + accessToken: string; + refreshToken?: string; + expiresAt: number; +} + +class CLIError extends Error { + constructor( + message: string, + readonly suggestion?: string, + ) { + super(message); + this.name = "CLIError"; + } +} + +function getApiUrl(): string { + return process.env.SUPERSET_API_URL || "https://api.superset.sh"; +} + +export async function refreshAccessToken( + refreshToken: string, +): Promise { + const apiUrl = getApiUrl(); + const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + resource: apiUrl, + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new CLIError( + `Token refresh failed: ${response.status}`, + body || "Run `superset auth login` again.", + ); + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + }; + + const expiresIn = data.expires_in ?? 60 * 60; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, + expiresAt: Date.now() + expiresIn * 1000, + }; +} From c3e8cca8265e7b7a177644ad5c27745082ad81fc Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 15:27:32 -0700 Subject: [PATCH 04/13] Fix HOST-AUTH-002 CLI test isolation --- packages/cli/src/lib/host/spawn.test.ts | 14 +- packages/cli/src/lib/resolve-auth.test.ts | 428 +++++++++++++--------- 2 files changed, 266 insertions(+), 176 deletions(-) diff --git a/packages/cli/src/lib/host/spawn.test.ts b/packages/cli/src/lib/host/spawn.test.ts index 8a1b05ea82a..390eb38a467 100644 --- a/packages/cli/src/lib/host/spawn.test.ts +++ b/packages/cli/src/lib/host/spawn.test.ts @@ -40,7 +40,7 @@ const fetchMock = mock(async () => new Response(null, { status: 200 })); globalThis.fetch = fetchMock as unknown as typeof fetch; const { spawnHostService } = await import("./spawn"); -const { writeConfig } = await import("../config"); +const { SUPERSET_HOME_DIR, writeConfig } = await import("../config"); function createApiClient(): ApiClient { return { @@ -69,6 +69,10 @@ function lastSpawnEnv(): NodeJS.ProcessEnv { return options.env; } +function activeConfigPath(): string { + return path.join(SUPERSET_HOME_DIR, "config.json"); +} + async function spawnWithToken(sessionToken: string) { return spawnHostService({ organizationId: "org_test", @@ -85,8 +89,8 @@ afterEach(() => { childProcess.unref.mockClear(); fetchMock.mockClear(); fs.rmSync(path.join(tempHome, "host"), { recursive: true, force: true }); - fs.rmSync(path.join(tempHome, "config.json"), { force: true }); - fs.rmSync(path.join(tempHome, "config.json.tmp"), { force: true }); + fs.rmSync(activeConfigPath(), { force: true }); + fs.rmSync(`${activeConfigPath()}.tmp`, { force: true }); }); afterAll(() => { @@ -104,9 +108,7 @@ describe("spawnHostService", () => { const env = lastSpawnEnv(); expect(env.AUTH_TOKEN).toBe("access-token-for-bootstrap"); - expect(env.SUPERSET_AUTH_CONFIG_PATH).toBe( - path.join(tempHome, "config.json"), - ); + expect(env.SUPERSET_AUTH_CONFIG_PATH).toBe(activeConfigPath()); }); it("does not pass the stored refresh token through the host child env", async () => { diff --git a/packages/cli/src/lib/resolve-auth.test.ts b/packages/cli/src/lib/resolve-auth.test.ts index 3aa91c960ea..fa0b4b254ec 100644 --- a/packages/cli/src/lib/resolve-auth.test.ts +++ b/packages/cli/src/lib/resolve-auth.test.ts @@ -1,220 +1,308 @@ -import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { LoginResult } from "@superset/shared/auth/token-refresh"; - -const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; -const tempHome = fs.mkdtempSync( - path.join(os.tmpdir(), "superset-cli-resolve-auth-"), -); -process.env.SUPERSET_HOME_DIR = tempHome; - -interface HostServiceManifest { - pid: number; - endpoint: string; - authToken: string; - startedAt: number; - organizationId: string; -} -let refreshAccessTokenImpl = async ( - refreshToken: string, -): Promise => ({ - accessToken: "refreshed-access-token", - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, -}); -const refreshAccessTokenMock = mock((refreshToken: string) => - refreshAccessTokenImpl(refreshToken), -); - -let hostManifest: HostServiceManifest | null = null; -const alivePids = new Set(); -const readManifestMock = mock((organizationId: string) => - hostManifest?.organizationId === organizationId ? hostManifest : null, -); -const isProcessAliveMock = mock((pid: number) => alivePids.has(pid)); - -mock.module("@superset/shared/auth/token-refresh", () => ({ - refreshAccessToken: refreshAccessTokenMock, -})); - -mock.module("./host/manifest", () => ({ - readManifest: readManifestMock, - isProcessAlive: isProcessAliveMock, -})); - -const { resolveAuth } = await import("./resolve-auth"); -const { readConfig, writeConfig } = await import("./config"); - -function clearConfig(): void { - writeConfig({}); -} +const tempHomes: string[] = []; -afterEach(() => { - clearConfig(); - refreshAccessTokenMock.mockClear(); - refreshAccessTokenImpl = async (refreshToken: string) => ({ - accessToken: "refreshed-access-token", - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, +type ScenarioResult = Record; + +function runScenario(source: string): ScenarioResult { + const tempHome = fs.mkdtempSync( + path.join(os.tmpdir(), "superset-cli-resolve-auth-"), + ); + tempHomes.push(tempHome); + + const result = spawnSync(process.execPath, ["--eval", source], { + cwd: process.cwd(), + env: { + ...process.env, + SUPERSET_HOME_DIR: tempHome, + SUPERSET_API_URL: "https://api.example.com", + }, + encoding: "utf-8", + maxBuffer: 1024 * 1024, }); - hostManifest = null; - alivePids.clear(); - readManifestMock.mockClear(); - isProcessAliveMock.mockClear(); -}); + + if (result.status !== 0) { + throw new Error( + [ + `scenario failed with exit ${result.status}`, + "--- stdout ---", + result.stdout, + "--- stderr ---", + result.stderr, + ].join("\n"), + ); + } + + const output = result.stdout.trim().split("\n").at(-1); + if (!output) { + throw new Error("scenario produced no JSON output"); + } + return JSON.parse(output) as ScenarioResult; +} + +function scenario(body: string): ScenarioResult { + return runScenario(` + const { resolveAuth } = await import("./src/lib/resolve-auth.ts"); + const { readConfig, writeConfig } = await import("./src/lib/config.ts"); + const { writeManifest } = await import("./src/lib/host/manifest.ts"); + + ${body} + `); +} afterAll(() => { - fs.rmSync(tempHome, { recursive: true, force: true }); - if (originalSupersetHomeDir === undefined) { - delete process.env.SUPERSET_HOME_DIR; - } else { - process.env.SUPERSET_HOME_DIR = originalSupersetHomeDir; + for (const tempHome of tempHomes) { + fs.rmSync(tempHome, { recursive: true, force: true }); } }); describe("resolveAuth", () => { - it("throws when no override and no stored credentials", async () => { - await expect(resolveAuth(undefined)).rejects.toThrow(/Not logged in/); + it("throws when no override and no stored credentials", () => { + const result = scenario(` + try { + await resolveAuth(undefined); + console.log(JSON.stringify({ ok: true })); + } catch (error) { + console.log(JSON.stringify({ + ok: false, + message: error instanceof Error ? error.message : String(error), + })); + } + `); + + expect(result.ok).toBe(false); + expect(result.message).toMatch(/Not logged in/); }); - it("uses an override api key with 'override' source", async () => { - const result = await resolveAuth("sk_live_override"); + it("uses an override api key with 'override' source", () => { + const result = scenario(` + const resolved = await resolveAuth("sk_live_override"); + console.log(JSON.stringify({ + bearer: resolved.bearer, + authSource: resolved.authSource, + })); + `); + expect(result.bearer).toBe("sk_live_override"); expect(result.authSource).toBe("override"); }); - it("uses a stored apiKey from config with 'config' source", async () => { - writeConfig({ apiKey: "sk_live_stored", organizationId: "org_1" }); - const result = await resolveAuth(undefined); + it("uses a stored apiKey from config with 'config' source", () => { + const result = scenario(` + writeConfig({ apiKey: "sk_live_stored", organizationId: "org_1" }); + const resolved = await resolveAuth(undefined); + console.log(JSON.stringify({ + bearer: resolved.bearer, + authSource: resolved.authSource, + organizationId: resolved.config.organizationId, + })); + `); + expect(result.bearer).toBe("sk_live_stored"); expect(result.authSource).toBe("config"); - expect(result.config.organizationId).toBe("org_1"); + expect(result.organizationId).toBe("org_1"); }); - it("uses a stored OAuth session when present and unexpired", async () => { - const future = Date.now() + 60 * 60 * 1000; - writeConfig({ - auth: { - accessToken: "oauth-token", - refreshToken: "oauth-refresh", - expiresAt: future, - }, - }); - const result = await resolveAuth(undefined); + it("uses a stored OAuth session when present and unexpired", () => { + const result = scenario(` + writeConfig({ + auth: { + accessToken: "oauth-token", + refreshToken: "oauth-refresh", + expiresAt: Date.now() + 60 * 60 * 1000, + }, + }); + const resolved = await resolveAuth(undefined); + console.log(JSON.stringify({ + bearer: resolved.bearer, + authSource: resolved.authSource, + })); + `); + expect(result.bearer).toBe("oauth-token"); expect(result.authSource).toBe("oauth"); }); - it("defers OAuth refresh to the host while the host process is alive", async () => { - const pid = 24_680; - writeConfig({ - organizationId: "org_1", - auth: { - accessToken: "near-expiry-access-token", - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - hostManifest = { - pid, - endpoint: "http://127.0.0.1:4879", - authToken: "host-secret", - startedAt: Date.now(), - organizationId: "org_1", - }; - alivePids.add(pid); - - const result = await resolveAuth(undefined); + it("defers OAuth refresh to the host while the host process is alive", () => { + const result = scenario(` + let refreshCalls = 0; + globalThis.fetch = async () => { + refreshCalls += 1; + throw new Error("refresh should be deferred to the host"); + }; + writeConfig({ + organizationId: "org_1", + auth: { + accessToken: "near-expiry-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + writeManifest({ + pid: process.pid, + endpoint: "http://127.0.0.1:4879", + authToken: "host-secret", + startedAt: Date.now(), + organizationId: "org_1", + }); + const resolved = await resolveAuth(undefined); + console.log(JSON.stringify({ + bearer: resolved.bearer, + authSource: resolved.authSource, + refreshCalls, + config: readConfig(), + })); + `); expect(result.bearer).toBe("near-expiry-access-token"); expect(result.authSource).toBe("oauth"); - expect(refreshAccessTokenMock).not.toHaveBeenCalled(); - expect(readManifestMock).toHaveBeenCalledWith("org_1"); - expect(isProcessAliveMock).toHaveBeenCalledWith(pid); - expect(readConfig().auth?.accessToken).toBe("near-expiry-access-token"); - }); - - it("refreshes OAuth credentials when the host manifest process is not alive", async () => { - const pid = 24_681; - const refreshedExpiresAt = Date.now() + 60 * 60 * 1000; - refreshAccessTokenImpl = async () => ({ - accessToken: "refreshed-access-token", - refreshToken: "rotated-refresh-token", - expiresAt: refreshedExpiresAt, + expect(result.refreshCalls).toBe(0); + expect(result.config).toMatchObject({ + auth: { accessToken: "near-expiry-access-token" }, }); - writeConfig({ - organizationId: "org_1", - auth: { - accessToken: "near-expiry-access-token", - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - hostManifest = { - pid, - endpoint: "http://127.0.0.1:4879", - authToken: "host-secret", - startedAt: Date.now(), - organizationId: "org_1", - }; + }); - const result = await resolveAuth(undefined); + it("refreshes OAuth credentials when the host manifest process is not alive", () => { + const result = scenario(` + let refreshCalls = 0; + globalThis.fetch = async () => { + refreshCalls += 1; + return new Response(JSON.stringify({ + access_token: "refreshed-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rotated-refresh-token", + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + writeConfig({ + organizationId: "org_1", + auth: { + accessToken: "near-expiry-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + writeManifest({ + pid: 99999999, + endpoint: "http://127.0.0.1:4879", + authToken: "host-secret", + startedAt: Date.now(), + organizationId: "org_1", + }); + const resolved = await resolveAuth(undefined); + console.log(JSON.stringify({ + bearer: resolved.bearer, + authSource: resolved.authSource, + refreshCalls, + config: readConfig(), + })); + `); expect(result.bearer).toBe("refreshed-access-token"); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - expect(refreshAccessTokenMock).toHaveBeenCalledWith("refresh-token"); - expect(isProcessAliveMock).toHaveBeenCalledWith(pid); - expect(readConfig().auth).toEqual({ - accessToken: "refreshed-access-token", - refreshToken: "rotated-refresh-token", - expiresAt: refreshedExpiresAt, - }); - }); - - it("refreshes OAuth credentials when no host manifest exists", async () => { - writeConfig({ - organizationId: "org_1", + expect(result.authSource).toBe("oauth"); + expect(result.refreshCalls).toBe(1); + expect(result.config).toMatchObject({ auth: { - accessToken: "near-expiry-access-token", - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, + accessToken: "refreshed-access-token", + refreshToken: "rotated-refresh-token", }, }); + }); - const result = await resolveAuth(undefined); + it("refreshes OAuth credentials when no host manifest exists", () => { + const result = scenario(` + let refreshCalls = 0; + globalThis.fetch = async () => { + refreshCalls += 1; + return new Response(JSON.stringify({ + access_token: "refreshed-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rotated-refresh-token", + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + writeConfig({ + organizationId: "org_1", + auth: { + accessToken: "near-expiry-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const resolved = await resolveAuth(undefined); + console.log(JSON.stringify({ + bearer: resolved.bearer, + authSource: resolved.authSource, + refreshCalls, + config: readConfig(), + })); + `); expect(result.bearer).toBe("refreshed-access-token"); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - expect(readManifestMock).toHaveBeenCalledWith("org_1"); - expect(isProcessAliveMock).not.toHaveBeenCalled(); + expect(result.authSource).toBe("oauth"); + expect(result.refreshCalls).toBe(1); }); - it("throws when OAuth session is expired and there is no refresh token", async () => { - writeConfig({ - auth: { accessToken: "stale", expiresAt: Date.now() - 1000 }, - }); - await expect(resolveAuth(undefined)).rejects.toThrow(/Session expired/); + it("throws when OAuth session is expired and there is no refresh token", () => { + const result = scenario(` + writeConfig({ + auth: { accessToken: "stale", expiresAt: Date.now() - 1000 }, + }); + try { + await resolveAuth(undefined); + console.log(JSON.stringify({ ok: true })); + } catch (error) { + console.log(JSON.stringify({ + ok: false, + message: error instanceof Error ? error.message : String(error), + })); + } + `); + + expect(result.ok).toBe(false); + expect(result.message).toMatch(/Session expired/); }); - it("prefers an override over a stored apiKey", async () => { - writeConfig({ apiKey: "sk_live_stored" }); - const result = await resolveAuth("sk_live_override"); + it("prefers an override over a stored apiKey", () => { + const result = scenario(` + writeConfig({ apiKey: "sk_live_stored" }); + const resolved = await resolveAuth("sk_live_override"); + console.log(JSON.stringify({ + bearer: resolved.bearer, + authSource: resolved.authSource, + })); + `); + expect(result.bearer).toBe("sk_live_override"); expect(result.authSource).toBe("override"); }); - it("prefers a stored apiKey over a stored OAuth session", async () => { - writeConfig({ - apiKey: "sk_live_stored", - auth: { - accessToken: "oauth-token", - expiresAt: Date.now() + 60 * 60 * 1000, - }, - }); - const result = await resolveAuth(undefined); + it("prefers a stored apiKey over a stored OAuth session", () => { + const result = scenario(` + writeConfig({ + apiKey: "sk_live_stored", + auth: { + accessToken: "oauth-token", + expiresAt: Date.now() + 60 * 60 * 1000, + }, + }); + const resolved = await resolveAuth(undefined); + console.log(JSON.stringify({ + bearer: resolved.bearer, + authSource: resolved.authSource, + })); + `); + expect(result.bearer).toBe("sk_live_stored"); expect(result.authSource).toBe("config"); }); From e94b5ad81507049b09a787238b2f2be140751bb8 Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 15:56:09 -0700 Subject: [PATCH 05/13] HOST-AUTH-003 session expiry handling --- .../src/commands/auth/logout/command.test.ts | 147 ++++++++ .../cli/src/commands/auth/logout/command.ts | 41 +++ packages/host-service/src/app.ts | 2 + packages/host-service/src/errors.ts | 7 +- packages/host-service/src/events/event-bus.ts | 18 + packages/host-service/src/events/index.ts | 3 + packages/host-service/src/events/types.ts | 19 + .../JwtApiAuthProvider.test.ts | 337 +++++++++++++++++- .../JwtApiAuthProvider/JwtApiAuthProvider.ts | 191 +++++++++- .../auth/JwtApiAuthProvider/index.ts | 2 + .../host-service/src/providers/auth/hint.ts | 2 + .../host-service/src/providers/auth/index.ts | 3 +- .../host-service/src/providers/auth/types.ts | 17 + .../src/trpc/auth-expired-middleware.test.ts | 71 ++++ packages/host-service/src/trpc/error-types.ts | 8 + packages/host-service/src/trpc/index.ts | 7 + packages/host-service/src/types.ts | 2 + 17 files changed, 856 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/commands/auth/logout/command.test.ts create mode 100644 packages/host-service/src/providers/auth/hint.ts create mode 100644 packages/host-service/src/trpc/auth-expired-middleware.test.ts diff --git a/packages/cli/src/commands/auth/logout/command.test.ts b/packages/cli/src/commands/auth/logout/command.test.ts new file mode 100644 index 00000000000..5ab46c64c35 --- /dev/null +++ b/packages/cli/src/commands/auth/logout/command.test.ts @@ -0,0 +1,147 @@ +import { afterAll, afterEach, describe, expect, it, spyOn } from "bun:test"; +import * as fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; +const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "superset-cli-logout-")); +process.env.SUPERSET_HOME_DIR = tempHome; + +const { readConfig, writeConfig } = await import("../../../lib/config"); +const { writeManifest } = await import("../../../lib/host/manifest"); +const { default: logoutCommand } = await import("./command"); + +function noSuchProcessError(): NodeJS.ErrnoException { + const error = new Error("No such process") as NodeJS.ErrnoException; + error.code = "ESRCH"; + return error; +} + +function writeLoggedInConfig(): void { + writeConfig({ + organizationId: "org_1", + apiKey: "sk_live_existing", + auth: { + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); +} + +function writeHostManifest(pid: number): void { + writeManifest({ + pid, + endpoint: "http://127.0.0.1:49152", + authToken: "host-token", + startedAt: Date.now(), + organizationId: "org_1", + }); +} + +async function runLogout(): Promise { + await logoutCommand.run({ + options: {}, + args: {}, + ctx: {}, + signal: new AbortController().signal, + }); +} + +afterEach(() => { + fs.rmSync(path.join(tempHome, "config.json"), { force: true }); + fs.rmSync(path.join(tempHome, "config.json.tmp"), { force: true }); + fs.rmSync(path.join(tempHome, "host"), { recursive: true, force: true }); +}); + +afterAll(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + if (originalSupersetHomeDir === undefined) { + delete process.env.SUPERSET_HOME_DIR; + } else { + process.env.SUPERSET_HOME_DIR = originalSupersetHomeDir; + } +}); + +describe("auth logout", () => { + it("sends SIGTERM to the running host before clearing credentials", async () => { + const pid = 91_001; + writeLoggedInConfig(); + writeHostManifest(pid); + const order: string[] = []; + let sigtermSent = false; + let checksAfterSigterm = 0; + const killSpy = spyOn(process, "kill").mockImplementation((( + targetPid: number, + signal?: NodeJS.Signals | number, + ) => { + expect(targetPid).toBe(pid); + if (signal === 0) { + order.push(sigtermSent ? "alive-after-sigterm" : "alive-before"); + if (!sigtermSent) return true; + checksAfterSigterm += 1; + if (checksAfterSigterm < 2) return true; + throw noSuchProcessError(); + } + + expect(signal).toBe("SIGTERM"); + order.push("sigterm"); + expect(readConfig().auth?.refreshToken).toBe("refresh-token"); + sigtermSent = true; + return true; + }) as typeof process.kill); + + try { + await runLogout(); + } finally { + killSpy.mockRestore(); + } + + expect(order).toEqual([ + "alive-before", + "sigterm", + "alive-after-sigterm", + "alive-after-sigterm", + ]); + expect(readConfig()).toEqual({ organizationId: "org_1" }); + }); + + it("waits up to five seconds for host death, then clears credentials", async () => { + const pid = 91_002; + writeLoggedInConfig(); + writeHostManifest(pid); + let now = 1_700_000_000_000; + const nowSpy = spyOn(Date, "now").mockImplementation(() => now); + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + handler: Parameters[0], + timeout?: Parameters[1], + ...args: unknown[] + ) => { + if (typeof timeout === "number") now += timeout; + if (typeof handler === "function") { + const callback = handler as (...callbackArgs: unknown[]) => void; + callback(...args); + } + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + const killSpy = spyOn(process, "kill").mockImplementation((( + targetPid: number, + signal?: NodeJS.Signals | number, + ) => { + expect(targetPid).toBe(pid); + expect(signal === 0 || signal === "SIGTERM").toBe(true); + return true; + }) as typeof process.kill); + + try { + await runLogout(); + expect(timeoutSpy).toHaveBeenCalledTimes(50); + } finally { + killSpy.mockRestore(); + timeoutSpy.mockRestore(); + nowSpy.mockRestore(); + } + + expect(readConfig()).toEqual({ organizationId: "org_1" }); + }); +}); diff --git a/packages/cli/src/commands/auth/logout/command.ts b/packages/cli/src/commands/auth/logout/command.ts index d103ff4dc3e..ca373469bb9 100644 --- a/packages/cli/src/commands/auth/logout/command.ts +++ b/packages/cli/src/commands/auth/logout/command.ts @@ -1,11 +1,52 @@ +import { CLIError } from "@superset/cli-framework"; import { command } from "../../../lib/command"; import { readConfig, writeConfig } from "../../../lib/config"; +import { isProcessAlive, readManifest } from "../../../lib/host/manifest"; + +const HOST_SHUTDOWN_TIMEOUT_MS = 5_000; +const HOST_SHUTDOWN_POLL_MS = 100; + +function isNoSuchProcessError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code: unknown }).code === "ESRCH" + ); +} + +async function stopRunningHost( + organizationId: string | undefined, +): Promise { + if (!organizationId) return; + + const manifest = readManifest(organizationId); + if (!manifest || !isProcessAlive(manifest.pid)) return; + + try { + process.kill(manifest.pid, "SIGTERM"); + } catch (error) { + if (isNoSuchProcessError(error)) return; + throw new CLIError( + `Failed to stop host service (pid ${manifest.pid}): ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } + + const deadline = Date.now() + HOST_SHUTDOWN_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!isProcessAlive(manifest.pid)) return; + await new Promise((resolve) => setTimeout(resolve, HOST_SHUTDOWN_POLL_MS)); + } +} export default command({ description: "Clear stored credentials", skipMiddleware: true, run: async () => { const config = readConfig(); + await stopRunningHost(config.organizationId); delete config.auth; delete config.apiKey; writeConfig(config); diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 41e8d331f56..0c91932c4f8 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -135,6 +135,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { const eventBus = new EventBus({ db, filesystem, gitWatcher }); eventBus.start(); + providers.auth.setEventBus?.(eventBus); // Backfill `kind='main'` v2 workspaces for projects already set up before // this column shipped. Idempotent; runs in the background so it doesn't @@ -192,6 +193,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { db, runtime, eventBus, + authProvider: providers.auth, organizationId: config.organizationId, isAuthenticated, } as Record; diff --git a/packages/host-service/src/errors.ts b/packages/host-service/src/errors.ts index e624e111a58..6b2b9aa83db 100644 --- a/packages/host-service/src/errors.ts +++ b/packages/host-service/src/errors.ts @@ -1,5 +1,8 @@ -export const AUTH_REFRESH_FAILED_MESSAGE = - "Superset session expired — run `superset auth login`"; +import { SESSION_EXPIRED_HINT } from "./providers/auth/hint"; + +export { SESSION_EXPIRED_HINT }; + +export const AUTH_REFRESH_FAILED_MESSAGE = SESSION_EXPIRED_HINT; export type AuthRefreshFailureReason = | "invalid_grant" diff --git a/packages/host-service/src/events/event-bus.ts b/packages/host-service/src/events/event-bus.ts index a010a88f04e..3605e4a587b 100644 --- a/packages/host-service/src/events/event-bus.ts +++ b/packages/host-service/src/events/event-bus.ts @@ -182,6 +182,24 @@ export class EventBus { this.broadcast({ type: "terminal:lifecycle", ...message }); } + broadcastAuthSessionExpired( + message: Omit< + Extract, + "type" + >, + ): void { + this.broadcast({ type: "auth:session_expired", ...message }); + } + + broadcastAuthSessionRestored( + message: Omit< + Extract, + "type" + >, + ): void { + this.broadcast({ type: "auth:session_restored", ...message }); + } + /** * Fan out port add/remove events discovered by the host-service scanner. * Renderer clients use this to patch their host snapshot immediately while diff --git a/packages/host-service/src/events/index.ts b/packages/host-service/src/events/index.ts index 585bde04ada..6f44489b483 100644 --- a/packages/host-service/src/events/index.ts +++ b/packages/host-service/src/events/index.ts @@ -6,6 +6,9 @@ export { } from "./map-event-type.ts"; export type { AgentLifecycleMessage, + AuthSessionExpiredMessage, + AuthSessionExpiredReason, + AuthSessionRestoredMessage, ClientMessage, EventBusErrorMessage, FsEventsMessage, diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index a26139820d2..5dff3c272e5 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -53,6 +53,23 @@ export interface PortChangedMessage { occurredAt: number; } +export type AuthSessionExpiredReason = + | "invalid_grant" + | "network_error" + | "http_error"; + +export interface AuthSessionExpiredMessage { + type: "auth:session_expired"; + reason: AuthSessionExpiredReason; + hint: string; + occurredAt: number; +} + +export interface AuthSessionRestoredMessage { + type: "auth:session_restored"; + occurredAt: number; +} + export interface EventBusErrorMessage { type: "error"; message: string; @@ -64,6 +81,8 @@ export type ServerMessage = | AgentLifecycleMessage | TerminalLifecycleMessage | PortChangedMessage + | AuthSessionExpiredMessage + | AuthSessionRestoredMessage | EventBusErrorMessage; // ── Client → Server ──────────────────────────────────────────────── diff --git a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts index a0e88443ff7..bc520873759 100644 --- a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts +++ b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts @@ -11,6 +11,11 @@ import * as fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { LoginResult } from "@superset/shared/auth/token-refresh"; +import type { + AuthSessionExpiredMessage, + AuthSessionRestoredMessage, +} from "../../../events"; +import type { AuthSessionEventPublisher } from "../types"; let refreshAccessTokenImpl = async ( refreshToken: string, @@ -28,9 +33,11 @@ mock.module("@superset/shared/auth/token-refresh", () => ({ })); const { JwtApiAuthProvider } = await import("./JwtApiAuthProvider"); -const { AUTH_REFRESH_FAILED_MESSAGE, AuthRefreshFailedError } = await import( - "../../../errors" -); +const { + AUTH_REFRESH_FAILED_MESSAGE, + AuthRefreshFailedError, + SESSION_EXPIRED_HINT, +} = await import("../../../errors"); const tempRoot = fs.mkdtempSync( path.join(os.tmpdir(), "superset-host-jwt-api-auth-"), @@ -88,16 +95,74 @@ function readConfig(configPath: string): { }; } +interface RecordedAuthEvents { + eventBus: AuthSessionEventPublisher; + expired: Array>; + restored: Array>; +} + +function createAuthEvents(): RecordedAuthEvents { + const expired: Array> = []; + const restored: Array> = []; + return { + expired, + restored, + eventBus: { + broadcastAuthSessionExpired: (message) => expired.push(message), + broadcastAuthSessionRestored: (message) => restored.push(message), + }, + }; +} + function createProvider( configPath: string, + eventBus?: AuthSessionEventPublisher, ): InstanceType { return new JwtApiAuthProvider({ getSessionToken: async () => "bootstrap-access-token", apiUrl: "https://api.example.com", authConfigPath: configPath, + eventBus, }); } +function mockNow(initialNow: number): { + advance: (ms: number) => void; + restore: () => void; +} { + let now = initialNow; + const nowSpy = spyOn(Date, "now").mockImplementation(() => now); + return { + advance: (ms: number) => { + now += ms; + }, + restore: () => nowSpy.mockRestore(), + }; +} + +async function captureProcessErrors( + run: () => Promise, +): Promise { + const errors: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + errors.push(reason); + }; + const onUncaughtException = (error: Error) => { + errors.push(error); + }; + + process.on("unhandledRejection", onUnhandledRejection); + process.on("uncaughtException", onUncaughtException); + try { + await run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + process.off("uncaughtException", onUncaughtException); + } + return errors; +} + afterEach(() => { refreshAccessTokenMock.mockClear(); refreshAccessTokenImpl = async (refreshToken: string) => ({ @@ -235,6 +300,272 @@ describe("JwtApiAuthProvider", () => { }); }); + it("emits one auth:session_expired event with the exact hint and wipes refresh token on invalid_grant", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + refreshAccessTokenImpl = async () => { + throw new Error("Token refresh failed: 401 invalid_grant"); + }; + writeConfig(configPath, { + organizationId: "org_1", + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "invalid_grant", + statusCode: 401, + }); + + expect(provider.getAuthState()).toMatchObject({ + kind: "expired_permanent", + reason: "invalid_grant", + statusCode: 401, + }); + expect(provider.isInAnyExpiredState()).toBe(true); + expect(events.expired).toEqual([ + { + reason: "invalid_grant", + hint: SESSION_EXPIRED_HINT, + occurredAt: Date.now(), + }, + ]); + expect(events.restored).toEqual([]); + expect(readConfig(configPath)).toEqual({ + organizationId: "org_1", + auth: { + accessToken: expect.any(String), + expiresAt: expect.any(Number), + }, + }); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "invalid_grant", + statusCode: 401, + }); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(events.expired).toHaveLength(1); + expect(events.restored).toHaveLength(0); + } finally { + clock.restore(); + } + }); + + it("records transient network failures, preserves refresh token, and suppresses retry inside 60 seconds", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + refreshAccessTokenImpl = async () => { + throw new TypeError("fetch failed"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "network_error", + }); + + expect(provider.getAuthState()).toEqual({ + kind: "expired_transient", + reason: "network_error", + lastFailureAt: Date.now(), + statusCode: undefined, + }); + expect(readConfig(configPath).auth?.refreshToken).toBe("refresh-token"); + expect(events.expired).toEqual([ + { + reason: "network_error", + hint: SESSION_EXPIRED_HINT, + occurredAt: Date.now(), + }, + ]); + + for (let i = 0; i < 20; i += 1) { + clock.advance(1_000); + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "network_error", + }); + } + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(events.expired).toHaveLength(1); + expect(events.restored).toHaveLength(0); + } finally { + clock.restore(); + } + }); + + it("records transient 5xx failures and preserves the refresh token", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + refreshAccessTokenImpl = async () => { + throw new Error("Token refresh failed: 503"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "http_error", + statusCode: 503, + }); + + expect(provider.getAuthState()).toEqual({ + kind: "expired_transient", + reason: "http_error", + lastFailureAt: Date.now(), + statusCode: 503, + }); + expect(readConfig(configPath).auth?.refreshToken).toBe("refresh-token"); + expect(events.expired).toEqual([ + { + reason: "http_error", + hint: SESSION_EXPIRED_HINT, + occurredAt: Date.now(), + }, + ]); + expect(events.restored).toEqual([]); + } finally { + clock.restore(); + } + }); + + it("retries a transient failure after 60 seconds and broadcasts auth:session_restored once on success", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + const refreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); + refreshAccessTokenImpl = async () => { + throw new TypeError("fetch failed"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + reason: "network_error", + }); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + + clock.advance(61_000); + refreshAccessTokenImpl = async (refreshToken: string) => ({ + accessToken: refreshedToken, + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, + }); + + await expect(provider.getSessionToken()).resolves.toBe(refreshedToken); + + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + expect(provider.getAuthState()).toEqual({ kind: "healthy" }); + expect(provider.isInAnyExpiredState()).toBe(false); + expect(events.expired).toHaveLength(1); + expect(events.restored).toEqual([{ occurredAt: Date.now() }]); + + await expect(provider.getSessionToken()).resolves.toBe(refreshedToken); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + expect(events.restored).toHaveLength(1); + } finally { + clock.restore(); + } + }); + + it("updates transient lastFailureAt after a retry failure without re-emitting auth:session_expired", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + refreshAccessTokenImpl = async () => { + throw new TypeError("fetch failed"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + reason: "network_error", + }); + clock.advance(61_000); + await expect(provider.getSessionToken()).rejects.toMatchObject({ + reason: "network_error", + }); + + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + expect(provider.getAuthState()).toEqual({ + kind: "expired_transient", + reason: "network_error", + lastFailureAt: Date.now(), + statusCode: undefined, + }); + expect(events.expired).toHaveLength(1); + expect(events.restored).toHaveLength(0); + } finally { + clock.restore(); + } + }); + + it("does not emit process-level error events for permanent or transient refresh failures", async () => { + const errors = await captureProcessErrors(async () => { + for (const failure of [ + () => new Error("Token refresh failed: 401 invalid_grant"), + () => new TypeError("fetch failed"), + () => new Error("Token refresh failed: 503"), + ]) { + const configPath = createConfigPath(); + refreshAccessTokenImpl = async () => { + throw failure(); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + await expect( + createProvider(configPath).getSessionToken(), + ).rejects.toBeInstanceOf(AuthRefreshFailedError); + } + }); + + expect(errors).toEqual([]); + }); + it("uses the exact refresh failure hint without leaking token, URL, or response body", async () => { const configPath = createConfigPath(); const leakedToken = "refresh-token-secret"; diff --git a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts index 92ce660f50a..016f41d425c 100644 --- a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts +++ b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts @@ -5,10 +5,12 @@ import { AuthRefreshFailedError, type AuthRefreshFailureReason, } from "../../../errors"; -import type { ApiAuthProvider } from "../types"; +import { SESSION_EXPIRED_HINT } from "../hint"; +import type { ApiAuthProvider, AuthSessionEventPublisher } from "../types"; const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000; const JWT_CACHE_DURATION_MS = 55 * 60 * 1000; +const TRANSIENT_RETRY_INTERVAL_MS = 60 * 1000; interface SupersetAuthConfig { accessToken: string; @@ -28,6 +30,23 @@ interface RefreshFailureClassification { statusCode?: number; } +export type JwtApiAuthProviderExpiredState = + | { + kind: "expired_permanent"; + reason: "invalid_grant"; + statusCode?: number; + } + | { + kind: "expired_transient"; + reason: "network_error" | "http_error"; + lastFailureAt: number; + statusCode?: number; + }; + +export type JwtApiAuthProviderAuthState = + | { kind: "healthy" } + | JwtApiAuthProviderExpiredState; + export interface JwtApiAuthProviderOptions { /** * Returns the current session/api-key/JWT token to authenticate with. @@ -37,6 +56,7 @@ export interface JwtApiAuthProviderOptions { getSessionToken: () => Promise; apiUrl: string; authConfigPath?: string; + eventBus?: AuthSessionEventPublisher; } function looksLikeJwt(token: string): boolean { @@ -92,12 +112,25 @@ function readStatusCode(error: unknown): number | undefined { return Number.parseInt(match[1], 10); } +function errorIndicatesInvalidGrant(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + const suggestion = + isObject(error) && typeof error.suggestion === "string" + ? error.suggestion + : ""; + return /\binvalid_grant\b/i.test(`${message}\n${suggestion}`); +} + function reasonForRefreshError(error: unknown): RefreshFailureClassification { const statusCode = readStatusCode(error); if (statusCode === undefined) { return { reason: "network_error" }; } - if (statusCode === 400 || statusCode === 401 || statusCode === 403) { + if ( + statusCode === 401 || + ((statusCode === 400 || statusCode === 403) && + errorIndicatesInvalidGrant(error)) + ) { return { reason: "invalid_grant", statusCode }; } return { reason: "http_error", statusCode }; @@ -131,16 +164,19 @@ export class JwtApiAuthProvider implements ApiAuthProvider { private readonly loadSessionToken: () => Promise; private readonly apiUrl: string; private readonly authConfigPath: string | undefined; + private eventBus: AuthSessionEventPublisher | undefined; private cachedJwt: string | null = null; private cachedJwtSessionToken: string | null = null; private cachedJwtExpiresAt = 0; private currentCredential: SupersetAuthConfig | null = null; private inflightRefresh: Promise | null = null; + private expired: JwtApiAuthProviderExpiredState | null = null; constructor(options: JwtApiAuthProviderOptions) { this.loadSessionToken = options.getSessionToken; this.apiUrl = options.apiUrl; this.authConfigPath = options.authConfigPath; + this.eventBus = options.eventBus; } async getHeaders(): Promise> { @@ -155,11 +191,45 @@ export class JwtApiAuthProvider implements ApiAuthProvider { this.currentCredential = null; } + setEventBus(eventBus: AuthSessionEventPublisher): void { + this.eventBus = eventBus; + } + + isInAnyExpiredState(): boolean { + return this.expired !== null; + } + + isInExpiredState(): boolean { + return this.isInAnyExpiredState(); + } + + getAuthState(): JwtApiAuthProviderAuthState { + if (!this.expired) return { kind: "healthy" }; + return { ...this.expired }; + } + async getSessionToken(): Promise { if (!this.authConfigPath) { return this.loadSessionToken(); } + if (this.expired?.kind === "expired_permanent") { + throw new AuthRefreshFailedError({ + reason: this.expired.reason, + statusCode: this.expired.statusCode, + }); + } + + if (this.expired?.kind === "expired_transient") { + const elapsedMs = Date.now() - this.expired.lastFailureAt; + if (elapsedMs < TRANSIENT_RETRY_INTERVAL_MS) { + throw new AuthRefreshFailedError({ + reason: this.expired.reason, + statusCode: this.expired.statusCode, + }); + } + } + const credential = this.currentCredential ?? this.readCurrentCredential(); if (!credential) { return this.loadSessionToken(); @@ -167,7 +237,10 @@ export class JwtApiAuthProvider implements ApiAuthProvider { this.currentCredential = credential; const expiresAt = readJwtExp(credential.accessToken); - if (expiresAt === null || expiresAt - Date.now() > JWT_REFRESH_BUFFER_MS) { + const needsRefresh = + this.expired !== null || + (expiresAt !== null && expiresAt - Date.now() <= JWT_REFRESH_BUFFER_MS); + if (!needsRefresh) { return credential.accessToken; } @@ -226,10 +299,23 @@ export class JwtApiAuthProvider implements ApiAuthProvider { credential: SupersetAuthConfig, ): Promise { if (!credential.refreshToken) { + this.transitionToPermanent({ reason: "invalid_grant" }); + this.wipeRefreshToken(); throw new AuthRefreshFailedError({ reason: "invalid_grant" }); } - const refreshed = await this.runRefresh(credential.refreshToken); + let refreshed: SupersetAuthConfig; + try { + refreshed = await this.runRefresh(credential.refreshToken); + } catch (error) { + const failure = + error instanceof AuthRefreshFailedError + ? { reason: error.reason, statusCode: error.statusCode } + : reasonForRefreshError(error); + this.handleRefreshFailure(failure); + throw new AuthRefreshFailedError(failure); + } + const nextCredential: SupersetAuthConfig = { accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken ?? credential.refreshToken, @@ -248,21 +334,96 @@ export class JwtApiAuthProvider implements ApiAuthProvider { this.cachedJwt = null; this.cachedJwtSessionToken = null; this.cachedJwtExpiresAt = 0; + this.transitionToHealthy(); return nextCredential.accessToken; } private async runRefresh(refreshToken: string): Promise { - try { - const refreshed = await refreshAccessToken(refreshToken); - return { - accessToken: refreshed.accessToken, - refreshToken: refreshed.refreshToken ?? refreshToken, - expiresAt: refreshed.expiresAt, - }; - } catch (error) { - if (error instanceof AuthRefreshFailedError) throw error; - const options = reasonForRefreshError(error); - throw new AuthRefreshFailedError(options); + const refreshed = await refreshAccessToken(refreshToken); + return { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken ?? refreshToken, + expiresAt: refreshed.expiresAt, + }; + } + + private handleRefreshFailure(failure: RefreshFailureClassification): void { + if (failure.reason === "invalid_grant") { + this.transitionToPermanent({ + reason: failure.reason, + statusCode: failure.statusCode, + }); + this.wipeRefreshToken(); + return; + } + + this.transitionToTransient({ + reason: failure.reason, + statusCode: failure.statusCode, + }); + } + + private transitionToPermanent(failure: { + reason: "invalid_grant"; + statusCode?: number; + }): void { + const wasHealthy = this.expired === null; + const occurredAt = Date.now(); + this.expired = { + kind: "expired_permanent", + reason: failure.reason, + statusCode: failure.statusCode, + }; + if (wasHealthy) { + this.eventBus?.broadcastAuthSessionExpired({ + reason: failure.reason, + hint: SESSION_EXPIRED_HINT, + occurredAt, + }); + } + } + + private transitionToTransient(failure: { + reason: "network_error" | "http_error"; + statusCode?: number; + }): void { + const wasHealthy = this.expired === null; + const occurredAt = Date.now(); + this.expired = { + kind: "expired_transient", + reason: failure.reason, + lastFailureAt: occurredAt, + statusCode: failure.statusCode, + }; + if (wasHealthy) { + this.eventBus?.broadcastAuthSessionExpired({ + reason: failure.reason, + hint: SESSION_EXPIRED_HINT, + occurredAt, + }); + } + } + + private transitionToHealthy(): void { + const wasExpiredTransient = this.expired?.kind === "expired_transient"; + const occurredAt = Date.now(); + this.expired = null; + if (wasExpiredTransient) { + this.eventBus?.broadcastAuthSessionRestored({ occurredAt }); } } + + private wipeRefreshToken(): void { + if (!this.authConfigPath) return; + + const latestConfig = readConfigAtPath(this.authConfigPath); + if (!latestConfig.auth?.refreshToken) return; + + const nextAuth = { ...latestConfig.auth }; + delete nextAuth.refreshToken; + writeConfigAtPath(this.authConfigPath, { + ...latestConfig, + auth: nextAuth, + }); + } } diff --git a/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts b/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts index c459e9450a7..dee78e3dd8e 100644 --- a/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts +++ b/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts @@ -1,4 +1,6 @@ export { JwtApiAuthProvider, + type JwtApiAuthProviderAuthState, + type JwtApiAuthProviderExpiredState, type JwtApiAuthProviderOptions, } from "./JwtApiAuthProvider"; diff --git a/packages/host-service/src/providers/auth/hint.ts b/packages/host-service/src/providers/auth/hint.ts new file mode 100644 index 00000000000..3b1a505a868 --- /dev/null +++ b/packages/host-service/src/providers/auth/hint.ts @@ -0,0 +1,2 @@ +export const SESSION_EXPIRED_HINT = + "Superset session expired — run `superset auth login`"; diff --git a/packages/host-service/src/providers/auth/index.ts b/packages/host-service/src/providers/auth/index.ts index 328f994d7e3..69fdbb910a9 100644 --- a/packages/host-service/src/providers/auth/index.ts +++ b/packages/host-service/src/providers/auth/index.ts @@ -1,7 +1,8 @@ export { DeviceKeyApiAuthProvider } from "./DeviceKeyAuthProvider"; +export { SESSION_EXPIRED_HINT } from "./hint"; export { JwtApiAuthProvider, JwtApiAuthProvider as JwtAuthProvider, type JwtApiAuthProviderOptions, } from "./JwtApiAuthProvider"; -export type { ApiAuthProvider } from "./types"; +export type { ApiAuthProvider, AuthSessionEventPublisher } from "./types"; diff --git a/packages/host-service/src/providers/auth/types.ts b/packages/host-service/src/providers/auth/types.ts index e43e3ff75b7..4877e6e831b 100644 --- a/packages/host-service/src/providers/auth/types.ts +++ b/packages/host-service/src/providers/auth/types.ts @@ -1,3 +1,17 @@ +import type { + AuthSessionExpiredMessage, + AuthSessionRestoredMessage, +} from "../../events/types"; + +export interface AuthSessionEventPublisher { + broadcastAuthSessionExpired( + message: Omit, + ): void; + broadcastAuthSessionRestored( + message: Omit, + ): void; +} + export interface ApiAuthProvider { getHeaders(): Promise>; /** @@ -7,4 +21,7 @@ export interface ApiAuthProvider { * rotated, JWKS rolled). */ invalidateCache(): void; + isInAnyExpiredState?(): boolean; + isInExpiredState?(): boolean; + setEventBus?(eventBus: AuthSessionEventPublisher): void; } diff --git a/packages/host-service/src/trpc/auth-expired-middleware.test.ts b/packages/host-service/src/trpc/auth-expired-middleware.test.ts new file mode 100644 index 00000000000..0d43a72243c --- /dev/null +++ b/packages/host-service/src/trpc/auth-expired-middleware.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { ApiAuthProvider } from "../providers/auth"; +import { SESSION_EXPIRED_HINT } from "../providers/auth/hint"; +import type { HostServiceContext } from "../types"; +import { protectedProcedure, publicProcedure, router } from "./index"; + +function createContext(authProvider: ApiAuthProvider): HostServiceContext { + return { + isAuthenticated: true, + authProvider, + } as unknown as HostServiceContext; +} + +function createAuthProvider(expired: boolean): ApiAuthProvider { + return { + getHeaders: mock(async () => { + throw new Error("middleware must not refresh credentials"); + }), + invalidateCache: mock(() => {}), + isInAnyExpiredState: mock(() => expired), + }; +} + +describe("auth expired tRPC middleware", () => { + it("short-circuits expired_permanent protected procedures without invoking the resolver", async () => { + const resolver = mock(() => "resolved"); + const authProvider = createAuthProvider(true); + const testRouter = router({ + secured: protectedProcedure.query(() => resolver()), + public: publicProcedure.query(() => "public"), + }); + const caller = testRouter.createCaller(createContext(authProvider)); + + await expect(caller.secured()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: SESSION_EXPIRED_HINT, + }); + await expect(caller.public()).resolves.toBe("public"); + expect(resolver).not.toHaveBeenCalled(); + expect(authProvider.getHeaders).not.toHaveBeenCalled(); + }); + + it("short-circuits expired_transient protected procedures without invoking the resolver", async () => { + const resolver = mock(() => "resolved"); + const authProvider = createAuthProvider(true); + const testRouter = router({ + secured: protectedProcedure.query(() => resolver()), + }); + const caller = testRouter.createCaller(createContext(authProvider)); + + await expect(caller.secured()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: SESSION_EXPIRED_HINT, + }); + expect(resolver).not.toHaveBeenCalled(); + expect(authProvider.getHeaders).not.toHaveBeenCalled(); + }); + + it("invokes protected resolvers normally when auth state is healthy", async () => { + const resolver = mock(() => "resolved"); + const authProvider = createAuthProvider(false); + const testRouter = router({ + secured: protectedProcedure.query(() => resolver()), + }); + const caller = testRouter.createCaller(createContext(authProvider)); + + await expect(caller.secured()).resolves.toBe("resolved"); + expect(resolver).toHaveBeenCalledTimes(1); + expect(authProvider.getHeaders).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/host-service/src/trpc/error-types.ts b/packages/host-service/src/trpc/error-types.ts index 40ba5d0768b..834f60ade6d 100644 --- a/packages/host-service/src/trpc/error-types.ts +++ b/packages/host-service/src/trpc/error-types.ts @@ -2,6 +2,14 @@ * Cross-cutting error shapes surfaced via the tRPC error formatter. * Lives here (not in a router) to avoid circular imports with `trpc/index.ts`. */ +import { SESSION_EXPIRED_HINT } from "../providers/auth/hint"; + +export { SESSION_EXPIRED_HINT }; + +export interface SessionExpiredUnauthorizedError { + code: "UNAUTHORIZED"; + message: typeof SESSION_EXPIRED_HINT; +} export interface TeardownFailureCause { kind: "TEARDOWN_FAILED"; diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index c469ac1cffa..00a9b531661 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -1,5 +1,6 @@ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; +import { SESSION_EXPIRED_HINT } from "../providers/auth/hint"; import type { HostServiceContext } from "../types"; import { type DeleteInProgressCause, @@ -74,6 +75,12 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { message: "Invalid or missing authentication token.", }); } + if (ctx.authProvider?.isInAnyExpiredState?.()) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: SESSION_EXPIRED_HINT, + }); + } return next({ ctx }); }); diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index 6a054eb2a0c..c5b9af7b883 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -4,6 +4,7 @@ import type { AppRouter } from "@superset/trpc"; import type { TRPCClient } from "@trpc/client"; import type { HostDb } from "./db"; import type { EventBus } from "./events"; +import type { ApiAuthProvider } from "./providers/auth"; import type { ChatRuntimeManager } from "./runtime/chat"; import type { WorkspaceFilesystemManager } from "./runtime/filesystem"; import type { GitFactory } from "./runtime/git"; @@ -27,6 +28,7 @@ export interface HostServiceContext { db: HostDb; runtime: HostServiceRuntime; eventBus: EventBus; + authProvider: ApiAuthProvider; organizationId: string; isAuthenticated: boolean; } From 0fb2a4e480a55932f153058e38caf8be3950139f Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 16:09:50 -0700 Subject: [PATCH 06/13] test(cli): mock shared token refresh in start command --- packages/cli/src/commands/start/command.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/start/command.test.ts b/packages/cli/src/commands/start/command.test.ts index 3efd8667269..677948bb48d 100644 --- a/packages/cli/src/commands/start/command.test.ts +++ b/packages/cli/src/commands/start/command.test.ts @@ -72,7 +72,7 @@ mock.module("../../lib/api-client", () => ({ createApiClient: createApiClientMock, })); -mock.module("../../lib/auth", () => ({ +mock.module("@superset/shared/auth/token-refresh", () => ({ refreshAccessToken: refreshAccessTokenMock, })); From 7e6823df9170863469d5f43e3c4831cb59f60194 Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 17:27:40 -0700 Subject: [PATCH 07/13] fix: keep host auth refresh local --- .../cli/src/commands/start/command.test.ts | 2 +- packages/cli/src/lib/auth.ts | 51 +- packages/cli/src/lib/resolve-auth.ts | 2 +- .../JwtApiAuthProvider.test.ts | 623 ---------------- .../JwtApiAuthProvider/JwtApiAuthProvider.ts | 429 ----------- .../auth/JwtApiAuthProvider/index.ts | 6 - .../JwtAuthProvider/JwtAuthProvider.test.ts | 677 +++++++++++++++++- .../auth/JwtAuthProvider/JwtAuthProvider.ts | 491 ++++++++++++- .../providers/auth/JwtAuthProvider/index.ts | 6 +- .../host-service/src/providers/auth/index.ts | 6 +- packages/shared/package.json | 4 - packages/shared/src/auth/index.ts | 1 - packages/shared/src/auth/token-refresh.ts | 59 -- 13 files changed, 1205 insertions(+), 1152 deletions(-) delete mode 100644 packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts delete mode 100644 packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts delete mode 100644 packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts delete mode 100644 packages/shared/src/auth/token-refresh.ts diff --git a/packages/cli/src/commands/start/command.test.ts b/packages/cli/src/commands/start/command.test.ts index 677948bb48d..3efd8667269 100644 --- a/packages/cli/src/commands/start/command.test.ts +++ b/packages/cli/src/commands/start/command.test.ts @@ -72,7 +72,7 @@ mock.module("../../lib/api-client", () => ({ createApiClient: createApiClientMock, })); -mock.module("@superset/shared/auth/token-refresh", () => ({ +mock.module("../../lib/auth", () => ({ refreshAccessToken: refreshAccessTokenMock, })); diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index 9442ce547a7..fe9b8658085 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -1,17 +1,20 @@ import { createHash, randomBytes } from "node:crypto"; import { createServer, type Server } from "node:http"; import { CLIError } from "@superset/cli-framework"; -import type { LoginResult } from "@superset/shared/auth/token-refresh"; import { env } from "./env"; -export type { LoginResult } from "@superset/shared/auth/token-refresh"; -export { refreshAccessToken } from "@superset/shared/auth/token-refresh"; - const CLIENT_ID = "superset-cli"; const PASTE_REDIRECT_PATH = "/cli/auth/code"; const SCOPE = "openid profile email offline_access"; const LOOPBACK_PORTS = [51789, 51790, 51791, 51792, 51793]; const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; +const LOGIN_AGAIN_SUGGESTION = "Run `superset auth login` again."; + +export interface LoginResult { + accessToken: string; + refreshToken?: string; + expiresAt: number; +} export interface LoginCallbacks { onAuthorizationUrl?: (url: string) => void; @@ -220,10 +223,9 @@ async function exchangeCodeForToken({ }); if (!response.ok) { - const body = await response.text(); throw new CLIError( `Token exchange failed: ${response.status}`, - body || "Run `superset auth login` again.", + LOGIN_AGAIN_SUGGESTION, ); } @@ -242,6 +244,43 @@ async function exchangeCodeForToken({ }; } +export async function refreshAccessToken( + refreshToken: string, +): Promise { + const apiUrl = env.SUPERSET_API_URL; + const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + resource: apiUrl, + }), + }); + + if (!response.ok) { + throw new CLIError( + `Token refresh failed: ${response.status}`, + LOGIN_AGAIN_SUGGESTION, + ); + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + }; + + const expiresIn = data.expires_in ?? 60 * 60; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, + expiresAt: Date.now() + expiresIn * 1000, + }; +} + export async function login( signal: AbortSignal, callbacks: LoginCallbacks, diff --git a/packages/cli/src/lib/resolve-auth.ts b/packages/cli/src/lib/resolve-auth.ts index 1a831c73c6f..034728154bd 100644 --- a/packages/cli/src/lib/resolve-auth.ts +++ b/packages/cli/src/lib/resolve-auth.ts @@ -1,6 +1,6 @@ import { CLIError } from "@superset/cli-framework"; -import { refreshAccessToken } from "@superset/shared/auth/token-refresh"; import { type ApiClient, createApiClient } from "./api-client"; +import { refreshAccessToken } from "./auth"; import { readConfig, type SupersetConfig, writeConfig } from "./config"; import { isProcessAlive, readManifest } from "./host/manifest"; diff --git a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts deleted file mode 100644 index bc520873759..00000000000 --- a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts +++ /dev/null @@ -1,623 +0,0 @@ -import { - afterAll, - afterEach, - describe, - expect, - it, - mock, - spyOn, -} from "bun:test"; -import * as fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { LoginResult } from "@superset/shared/auth/token-refresh"; -import type { - AuthSessionExpiredMessage, - AuthSessionRestoredMessage, -} from "../../../events"; -import type { AuthSessionEventPublisher } from "../types"; - -let refreshAccessTokenImpl = async ( - refreshToken: string, -): Promise => ({ - accessToken: jwtWithExp(Date.now() + 60 * 60 * 1000), - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, -}); -const refreshAccessTokenMock = mock((refreshToken: string) => - refreshAccessTokenImpl(refreshToken), -); - -mock.module("@superset/shared/auth/token-refresh", () => ({ - refreshAccessToken: refreshAccessTokenMock, -})); - -const { JwtApiAuthProvider } = await import("./JwtApiAuthProvider"); -const { - AUTH_REFRESH_FAILED_MESSAGE, - AuthRefreshFailedError, - SESSION_EXPIRED_HINT, -} = await import("../../../errors"); - -const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), "superset-host-jwt-api-auth-"), -); - -function jwtWithExp(expiresAtMs: number): string { - const header = Buffer.from(JSON.stringify({ alg: "none" })).toString( - "base64url", - ); - const payload = Buffer.from( - JSON.stringify({ exp: Math.floor(expiresAtMs / 1000) }), - ).toString("base64url"); - return `${header}.${payload}.signature`; -} - -function createConfigPath(): string { - const dir = fs.mkdtempSync(path.join(tempRoot, "case-")); - return path.join(dir, "config.json"); -} - -function writeConfig( - configPath: string, - config: { - auth: { - accessToken: string; - refreshToken?: string; - expiresAt: number; - }; - organizationId?: string; - apiKey?: string; - }, -): void { - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { - mode: 0o600, - }); -} - -function readConfig(configPath: string): { - auth?: { - accessToken: string; - refreshToken?: string; - expiresAt: number; - }; - organizationId?: string; - apiKey?: string; -} { - return JSON.parse(fs.readFileSync(configPath, "utf-8")) as { - auth?: { - accessToken: string; - refreshToken?: string; - expiresAt: number; - }; - organizationId?: string; - apiKey?: string; - }; -} - -interface RecordedAuthEvents { - eventBus: AuthSessionEventPublisher; - expired: Array>; - restored: Array>; -} - -function createAuthEvents(): RecordedAuthEvents { - const expired: Array> = []; - const restored: Array> = []; - return { - expired, - restored, - eventBus: { - broadcastAuthSessionExpired: (message) => expired.push(message), - broadcastAuthSessionRestored: (message) => restored.push(message), - }, - }; -} - -function createProvider( - configPath: string, - eventBus?: AuthSessionEventPublisher, -): InstanceType { - return new JwtApiAuthProvider({ - getSessionToken: async () => "bootstrap-access-token", - apiUrl: "https://api.example.com", - authConfigPath: configPath, - eventBus, - }); -} - -function mockNow(initialNow: number): { - advance: (ms: number) => void; - restore: () => void; -} { - let now = initialNow; - const nowSpy = spyOn(Date, "now").mockImplementation(() => now); - return { - advance: (ms: number) => { - now += ms; - }, - restore: () => nowSpy.mockRestore(), - }; -} - -async function captureProcessErrors( - run: () => Promise, -): Promise { - const errors: unknown[] = []; - const onUnhandledRejection = (reason: unknown) => { - errors.push(reason); - }; - const onUncaughtException = (error: Error) => { - errors.push(error); - }; - - process.on("unhandledRejection", onUnhandledRejection); - process.on("uncaughtException", onUncaughtException); - try { - await run(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - process.off("uncaughtException", onUncaughtException); - } - return errors; -} - -afterEach(() => { - refreshAccessTokenMock.mockClear(); - refreshAccessTokenImpl = async (refreshToken: string) => ({ - accessToken: jwtWithExp(Date.now() + 60 * 60 * 1000), - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, - }); -}); - -afterAll(() => { - fs.rmSync(tempRoot, { recursive: true, force: true }); -}); - -describe("JwtApiAuthProvider", () => { - it("refreshes a JWT within the leeway and persists the rotated credential atomically", async () => { - const configPath = createConfigPath(); - const oldToken = jwtWithExp(Date.now() + 60_000); - const refreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); - const refreshedExpiresAt = Date.now() + 60 * 60 * 1000; - refreshAccessTokenImpl = async () => ({ - accessToken: refreshedToken, - refreshToken: "rotated-refresh-token", - expiresAt: refreshedExpiresAt, - }); - writeConfig(configPath, { - organizationId: "org_1", - apiKey: "sk_live_existing", - auth: { - accessToken: oldToken, - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const renameSpy = spyOn(fs, "renameSync"); - - const token = await createProvider(configPath).getSessionToken(); - - expect(token).toBe(refreshedToken); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - expect(refreshAccessTokenMock).toHaveBeenCalledWith("refresh-token"); - expect(renameSpy).toHaveBeenCalledWith(`${configPath}.tmp`, configPath); - expect(readConfig(configPath)).toEqual({ - organizationId: "org_1", - apiKey: "sk_live_existing", - auth: { - accessToken: refreshedToken, - refreshToken: "rotated-refresh-token", - expiresAt: refreshedExpiresAt, - }, - }); - - renameSpy.mockRestore(); - }); - - it("returns the in-memory token without refresh or config re-read when the JWT is fresh", async () => { - const configPath = createConfigPath(); - const freshToken = jwtWithExp(Date.now() + 60 * 60 * 1000); - writeConfig(configPath, { - auth: { - accessToken: freshToken, - refreshToken: "refresh-token", - expiresAt: Date.now() + 60 * 60 * 1000, - }, - }); - const provider = createProvider(configPath); - const readSpy = spyOn(fs, "readFileSync"); - - expect(await provider.getSessionToken()).toBe(freshToken); - readSpy.mockClear(); - - expect(await provider.getSessionToken()).toBe(freshToken); - expect(refreshAccessTokenMock).not.toHaveBeenCalled(); - expect(readSpy).not.toHaveBeenCalled(); - - readSpy.mockRestore(); - }); - - it("coalesces concurrent refresh callers into one in-flight refresh", async () => { - const configPath = createConfigPath(); - const oldToken = jwtWithExp(Date.now() + 60_000); - const firstRefreshedToken = jwtWithExp(Date.now() + 60_000); - const secondRefreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); - let refreshCount = 0; - refreshAccessTokenImpl = async (refreshToken: string) => { - refreshCount += 1; - await new Promise((resolve) => setTimeout(resolve, 10)); - return { - accessToken: - refreshCount === 1 ? firstRefreshedToken : secondRefreshedToken, - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, - }; - }; - writeConfig(configPath, { - auth: { - accessToken: oldToken, - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath); - - const results = await Promise.all( - Array.from({ length: 50 }, () => provider.getSessionToken()), - ); - - expect(new Set(results)).toEqual(new Set([firstRefreshedToken])); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - - await expect(provider.getSessionToken()).resolves.toBe( - secondRefreshedToken, - ); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); - }); - - it("throws invalid_grant AuthRefreshFailedError on a 401 refresh response", async () => { - const configPath = createConfigPath(); - refreshAccessTokenImpl = async () => { - throw new Error("Token refresh failed: 401"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - - await expect( - createProvider(configPath).getSessionToken(), - ).rejects.toMatchObject({ - message: AUTH_REFRESH_FAILED_MESSAGE, - reason: "invalid_grant", - statusCode: 401, - }); - }); - - it("emits one auth:session_expired event with the exact hint and wipes refresh token on invalid_grant", async () => { - const clock = mockNow(1_700_000_000_000); - try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - refreshAccessTokenImpl = async () => { - throw new Error("Token refresh failed: 401 invalid_grant"); - }; - writeConfig(configPath, { - organizationId: "org_1", - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "invalid_grant", - statusCode: 401, - }); - - expect(provider.getAuthState()).toMatchObject({ - kind: "expired_permanent", - reason: "invalid_grant", - statusCode: 401, - }); - expect(provider.isInAnyExpiredState()).toBe(true); - expect(events.expired).toEqual([ - { - reason: "invalid_grant", - hint: SESSION_EXPIRED_HINT, - occurredAt: Date.now(), - }, - ]); - expect(events.restored).toEqual([]); - expect(readConfig(configPath)).toEqual({ - organizationId: "org_1", - auth: { - accessToken: expect.any(String), - expiresAt: expect.any(Number), - }, - }); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "invalid_grant", - statusCode: 401, - }); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - expect(events.expired).toHaveLength(1); - expect(events.restored).toHaveLength(0); - } finally { - clock.restore(); - } - }); - - it("records transient network failures, preserves refresh token, and suppresses retry inside 60 seconds", async () => { - const clock = mockNow(1_700_000_000_000); - try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - refreshAccessTokenImpl = async () => { - throw new TypeError("fetch failed"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "network_error", - }); - - expect(provider.getAuthState()).toEqual({ - kind: "expired_transient", - reason: "network_error", - lastFailureAt: Date.now(), - statusCode: undefined, - }); - expect(readConfig(configPath).auth?.refreshToken).toBe("refresh-token"); - expect(events.expired).toEqual([ - { - reason: "network_error", - hint: SESSION_EXPIRED_HINT, - occurredAt: Date.now(), - }, - ]); - - for (let i = 0; i < 20; i += 1) { - clock.advance(1_000); - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "network_error", - }); - } - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - expect(events.expired).toHaveLength(1); - expect(events.restored).toHaveLength(0); - } finally { - clock.restore(); - } - }); - - it("records transient 5xx failures and preserves the refresh token", async () => { - const clock = mockNow(1_700_000_000_000); - try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - refreshAccessTokenImpl = async () => { - throw new Error("Token refresh failed: 503"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "http_error", - statusCode: 503, - }); - - expect(provider.getAuthState()).toEqual({ - kind: "expired_transient", - reason: "http_error", - lastFailureAt: Date.now(), - statusCode: 503, - }); - expect(readConfig(configPath).auth?.refreshToken).toBe("refresh-token"); - expect(events.expired).toEqual([ - { - reason: "http_error", - hint: SESSION_EXPIRED_HINT, - occurredAt: Date.now(), - }, - ]); - expect(events.restored).toEqual([]); - } finally { - clock.restore(); - } - }); - - it("retries a transient failure after 60 seconds and broadcasts auth:session_restored once on success", async () => { - const clock = mockNow(1_700_000_000_000); - try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - const refreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); - refreshAccessTokenImpl = async () => { - throw new TypeError("fetch failed"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - reason: "network_error", - }); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - - clock.advance(61_000); - refreshAccessTokenImpl = async (refreshToken: string) => ({ - accessToken: refreshedToken, - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, - }); - - await expect(provider.getSessionToken()).resolves.toBe(refreshedToken); - - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); - expect(provider.getAuthState()).toEqual({ kind: "healthy" }); - expect(provider.isInAnyExpiredState()).toBe(false); - expect(events.expired).toHaveLength(1); - expect(events.restored).toEqual([{ occurredAt: Date.now() }]); - - await expect(provider.getSessionToken()).resolves.toBe(refreshedToken); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); - expect(events.restored).toHaveLength(1); - } finally { - clock.restore(); - } - }); - - it("updates transient lastFailureAt after a retry failure without re-emitting auth:session_expired", async () => { - const clock = mockNow(1_700_000_000_000); - try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - refreshAccessTokenImpl = async () => { - throw new TypeError("fetch failed"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - reason: "network_error", - }); - clock.advance(61_000); - await expect(provider.getSessionToken()).rejects.toMatchObject({ - reason: "network_error", - }); - - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); - expect(provider.getAuthState()).toEqual({ - kind: "expired_transient", - reason: "network_error", - lastFailureAt: Date.now(), - statusCode: undefined, - }); - expect(events.expired).toHaveLength(1); - expect(events.restored).toHaveLength(0); - } finally { - clock.restore(); - } - }); - - it("does not emit process-level error events for permanent or transient refresh failures", async () => { - const errors = await captureProcessErrors(async () => { - for (const failure of [ - () => new Error("Token refresh failed: 401 invalid_grant"), - () => new TypeError("fetch failed"), - () => new Error("Token refresh failed: 503"), - ]) { - const configPath = createConfigPath(); - refreshAccessTokenImpl = async () => { - throw failure(); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - await expect( - createProvider(configPath).getSessionToken(), - ).rejects.toBeInstanceOf(AuthRefreshFailedError); - } - }); - - expect(errors).toEqual([]); - }); - - it("uses the exact refresh failure hint without leaking token, URL, or response body", async () => { - const configPath = createConfigPath(); - const leakedToken = "refresh-token-secret"; - const leakedUrl = - "https://api.example.com/api/auth/oauth2/token?refresh_token=secret"; - const leakedBody = "raw invalid_grant response body"; - refreshAccessTokenImpl = async () => { - throw new Error( - `Token refresh failed: 500 ${leakedToken} ${leakedUrl} ${leakedBody}`, - ); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: leakedToken, - expiresAt: Date.now() + 60_000, - }, - }); - - try { - await createProvider(configPath).getSessionToken(); - throw new Error("expected getSessionToken to throw"); - } catch (error) { - expect(error).toBeInstanceOf(AuthRefreshFailedError); - const refreshError = error as InstanceType; - expect(refreshError.message).toBe(AUTH_REFRESH_FAILED_MESSAGE); - expect(refreshError.reason).toBe("http_error"); - expect(refreshError.statusCode).toBe(500); - expect(refreshError.message).not.toContain(leakedToken); - expect(refreshError.message).not.toContain(leakedUrl); - expect(refreshError.message).not.toContain(leakedBody); - } - }); - - it("classifies thrown fetch failures as network_error", async () => { - const configPath = createConfigPath(); - refreshAccessTokenImpl = async () => { - throw new TypeError("fetch failed"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - - await expect( - createProvider(configPath).getSessionToken(), - ).rejects.toMatchObject({ - message: AUTH_REFRESH_FAILED_MESSAGE, - reason: "network_error", - }); - }); -}); diff --git a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts b/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts deleted file mode 100644 index 016f41d425c..00000000000 --- a/packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts +++ /dev/null @@ -1,429 +0,0 @@ -import * as fs from "node:fs"; -import { dirname } from "node:path"; -import { refreshAccessToken } from "@superset/shared/auth/token-refresh"; -import { - AuthRefreshFailedError, - type AuthRefreshFailureReason, -} from "../../../errors"; -import { SESSION_EXPIRED_HINT } from "../hint"; -import type { ApiAuthProvider, AuthSessionEventPublisher } from "../types"; - -const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000; -const JWT_CACHE_DURATION_MS = 55 * 60 * 1000; -const TRANSIENT_RETRY_INTERVAL_MS = 60 * 1000; - -interface SupersetAuthConfig { - accessToken: string; - refreshToken?: string; - expiresAt: number; -} - -interface SupersetConfig { - auth?: SupersetAuthConfig; - apiKey?: string; - organizationId?: string; - [key: string]: unknown; -} - -interface RefreshFailureClassification { - reason: AuthRefreshFailureReason; - statusCode?: number; -} - -export type JwtApiAuthProviderExpiredState = - | { - kind: "expired_permanent"; - reason: "invalid_grant"; - statusCode?: number; - } - | { - kind: "expired_transient"; - reason: "network_error" | "http_error"; - lastFailureAt: number; - statusCode?: number; - }; - -export type JwtApiAuthProviderAuthState = - | { kind: "healthy" } - | JwtApiAuthProviderExpiredState; - -export interface JwtApiAuthProviderOptions { - /** - * Returns the current session/api-key/JWT token to authenticate with. - * Used directly when no auth config path is available, and as a fallback - * when the config file has not been written yet. - */ - getSessionToken: () => Promise; - apiUrl: string; - authConfigPath?: string; - eventBus?: AuthSessionEventPublisher; -} - -function looksLikeJwt(token: string): boolean { - const parts = token.split("."); - return parts.length === 3 && parts.every(Boolean); -} - -function readJwtExp(token: string): number | null { - const parts = token.split("."); - if (parts.length !== 3) return null; - - const payload = parts[1]; - if (!payload) return null; - - try { - const parsed: unknown = JSON.parse( - Buffer.from(payload, "base64url").toString("utf8"), - ); - if ( - typeof parsed === "object" && - parsed !== null && - !Array.isArray(parsed) && - typeof (parsed as { exp?: unknown }).exp === "number" - ) { - return (parsed as { exp: number }).exp * 1000; - } - return null; - } catch { - return null; - } -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isSupersetAuthConfig(value: unknown): value is SupersetAuthConfig { - if (!isObject(value)) return false; - return ( - typeof value.accessToken === "string" && - typeof value.expiresAt === "number" && - (value.refreshToken === undefined || typeof value.refreshToken === "string") - ); -} - -function readStatusCode(error: unknown): number | undefined { - if (isObject(error) && typeof error.statusCode === "number") { - return error.statusCode; - } - const message = error instanceof Error ? error.message : String(error); - const match = /Token refresh failed:\s*(\d{3})/.exec(message); - if (!match?.[1]) return undefined; - return Number.parseInt(match[1], 10); -} - -function errorIndicatesInvalidGrant(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - const suggestion = - isObject(error) && typeof error.suggestion === "string" - ? error.suggestion - : ""; - return /\binvalid_grant\b/i.test(`${message}\n${suggestion}`); -} - -function reasonForRefreshError(error: unknown): RefreshFailureClassification { - const statusCode = readStatusCode(error); - if (statusCode === undefined) { - return { reason: "network_error" }; - } - if ( - statusCode === 401 || - ((statusCode === 400 || statusCode === 403) && - errorIndicatesInvalidGrant(error)) - ) { - return { reason: "invalid_grant", statusCode }; - } - return { reason: "http_error", statusCode }; -} - -function readConfigAtPath(configPath: string): SupersetConfig { - if (!fs.existsSync(configPath)) return {}; - const parsed: unknown = JSON.parse(fs.readFileSync(configPath, "utf-8")); - return isObject(parsed) ? parsed : {}; -} - -function writeConfigAtPath(configPath: string, config: SupersetConfig): void { - const configDir = dirname(configPath); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); - } - try { - const stat = fs.statSync(configDir); - if ((stat.mode & 0o077) !== 0) fs.chmodSync(configDir, 0o700); - } catch {} - - const tmpPath = `${configPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 }); - try { - fs.chmodSync(tmpPath, 0o600); - } catch {} - fs.renameSync(tmpPath, configPath); -} - -export class JwtApiAuthProvider implements ApiAuthProvider { - private readonly loadSessionToken: () => Promise; - private readonly apiUrl: string; - private readonly authConfigPath: string | undefined; - private eventBus: AuthSessionEventPublisher | undefined; - private cachedJwt: string | null = null; - private cachedJwtSessionToken: string | null = null; - private cachedJwtExpiresAt = 0; - private currentCredential: SupersetAuthConfig | null = null; - private inflightRefresh: Promise | null = null; - private expired: JwtApiAuthProviderExpiredState | null = null; - - constructor(options: JwtApiAuthProviderOptions) { - this.loadSessionToken = options.getSessionToken; - this.apiUrl = options.apiUrl; - this.authConfigPath = options.authConfigPath; - this.eventBus = options.eventBus; - } - - async getHeaders(): Promise> { - const jwt = await this.getJwt(); - return { Authorization: `Bearer ${jwt}` }; - } - - invalidateCache(): void { - this.cachedJwt = null; - this.cachedJwtSessionToken = null; - this.cachedJwtExpiresAt = 0; - this.currentCredential = null; - } - - setEventBus(eventBus: AuthSessionEventPublisher): void { - this.eventBus = eventBus; - } - - isInAnyExpiredState(): boolean { - return this.expired !== null; - } - - isInExpiredState(): boolean { - return this.isInAnyExpiredState(); - } - - getAuthState(): JwtApiAuthProviderAuthState { - if (!this.expired) return { kind: "healthy" }; - return { ...this.expired }; - } - - async getSessionToken(): Promise { - if (!this.authConfigPath) { - return this.loadSessionToken(); - } - - if (this.expired?.kind === "expired_permanent") { - throw new AuthRefreshFailedError({ - reason: this.expired.reason, - statusCode: this.expired.statusCode, - }); - } - - if (this.expired?.kind === "expired_transient") { - const elapsedMs = Date.now() - this.expired.lastFailureAt; - if (elapsedMs < TRANSIENT_RETRY_INTERVAL_MS) { - throw new AuthRefreshFailedError({ - reason: this.expired.reason, - statusCode: this.expired.statusCode, - }); - } - } - - const credential = this.currentCredential ?? this.readCurrentCredential(); - if (!credential) { - return this.loadSessionToken(); - } - this.currentCredential = credential; - - const expiresAt = readJwtExp(credential.accessToken); - const needsRefresh = - this.expired !== null || - (expiresAt !== null && expiresAt - Date.now() <= JWT_REFRESH_BUFFER_MS); - if (!needsRefresh) { - return credential.accessToken; - } - - if (this.inflightRefresh) { - return this.inflightRefresh; - } - - this.inflightRefresh = this.refreshCredential(credential).finally(() => { - this.inflightRefresh = null; - }); - return this.inflightRefresh; - } - - async getJwt(): Promise { - const sessionToken = await this.getSessionToken(); - - // OAuth access tokens are already JWTs. Delegate to getSessionToken so - // host-owned refresh and single-flight behavior run before pass-through. - if (looksLikeJwt(sessionToken)) { - return sessionToken; - } - - if ( - this.cachedJwt && - this.cachedJwtSessionToken === sessionToken && - Date.now() < this.cachedJwtExpiresAt - JWT_REFRESH_BUFFER_MS - ) { - return this.cachedJwt; - } - - // better-auth's apiKey plugin reads `sk_live_…` from x-api-key, not - // Authorization: Bearer; mirror what the CLI's tRPC client does in - // packages/cli/src/lib/api-client.ts. - const response = await fetch(`${this.apiUrl}/api/auth/token`, { - headers: sessionToken.startsWith("sk_live_") - ? { "x-api-key": sessionToken } - : { Authorization: `Bearer ${sessionToken}` }, - }); - if (!response.ok) { - throw new Error(`Failed to mint JWT: ${response.status}`); - } - const data = (await response.json()) as { token: string }; - this.cachedJwt = data.token; - this.cachedJwtSessionToken = sessionToken; - this.cachedJwtExpiresAt = Date.now() + JWT_CACHE_DURATION_MS; - return data.token; - } - - private readCurrentCredential(): SupersetAuthConfig | null { - if (!this.authConfigPath) return null; - const config = readConfigAtPath(this.authConfigPath); - return isSupersetAuthConfig(config.auth) ? config.auth : null; - } - - private async refreshCredential( - credential: SupersetAuthConfig, - ): Promise { - if (!credential.refreshToken) { - this.transitionToPermanent({ reason: "invalid_grant" }); - this.wipeRefreshToken(); - throw new AuthRefreshFailedError({ reason: "invalid_grant" }); - } - - let refreshed: SupersetAuthConfig; - try { - refreshed = await this.runRefresh(credential.refreshToken); - } catch (error) { - const failure = - error instanceof AuthRefreshFailedError - ? { reason: error.reason, statusCode: error.statusCode } - : reasonForRefreshError(error); - this.handleRefreshFailure(failure); - throw new AuthRefreshFailedError(failure); - } - - const nextCredential: SupersetAuthConfig = { - accessToken: refreshed.accessToken, - refreshToken: refreshed.refreshToken ?? credential.refreshToken, - expiresAt: refreshed.expiresAt, - }; - - if (this.authConfigPath) { - const latestConfig = readConfigAtPath(this.authConfigPath); - writeConfigAtPath(this.authConfigPath, { - ...latestConfig, - auth: nextCredential, - }); - } - - this.currentCredential = nextCredential; - this.cachedJwt = null; - this.cachedJwtSessionToken = null; - this.cachedJwtExpiresAt = 0; - this.transitionToHealthy(); - return nextCredential.accessToken; - } - - private async runRefresh(refreshToken: string): Promise { - const refreshed = await refreshAccessToken(refreshToken); - return { - accessToken: refreshed.accessToken, - refreshToken: refreshed.refreshToken ?? refreshToken, - expiresAt: refreshed.expiresAt, - }; - } - - private handleRefreshFailure(failure: RefreshFailureClassification): void { - if (failure.reason === "invalid_grant") { - this.transitionToPermanent({ - reason: failure.reason, - statusCode: failure.statusCode, - }); - this.wipeRefreshToken(); - return; - } - - this.transitionToTransient({ - reason: failure.reason, - statusCode: failure.statusCode, - }); - } - - private transitionToPermanent(failure: { - reason: "invalid_grant"; - statusCode?: number; - }): void { - const wasHealthy = this.expired === null; - const occurredAt = Date.now(); - this.expired = { - kind: "expired_permanent", - reason: failure.reason, - statusCode: failure.statusCode, - }; - if (wasHealthy) { - this.eventBus?.broadcastAuthSessionExpired({ - reason: failure.reason, - hint: SESSION_EXPIRED_HINT, - occurredAt, - }); - } - } - - private transitionToTransient(failure: { - reason: "network_error" | "http_error"; - statusCode?: number; - }): void { - const wasHealthy = this.expired === null; - const occurredAt = Date.now(); - this.expired = { - kind: "expired_transient", - reason: failure.reason, - lastFailureAt: occurredAt, - statusCode: failure.statusCode, - }; - if (wasHealthy) { - this.eventBus?.broadcastAuthSessionExpired({ - reason: failure.reason, - hint: SESSION_EXPIRED_HINT, - occurredAt, - }); - } - } - - private transitionToHealthy(): void { - const wasExpiredTransient = this.expired?.kind === "expired_transient"; - const occurredAt = Date.now(); - this.expired = null; - if (wasExpiredTransient) { - this.eventBus?.broadcastAuthSessionRestored({ occurredAt }); - } - } - - private wipeRefreshToken(): void { - if (!this.authConfigPath) return; - - const latestConfig = readConfigAtPath(this.authConfigPath); - if (!latestConfig.auth?.refreshToken) return; - - const nextAuth = { ...latestConfig.auth }; - delete nextAuth.refreshToken; - writeConfigAtPath(this.authConfigPath, { - ...latestConfig, - auth: nextAuth, - }); - } -} diff --git a/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts b/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts deleted file mode 100644 index dee78e3dd8e..00000000000 --- a/packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - JwtApiAuthProvider, - type JwtApiAuthProviderAuthState, - type JwtApiAuthProviderExpiredState, - type JwtApiAuthProviderOptions, -} from "./JwtApiAuthProvider"; diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts index 6e438991f1b..bdbc8fe7f8e 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts @@ -1,6 +1,48 @@ -import { describe, expect, it, mock } from "bun:test"; +import { + afterAll, + afterEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; +import * as fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { + AuthSessionExpiredMessage, + AuthSessionRestoredMessage, +} from "../../../events"; +import type { AuthSessionEventPublisher } from "../types"; + +type LoginResult = { + accessToken: string; + refreshToken?: string; + expiresAt: number; +}; + +let refreshAccessTokenImpl = async ( + refreshToken: string, +): Promise => ({ + accessToken: jwtWithExp(Date.now() + 60 * 60 * 1000), + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, +}); +const refreshAccessTokenMock = mock((refreshToken: string) => + refreshAccessTokenImpl(refreshToken), +); const { JwtApiAuthProvider } = await import("./JwtAuthProvider"); +const { + AUTH_REFRESH_FAILED_MESSAGE, + AuthRefreshFailedError, + SESSION_EXPIRED_HINT, +} = await import("../../../errors"); + +const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "superset-host-jwt-api-auth-"), +); function jwtWithExp(expiresAtMs: number): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString( @@ -12,7 +54,131 @@ function jwtWithExp(expiresAtMs: number): string { return `${header}.${payload}.signature`; } -describe("JwtAuthProvider getJwt", () => { +function createConfigPath(): string { + const dir = fs.mkdtempSync(path.join(tempRoot, "case-")); + return path.join(dir, "config.json"); +} + +function writeConfig( + configPath: string, + config: { + auth: { + accessToken: string; + refreshToken?: string; + expiresAt: number; + }; + organizationId?: string; + apiKey?: string; + }, +): void { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { + mode: 0o600, + }); +} + +function readConfig(configPath: string): { + auth?: { + accessToken: string; + refreshToken?: string; + expiresAt: number; + }; + organizationId?: string; + apiKey?: string; +} { + return JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + auth?: { + accessToken: string; + refreshToken?: string; + expiresAt: number; + }; + organizationId?: string; + apiKey?: string; + }; +} + +interface RecordedAuthEvents { + eventBus: AuthSessionEventPublisher; + expired: Array>; + restored: Array>; +} + +function createAuthEvents(): RecordedAuthEvents { + const expired: Array> = []; + const restored: Array> = []; + return { + expired, + restored, + eventBus: { + broadcastAuthSessionExpired: (message) => expired.push(message), + broadcastAuthSessionRestored: (message) => restored.push(message), + }, + }; +} + +function createProvider( + configPath: string, + eventBus?: AuthSessionEventPublisher, +): InstanceType { + return new JwtApiAuthProvider({ + getSessionToken: async () => "bootstrap-access-token", + apiUrl: "https://api.example.com", + authConfigPath: configPath, + eventBus, + refreshAccessToken: refreshAccessTokenMock, + }); +} + +function mockNow(initialNow: number): { + advance: (ms: number) => void; + restore: () => void; +} { + let now = initialNow; + const nowSpy = spyOn(Date, "now").mockImplementation(() => now); + return { + advance: (ms: number) => { + now += ms; + }, + restore: () => nowSpy.mockRestore(), + }; +} + +async function captureProcessErrors( + run: () => Promise, +): Promise { + const errors: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + errors.push(reason); + }; + const onUncaughtException = (error: Error) => { + errors.push(error); + }; + + process.on("unhandledRejection", onUnhandledRejection); + process.on("uncaughtException", onUncaughtException); + try { + await run(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + process.off("uncaughtException", onUncaughtException); + } + return errors; +} + +afterEach(() => { + refreshAccessTokenMock.mockClear(); + refreshAccessTokenImpl = async (refreshToken: string) => ({ + accessToken: jwtWithExp(Date.now() + 60 * 60 * 1000), + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, + }); +}); + +afterAll(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("JwtApiAuthProvider", () => { it("delegates the JWT branch to getSessionToken once per invocation without caching", async () => { const accessToken = jwtWithExp(Date.now() + 60 * 60 * 1000); const getSessionToken = mock(async () => accessToken); @@ -24,11 +190,508 @@ describe("JwtAuthProvider getJwt", () => { apiUrl: "https://api.example.com", }); - expect(await provider.getJwt()).toBe(accessToken); - expect(await provider.getJwt()).toBe(accessToken); - expect(getSessionToken).toHaveBeenCalledTimes(2); - expect(fetchMock).not.toHaveBeenCalled(); + try { + expect(await provider.getJwt()).toBe(accessToken); + expect(await provider.getJwt()).toBe(accessToken); + expect(getSessionToken).toHaveBeenCalledTimes(2); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("refreshes a JWT within the leeway and persists the rotated credential atomically", async () => { + const configPath = createConfigPath(); + const oldToken = jwtWithExp(Date.now() + 60_000); + const refreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); + const refreshedExpiresAt = Date.now() + 60 * 60 * 1000; + refreshAccessTokenImpl = async () => ({ + accessToken: refreshedToken, + refreshToken: "rotated-refresh-token", + expiresAt: refreshedExpiresAt, + }); + writeConfig(configPath, { + organizationId: "org_1", + apiKey: "sk_live_existing", + auth: { + accessToken: oldToken, + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const renameSpy = spyOn(fs, "renameSync"); - globalThis.fetch = originalFetch; + const token = await createProvider(configPath).getSessionToken(); + + expect(token).toBe(refreshedToken); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(refreshAccessTokenMock).toHaveBeenCalledWith("refresh-token"); + expect(renameSpy).toHaveBeenCalledWith(`${configPath}.tmp`, configPath); + expect(readConfig(configPath)).toEqual({ + organizationId: "org_1", + apiKey: "sk_live_existing", + auth: { + accessToken: refreshedToken, + refreshToken: "rotated-refresh-token", + expiresAt: refreshedExpiresAt, + }, + }); + + renameSpy.mockRestore(); + }); + + it("returns the in-memory token without refresh or config re-read when the JWT is fresh", async () => { + const configPath = createConfigPath(); + const freshToken = jwtWithExp(Date.now() + 60 * 60 * 1000); + writeConfig(configPath, { + auth: { + accessToken: freshToken, + refreshToken: "refresh-token", + expiresAt: Date.now() + 60 * 60 * 1000, + }, + }); + const provider = createProvider(configPath); + const readSpy = spyOn(fs, "readFileSync"); + + expect(await provider.getSessionToken()).toBe(freshToken); + readSpy.mockClear(); + + expect(await provider.getSessionToken()).toBe(freshToken); + expect(refreshAccessTokenMock).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + + readSpy.mockRestore(); + }); + + it("coalesces concurrent refresh callers into one in-flight refresh", async () => { + const configPath = createConfigPath(); + const oldToken = jwtWithExp(Date.now() + 60_000); + const firstRefreshedToken = jwtWithExp(Date.now() + 60_000); + const secondRefreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); + let refreshCount = 0; + refreshAccessTokenImpl = async (refreshToken: string) => { + refreshCount += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + accessToken: + refreshCount === 1 ? firstRefreshedToken : secondRefreshedToken, + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, + }; + }; + writeConfig(configPath, { + auth: { + accessToken: oldToken, + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath); + + const results = await Promise.all( + Array.from({ length: 50 }, () => provider.getSessionToken()), + ); + + expect(new Set(results)).toEqual(new Set([firstRefreshedToken])); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + + await expect(provider.getSessionToken()).resolves.toBe( + secondRefreshedToken, + ); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + }); + + it("throws invalid_grant AuthRefreshFailedError on a 401 refresh response", async () => { + const configPath = createConfigPath(); + refreshAccessTokenImpl = async () => { + throw new Error("Token refresh failed: 401"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + + await expect( + createProvider(configPath).getSessionToken(), + ).rejects.toMatchObject({ + message: AUTH_REFRESH_FAILED_MESSAGE, + reason: "invalid_grant", + statusCode: 401, + }); + }); + + it("classifies invalid_grant from the local OAuth refresh request without leaking the response body", async () => { + const configPath = createConfigPath(); + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token-secret", + expiresAt: Date.now() + 60_000, + }, + }); + const originalFetch = globalThis.fetch; + const fetchMock = mock( + async () => + new Response( + JSON.stringify({ + error: "invalid_grant", + refresh_token: "refresh-token-secret", + redirect: + "https://api.example.com/callback?code=authorization-code-secret", + }), + { status: 400 }, + ), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const provider = new JwtApiAuthProvider({ + getSessionToken: async () => "bootstrap-access-token", + apiUrl: "https://api.example.com", + authConfigPath: configPath, + }); + + try { + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: AUTH_REFRESH_FAILED_MESSAGE, + reason: "invalid_grant", + statusCode: 400, + }); + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: AUTH_REFRESH_FAILED_MESSAGE, + reason: "invalid_grant", + statusCode: 400, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).not.toContain("refresh-token-secret"); + expect(message).not.toContain("authorization-code-secret"); + throw error; + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("emits one auth:session_expired event with the exact hint and wipes refresh token on invalid_grant", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + refreshAccessTokenImpl = async () => { + throw new Error("Token refresh failed: 401 invalid_grant"); + }; + writeConfig(configPath, { + organizationId: "org_1", + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "invalid_grant", + statusCode: 401, + }); + + expect(provider.getAuthState()).toMatchObject({ + kind: "expired_permanent", + reason: "invalid_grant", + statusCode: 401, + }); + expect(provider.isInAnyExpiredState()).toBe(true); + expect(events.expired).toEqual([ + { + reason: "invalid_grant", + hint: SESSION_EXPIRED_HINT, + occurredAt: Date.now(), + }, + ]); + expect(events.restored).toEqual([]); + expect(readConfig(configPath)).toEqual({ + organizationId: "org_1", + auth: { + accessToken: expect.any(String), + expiresAt: expect.any(Number), + }, + }); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "invalid_grant", + statusCode: 401, + }); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(events.expired).toHaveLength(1); + expect(events.restored).toHaveLength(0); + } finally { + clock.restore(); + } + }); + + it("records transient network failures, preserves refresh token, and suppresses retry inside 60 seconds", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + refreshAccessTokenImpl = async () => { + throw new TypeError("fetch failed"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "network_error", + }); + + expect(provider.getAuthState()).toEqual({ + kind: "expired_transient", + reason: "network_error", + lastFailureAt: Date.now(), + statusCode: undefined, + }); + expect(readConfig(configPath).auth?.refreshToken).toBe("refresh-token"); + expect(events.expired).toEqual([ + { + reason: "network_error", + hint: SESSION_EXPIRED_HINT, + occurredAt: Date.now(), + }, + ]); + + for (let i = 0; i < 20; i += 1) { + clock.advance(1_000); + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "network_error", + }); + } + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(events.expired).toHaveLength(1); + expect(events.restored).toHaveLength(0); + } finally { + clock.restore(); + } + }); + + it("records transient 5xx failures and preserves the refresh token", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + refreshAccessTokenImpl = async () => { + throw new Error("Token refresh failed: 503"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + message: SESSION_EXPIRED_HINT, + reason: "http_error", + statusCode: 503, + }); + + expect(provider.getAuthState()).toEqual({ + kind: "expired_transient", + reason: "http_error", + lastFailureAt: Date.now(), + statusCode: 503, + }); + expect(readConfig(configPath).auth?.refreshToken).toBe("refresh-token"); + expect(events.expired).toEqual([ + { + reason: "http_error", + hint: SESSION_EXPIRED_HINT, + occurredAt: Date.now(), + }, + ]); + expect(events.restored).toEqual([]); + } finally { + clock.restore(); + } + }); + + it("retries a transient failure after 60 seconds and broadcasts auth:session_restored once on success", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + const refreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); + refreshAccessTokenImpl = async () => { + throw new TypeError("fetch failed"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + reason: "network_error", + }); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + + clock.advance(61_000); + refreshAccessTokenImpl = async (refreshToken: string) => ({ + accessToken: refreshedToken, + refreshToken, + expiresAt: Date.now() + 60 * 60 * 1000, + }); + + await expect(provider.getSessionToken()).resolves.toBe(refreshedToken); + + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + expect(provider.getAuthState()).toEqual({ kind: "healthy" }); + expect(provider.isInAnyExpiredState()).toBe(false); + expect(events.expired).toHaveLength(1); + expect(events.restored).toEqual([{ occurredAt: Date.now() }]); + + await expect(provider.getSessionToken()).resolves.toBe(refreshedToken); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + expect(events.restored).toHaveLength(1); + } finally { + clock.restore(); + } + }); + + it("updates transient lastFailureAt after a retry failure without re-emitting auth:session_expired", async () => { + const clock = mockNow(1_700_000_000_000); + try { + const configPath = createConfigPath(); + const events = createAuthEvents(); + refreshAccessTokenImpl = async () => { + throw new TypeError("fetch failed"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + const provider = createProvider(configPath, events.eventBus); + + await expect(provider.getSessionToken()).rejects.toMatchObject({ + reason: "network_error", + }); + clock.advance(61_000); + await expect(provider.getSessionToken()).rejects.toMatchObject({ + reason: "network_error", + }); + + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + expect(provider.getAuthState()).toEqual({ + kind: "expired_transient", + reason: "network_error", + lastFailureAt: Date.now(), + statusCode: undefined, + }); + expect(events.expired).toHaveLength(1); + expect(events.restored).toHaveLength(0); + } finally { + clock.restore(); + } + }); + + it("does not emit process-level error events for permanent or transient refresh failures", async () => { + const errors = await captureProcessErrors(async () => { + for (const failure of [ + () => new Error("Token refresh failed: 401 invalid_grant"), + () => new TypeError("fetch failed"), + () => new Error("Token refresh failed: 503"), + ]) { + const configPath = createConfigPath(); + refreshAccessTokenImpl = async () => { + throw failure(); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + await expect( + createProvider(configPath).getSessionToken(), + ).rejects.toBeInstanceOf(AuthRefreshFailedError); + } + }); + + expect(errors).toEqual([]); + }); + + it("uses the exact refresh failure hint without leaking token, URL, or response body", async () => { + const configPath = createConfigPath(); + const leakedToken = "refresh-token-secret"; + const leakedUrl = + "https://api.example.com/api/auth/oauth2/token?refresh_token=secret"; + const leakedBody = "raw invalid_grant response body"; + refreshAccessTokenImpl = async () => { + throw new Error( + `Token refresh failed: 500 ${leakedToken} ${leakedUrl} ${leakedBody}`, + ); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: leakedToken, + expiresAt: Date.now() + 60_000, + }, + }); + + try { + await createProvider(configPath).getSessionToken(); + throw new Error("expected getSessionToken to throw"); + } catch (error) { + expect(error).toBeInstanceOf(AuthRefreshFailedError); + const refreshError = error as InstanceType; + expect(refreshError.message).toBe(AUTH_REFRESH_FAILED_MESSAGE); + expect(refreshError.reason).toBe("http_error"); + expect(refreshError.statusCode).toBe(500); + expect(refreshError.message).not.toContain(leakedToken); + expect(refreshError.message).not.toContain(leakedUrl); + expect(refreshError.message).not.toContain(leakedBody); + } + }); + + it("classifies thrown fetch failures as network_error", async () => { + const configPath = createConfigPath(); + refreshAccessTokenImpl = async () => { + throw new TypeError("fetch failed"); + }; + writeConfig(configPath, { + auth: { + accessToken: jwtWithExp(Date.now() + 60_000), + refreshToken: "refresh-token", + expiresAt: Date.now() + 60_000, + }, + }); + + await expect( + createProvider(configPath).getSessionToken(), + ).rejects.toMatchObject({ + message: AUTH_REFRESH_FAILED_MESSAGE, + reason: "network_error", + }); }); }); diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts index 9789aa00e9b..6794000011f 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts @@ -1,5 +1,486 @@ -export { - JwtApiAuthProvider, - JwtApiAuthProvider as JwtAuthProvider, - type JwtApiAuthProviderOptions, -} from "../JwtApiAuthProvider"; +import * as fs from "node:fs"; +import { dirname } from "node:path"; +import { + AuthRefreshFailedError, + type AuthRefreshFailureReason, +} from "../../../errors"; +import { SESSION_EXPIRED_HINT } from "../hint"; +import type { ApiAuthProvider, AuthSessionEventPublisher } from "../types"; + +const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const JWT_CACHE_DURATION_MS = 55 * 60 * 1000; +const TRANSIENT_RETRY_INTERVAL_MS = 60 * 1000; +const CLIENT_ID = "superset-cli"; + +interface SupersetAuthConfig { + accessToken: string; + refreshToken?: string; + expiresAt: number; +} + +type RefreshAccessToken = (refreshToken: string) => Promise; + +interface SupersetConfig { + auth?: SupersetAuthConfig; + apiKey?: string; + organizationId?: string; + [key: string]: unknown; +} + +interface RefreshFailureClassification { + reason: AuthRefreshFailureReason; + statusCode?: number; +} + +export type JwtApiAuthProviderExpiredState = + | { + kind: "expired_permanent"; + reason: "invalid_grant"; + statusCode?: number; + } + | { + kind: "expired_transient"; + reason: "network_error" | "http_error"; + lastFailureAt: number; + statusCode?: number; + }; + +export type JwtApiAuthProviderAuthState = + | { kind: "healthy" } + | JwtApiAuthProviderExpiredState; + +export interface JwtApiAuthProviderOptions { + /** + * Returns the current session/api-key/JWT token to authenticate with. + * Used directly when no auth config path is available, and as a fallback + * when the config file has not been written yet. + */ + getSessionToken: () => Promise; + apiUrl: string; + authConfigPath?: string; + eventBus?: AuthSessionEventPublisher; + refreshAccessToken?: RefreshAccessToken; +} + +function looksLikeJwt(token: string): boolean { + const parts = token.split("."); + return parts.length === 3 && parts.every(Boolean); +} + +function readJwtExp(token: string): number | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const payload = parts[1]; + if (!payload) return null; + + try { + const parsed: unknown = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) && + typeof (parsed as { exp?: unknown }).exp === "number" + ) { + return (parsed as { exp: number }).exp * 1000; + } + return null; + } catch { + return null; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSupersetAuthConfig(value: unknown): value is SupersetAuthConfig { + if (!isObject(value)) return false; + return ( + typeof value.accessToken === "string" && + typeof value.expiresAt === "number" && + (value.refreshToken === undefined || typeof value.refreshToken === "string") + ); +} + +function readStatusCode(error: unknown): number | undefined { + if (isObject(error) && typeof error.statusCode === "number") { + return error.statusCode; + } + const message = error instanceof Error ? error.message : String(error); + const match = /Token refresh failed:\s*(\d{3})/.exec(message); + if (!match?.[1]) return undefined; + return Number.parseInt(match[1], 10); +} + +function errorIndicatesInvalidGrant(error: unknown): boolean { + if (isObject(error) && error.invalidGrant === true) { + return true; + } + const message = error instanceof Error ? error.message : String(error); + const suggestion = + isObject(error) && typeof error.suggestion === "string" + ? error.suggestion + : ""; + return /\binvalid_grant\b/i.test(`${message}\n${suggestion}`); +} + +function reasonForRefreshError(error: unknown): RefreshFailureClassification { + const statusCode = readStatusCode(error); + if (statusCode === undefined) { + return { reason: "network_error" }; + } + if ( + statusCode === 401 || + ((statusCode === 400 || statusCode === 403) && + errorIndicatesInvalidGrant(error)) + ) { + return { reason: "invalid_grant", statusCode }; + } + return { reason: "http_error", statusCode }; +} + +class OAuthRefreshRequestError extends Error { + constructor( + readonly statusCode: number, + readonly invalidGrant: boolean, + ) { + super(`Token refresh failed: ${statusCode}`); + this.name = "OAuthRefreshRequestError"; + } +} + +async function refreshAccessTokenFromOAuth( + apiUrl: string, + refreshToken: string, +): Promise { + const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + resource: apiUrl, + }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new OAuthRefreshRequestError( + response.status, + /\binvalid_grant\b/i.test(body), + ); + } + + const data = (await response.json()) as { + access_token: string; + expires_in?: number; + refresh_token?: string; + }; + const expiresIn = data.expires_in ?? 60 * 60; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, + expiresAt: Date.now() + expiresIn * 1000, + }; +} + +function readConfigAtPath(configPath: string): SupersetConfig { + if (!fs.existsSync(configPath)) return {}; + const parsed: unknown = JSON.parse(fs.readFileSync(configPath, "utf-8")); + return isObject(parsed) ? parsed : {}; +} + +function writeConfigAtPath(configPath: string, config: SupersetConfig): void { + const configDir = dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + } + try { + const stat = fs.statSync(configDir); + if ((stat.mode & 0o077) !== 0) fs.chmodSync(configDir, 0o700); + } catch {} + + const tmpPath = `${configPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + try { + fs.chmodSync(tmpPath, 0o600); + } catch {} + fs.renameSync(tmpPath, configPath); +} + +export class JwtApiAuthProvider implements ApiAuthProvider { + private readonly loadSessionToken: () => Promise; + private readonly apiUrl: string; + private readonly authConfigPath: string | undefined; + private readonly refreshAccessToken: RefreshAccessToken; + private eventBus: AuthSessionEventPublisher | undefined; + private cachedJwt: string | null = null; + private cachedJwtSessionToken: string | null = null; + private cachedJwtExpiresAt = 0; + private currentCredential: SupersetAuthConfig | null = null; + private inflightRefresh: Promise | null = null; + private expired: JwtApiAuthProviderExpiredState | null = null; + + constructor(options: JwtApiAuthProviderOptions) { + this.loadSessionToken = options.getSessionToken; + this.apiUrl = options.apiUrl; + this.authConfigPath = options.authConfigPath; + this.eventBus = options.eventBus; + this.refreshAccessToken = + options.refreshAccessToken ?? + ((refreshToken) => + refreshAccessTokenFromOAuth(this.apiUrl, refreshToken)); + } + + async getHeaders(): Promise> { + const jwt = await this.getJwt(); + return { Authorization: `Bearer ${jwt}` }; + } + + invalidateCache(): void { + this.cachedJwt = null; + this.cachedJwtSessionToken = null; + this.cachedJwtExpiresAt = 0; + this.currentCredential = null; + } + + setEventBus(eventBus: AuthSessionEventPublisher): void { + this.eventBus = eventBus; + } + + isInAnyExpiredState(): boolean { + return this.expired !== null; + } + + isInExpiredState(): boolean { + return this.isInAnyExpiredState(); + } + + getAuthState(): JwtApiAuthProviderAuthState { + if (!this.expired) return { kind: "healthy" }; + return { ...this.expired }; + } + + async getSessionToken(): Promise { + if (!this.authConfigPath) { + return this.loadSessionToken(); + } + + if (this.expired?.kind === "expired_permanent") { + throw new AuthRefreshFailedError({ + reason: this.expired.reason, + statusCode: this.expired.statusCode, + }); + } + + if (this.expired?.kind === "expired_transient") { + const elapsedMs = Date.now() - this.expired.lastFailureAt; + if (elapsedMs < TRANSIENT_RETRY_INTERVAL_MS) { + throw new AuthRefreshFailedError({ + reason: this.expired.reason, + statusCode: this.expired.statusCode, + }); + } + } + + const credential = this.currentCredential ?? this.readCurrentCredential(); + if (!credential) { + return this.loadSessionToken(); + } + this.currentCredential = credential; + + const expiresAt = readJwtExp(credential.accessToken); + const needsRefresh = + this.expired !== null || + (expiresAt !== null && expiresAt - Date.now() <= JWT_REFRESH_BUFFER_MS); + if (!needsRefresh) { + return credential.accessToken; + } + + if (this.inflightRefresh) { + return this.inflightRefresh; + } + + this.inflightRefresh = this.refreshCredential(credential).finally(() => { + this.inflightRefresh = null; + }); + return this.inflightRefresh; + } + + async getJwt(): Promise { + const sessionToken = await this.getSessionToken(); + + // OAuth access tokens are already JWTs. Delegate to getSessionToken so + // host-owned refresh and single-flight behavior run before pass-through. + if (looksLikeJwt(sessionToken)) { + return sessionToken; + } + + if ( + this.cachedJwt && + this.cachedJwtSessionToken === sessionToken && + Date.now() < this.cachedJwtExpiresAt - JWT_REFRESH_BUFFER_MS + ) { + return this.cachedJwt; + } + + // better-auth's apiKey plugin reads `sk_live_…` from x-api-key, not + // Authorization: Bearer; mirror what the CLI's tRPC client does in + // packages/cli/src/lib/api-client.ts. + const response = await fetch(`${this.apiUrl}/api/auth/token`, { + headers: sessionToken.startsWith("sk_live_") + ? { "x-api-key": sessionToken } + : { Authorization: `Bearer ${sessionToken}` }, + }); + if (!response.ok) { + throw new Error(`Failed to mint JWT: ${response.status}`); + } + const data = (await response.json()) as { token: string }; + this.cachedJwt = data.token; + this.cachedJwtSessionToken = sessionToken; + this.cachedJwtExpiresAt = Date.now() + JWT_CACHE_DURATION_MS; + return data.token; + } + + private readCurrentCredential(): SupersetAuthConfig | null { + if (!this.authConfigPath) return null; + const config = readConfigAtPath(this.authConfigPath); + return isSupersetAuthConfig(config.auth) ? config.auth : null; + } + + private async refreshCredential( + credential: SupersetAuthConfig, + ): Promise { + if (!credential.refreshToken) { + this.transitionToPermanent({ reason: "invalid_grant" }); + this.wipeRefreshToken(); + throw new AuthRefreshFailedError({ reason: "invalid_grant" }); + } + + let refreshed: SupersetAuthConfig; + try { + refreshed = await this.runRefresh(credential.refreshToken); + } catch (error) { + const failure = + error instanceof AuthRefreshFailedError + ? { reason: error.reason, statusCode: error.statusCode } + : reasonForRefreshError(error); + this.handleRefreshFailure(failure); + throw new AuthRefreshFailedError(failure); + } + + const nextCredential: SupersetAuthConfig = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken ?? credential.refreshToken, + expiresAt: refreshed.expiresAt, + }; + + if (this.authConfigPath) { + const latestConfig = readConfigAtPath(this.authConfigPath); + writeConfigAtPath(this.authConfigPath, { + ...latestConfig, + auth: nextCredential, + }); + } + + this.currentCredential = nextCredential; + this.cachedJwt = null; + this.cachedJwtSessionToken = null; + this.cachedJwtExpiresAt = 0; + this.transitionToHealthy(); + return nextCredential.accessToken; + } + + private async runRefresh(refreshToken: string): Promise { + const refreshed = await this.refreshAccessToken(refreshToken); + return { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken ?? refreshToken, + expiresAt: refreshed.expiresAt, + }; + } + + private handleRefreshFailure(failure: RefreshFailureClassification): void { + if (failure.reason === "invalid_grant") { + this.transitionToPermanent({ + reason: failure.reason, + statusCode: failure.statusCode, + }); + this.wipeRefreshToken(); + return; + } + + this.transitionToTransient({ + reason: failure.reason, + statusCode: failure.statusCode, + }); + } + + private transitionToPermanent(failure: { + reason: "invalid_grant"; + statusCode?: number; + }): void { + const wasHealthy = this.expired === null; + const occurredAt = Date.now(); + this.expired = { + kind: "expired_permanent", + reason: failure.reason, + statusCode: failure.statusCode, + }; + if (wasHealthy) { + this.eventBus?.broadcastAuthSessionExpired({ + reason: failure.reason, + hint: SESSION_EXPIRED_HINT, + occurredAt, + }); + } + } + + private transitionToTransient(failure: { + reason: "network_error" | "http_error"; + statusCode?: number; + }): void { + const wasHealthy = this.expired === null; + const occurredAt = Date.now(); + this.expired = { + kind: "expired_transient", + reason: failure.reason, + lastFailureAt: occurredAt, + statusCode: failure.statusCode, + }; + if (wasHealthy) { + this.eventBus?.broadcastAuthSessionExpired({ + reason: failure.reason, + hint: SESSION_EXPIRED_HINT, + occurredAt, + }); + } + } + + private transitionToHealthy(): void { + const wasExpiredTransient = this.expired?.kind === "expired_transient"; + const occurredAt = Date.now(); + this.expired = null; + if (wasExpiredTransient) { + this.eventBus?.broadcastAuthSessionRestored({ occurredAt }); + } + } + + private wipeRefreshToken(): void { + if (!this.authConfigPath) return; + + const latestConfig = readConfigAtPath(this.authConfigPath); + if (!latestConfig.auth?.refreshToken) return; + + const nextAuth = { ...latestConfig.auth }; + delete nextAuth.refreshToken; + writeConfigAtPath(this.authConfigPath, { + ...latestConfig, + auth: nextAuth, + }); + } +} diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts index d2abced4127..98fa128ff14 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/index.ts @@ -1,5 +1 @@ -export { - JwtApiAuthProvider, - JwtApiAuthProvider as JwtAuthProvider, - type JwtApiAuthProviderOptions, -} from "./JwtAuthProvider"; +export { JwtApiAuthProvider } from "./JwtAuthProvider"; diff --git a/packages/host-service/src/providers/auth/index.ts b/packages/host-service/src/providers/auth/index.ts index 69fdbb910a9..fe2f13880c2 100644 --- a/packages/host-service/src/providers/auth/index.ts +++ b/packages/host-service/src/providers/auth/index.ts @@ -1,8 +1,4 @@ export { DeviceKeyApiAuthProvider } from "./DeviceKeyAuthProvider"; export { SESSION_EXPIRED_HINT } from "./hint"; -export { - JwtApiAuthProvider, - JwtApiAuthProvider as JwtAuthProvider, - type JwtApiAuthProviderOptions, -} from "./JwtApiAuthProvider"; +export { JwtApiAuthProvider } from "./JwtAuthProvider"; export type { ApiAuthProvider, AuthSessionEventPublisher } from "./types"; diff --git a/packages/shared/package.json b/packages/shared/package.json index f2f2953807f..bafa022fca3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,10 +24,6 @@ "types": "./src/auth/index.ts", "default": "./src/auth/index.ts" }, - "./auth/token-refresh": { - "types": "./src/auth/token-refresh.ts", - "default": "./src/auth/token-refresh.ts" - }, "./agent-command": { "types": "./src/agent-command.ts", "default": "./src/agent-command.ts" diff --git a/packages/shared/src/auth/index.ts b/packages/shared/src/auth/index.ts index bfb74e1883f..3cff44b3858 100644 --- a/packages/shared/src/auth/index.ts +++ b/packages/shared/src/auth/index.ts @@ -1,3 +1,2 @@ export * from "./authorization"; export * from "./roles"; -export * from "./token-refresh"; diff --git a/packages/shared/src/auth/token-refresh.ts b/packages/shared/src/auth/token-refresh.ts deleted file mode 100644 index f65ae8a7170..00000000000 --- a/packages/shared/src/auth/token-refresh.ts +++ /dev/null @@ -1,59 +0,0 @@ -const CLIENT_ID = "superset-cli"; - -export interface LoginResult { - accessToken: string; - refreshToken?: string; - expiresAt: number; -} - -class CLIError extends Error { - constructor( - message: string, - readonly suggestion?: string, - ) { - super(message); - this.name = "CLIError"; - } -} - -function getApiUrl(): string { - return process.env.SUPERSET_API_URL || "https://api.superset.sh"; -} - -export async function refreshAccessToken( - refreshToken: string, -): Promise { - const apiUrl = getApiUrl(); - const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - resource: apiUrl, - }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new CLIError( - `Token refresh failed: ${response.status}`, - body || "Run `superset auth login` again.", - ); - } - - const data = (await response.json()) as { - access_token: string; - token_type: string; - expires_in?: number; - refresh_token?: string; - }; - - const expiresIn = data.expires_in ?? 60 * 60; - return { - accessToken: data.access_token, - refreshToken: data.refresh_token ?? refreshToken, - expiresAt: Date.now() + expiresIn * 1000, - }; -} From 675ecababa9d14074cf02bf075520308dc60398d Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 17:40:53 -0700 Subject: [PATCH 08/13] fix: fail logout when host shutdown times out --- .../cli/src/commands/auth/logout/command.test.ts | 14 +++++++++++--- packages/cli/src/commands/auth/logout/command.ts | 7 +++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/auth/logout/command.test.ts b/packages/cli/src/commands/auth/logout/command.test.ts index 5ab46c64c35..8a432a6407d 100644 --- a/packages/cli/src/commands/auth/logout/command.test.ts +++ b/packages/cli/src/commands/auth/logout/command.test.ts @@ -106,7 +106,7 @@ describe("auth logout", () => { expect(readConfig()).toEqual({ organizationId: "org_1" }); }); - it("waits up to five seconds for host death, then clears credentials", async () => { + it("fails logout and keeps credentials when host does not stop within timeout", async () => { const pid = 91_002; writeLoggedInConfig(); writeHostManifest(pid); @@ -134,7 +134,7 @@ describe("auth logout", () => { }) as typeof process.kill); try { - await runLogout(); + await expect(runLogout()).rejects.toThrow("Host service did not stop"); expect(timeoutSpy).toHaveBeenCalledTimes(50); } finally { killSpy.mockRestore(); @@ -142,6 +142,14 @@ describe("auth logout", () => { nowSpy.mockRestore(); } - expect(readConfig()).toEqual({ organizationId: "org_1" }); + expect(readConfig()).toEqual({ + organizationId: "org_1", + apiKey: "sk_live_existing", + auth: { + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: expect.any(Number), + }, + }); }); }); diff --git a/packages/cli/src/commands/auth/logout/command.ts b/packages/cli/src/commands/auth/logout/command.ts index ca373469bb9..6667dbc52c2 100644 --- a/packages/cli/src/commands/auth/logout/command.ts +++ b/packages/cli/src/commands/auth/logout/command.ts @@ -39,6 +39,13 @@ async function stopRunningHost( if (!isProcessAlive(manifest.pid)) return; await new Promise((resolve) => setTimeout(resolve, HOST_SHUTDOWN_POLL_MS)); } + + if (isProcessAlive(manifest.pid)) { + throw new CLIError( + `Host service did not stop within ${HOST_SHUTDOWN_TIMEOUT_MS}ms (pid ${manifest.pid})`, + "Try: superset stop", + ); + } } export default command({ From affd7e3062929d4d015afbaffce0a6aef97a4dae Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 17:46:07 -0700 Subject: [PATCH 09/13] fix: use unique temp files for config writes --- packages/cli/src/lib/config.test.ts | 37 ++++++++++++++----- packages/cli/src/lib/config.ts | 4 +- .../JwtAuthProvider/JwtAuthProvider.test.ts | 16 +++++++- .../auth/JwtAuthProvider/JwtAuthProvider.ts | 8 +++- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/lib/config.test.ts b/packages/cli/src/lib/config.test.ts index 1bf71b9759b..5d9c6dd9d71 100644 --- a/packages/cli/src/lib/config.test.ts +++ b/packages/cli/src/lib/config.test.ts @@ -16,6 +16,11 @@ const tmpPath = `${configPath}.tmp`; function removeConfigFiles(): void { fs.rmSync(configPath, { force: true }); fs.rmSync(tmpPath, { force: true }); + for (const file of fs.readdirSync(tempHome)) { + if (file.startsWith("config.json.") && file.endsWith(".tmp")) { + fs.rmSync(path.join(tempHome, file), { force: true }); + } + } } afterEach(() => { @@ -32,7 +37,7 @@ afterAll(() => { }); describe("writeConfig", () => { - it("writes config to a temp file before renaming it into place", () => { + it("writes config to a unique temp file before renaming it into place", () => { const config = { auth: { accessToken: "access-token", @@ -41,12 +46,17 @@ describe("writeConfig", () => { }, }; const originalRenameSync = fs.renameSync; + const tempPaths: string[] = []; const writeSpy = spyOn(fs, "writeFileSync"); const renameSpy = spyOn(fs, "renameSync").mockImplementation( (oldPath: PathLike, newPath: PathLike) => { - expect(oldPath).toBe(tmpPath); + const tempPath = String(oldPath); + tempPaths.push(tempPath); + expect(tempPath).not.toBe(tmpPath); + expect(tempPath.startsWith(`${configPath}.`)).toBe(true); + expect(tempPath.endsWith(".tmp")).toBe(true); expect(newPath).toBe(configPath); - expect(fs.existsSync(tmpPath)).toBe(true); + expect(fs.existsSync(tempPath)).toBe(true); originalRenameSync(oldPath, newPath); }, ); @@ -54,11 +64,12 @@ describe("writeConfig", () => { writeConfig(config); expect(writeSpy).toHaveBeenCalledWith( - tmpPath, + tempPaths[0], JSON.stringify(config, null, 2), - { mode: 0o600 }, + { mode: 0o600, flag: "wx" }, ); expect(renameSpy).toHaveBeenCalledTimes(1); + expect(tempPaths).toHaveLength(1); expect(readConfig()).toEqual(config); expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); @@ -83,9 +94,13 @@ describe("writeConfig", () => { }; writeConfig(originalConfig); - const renameSpy = spyOn(fs, "renameSync").mockImplementation(() => { - throw new Error("simulated crash before rename"); - }); + let pendingTmpPath: string | undefined; + const renameSpy = spyOn(fs, "renameSync").mockImplementation( + (oldPath: PathLike) => { + pendingTmpPath = String(oldPath); + throw new Error("simulated crash before rename"); + }, + ); expect(() => writeConfig(nextConfig)).toThrow( "simulated crash before rename", @@ -94,7 +109,11 @@ describe("writeConfig", () => { expect(JSON.parse(fs.readFileSync(configPath, "utf-8"))).toEqual( originalConfig, ); - expect(JSON.parse(fs.readFileSync(tmpPath, "utf-8"))).toEqual(nextConfig); + expect(pendingTmpPath).toBeDefined(); + expect(pendingTmpPath).not.toBe(tmpPath); + expect(JSON.parse(fs.readFileSync(pendingTmpPath!, "utf-8"))).toEqual( + nextConfig, + ); renameSpy.mockRestore(); }); diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 2134a8a919c..e8623197f3d 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import * as fs from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -38,9 +39,10 @@ export function readConfig(): SupersetConfig { export function writeConfig(config: SupersetConfig): void { ensureDir(); - const tmpPath = `${CONFIG_PATH}.tmp`; + const tmpPath = `${CONFIG_PATH}.${process.pid}.${randomUUID()}.tmp`; fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600, + flag: "wx", }); try { fs.chmodSync(tmpPath, 0o600); diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts index bdbc8fe7f8e..008f3e262e2 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts @@ -7,6 +7,7 @@ import { mock, spyOn, } from "bun:test"; +import type { PathLike } from "node:fs"; import * as fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -219,14 +220,25 @@ describe("JwtApiAuthProvider", () => { expiresAt: Date.now() + 60_000, }, }); - const renameSpy = spyOn(fs, "renameSync"); + const originalRenameSync = fs.renameSync; + let atomicTmpPath: string | undefined; + const renameSpy = spyOn(fs, "renameSync").mockImplementation( + (oldPath: PathLike, newPath: PathLike) => { + atomicTmpPath = String(oldPath); + expect(newPath).toBe(configPath); + originalRenameSync(oldPath, newPath); + }, + ); const token = await createProvider(configPath).getSessionToken(); expect(token).toBe(refreshedToken); expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); expect(refreshAccessTokenMock).toHaveBeenCalledWith("refresh-token"); - expect(renameSpy).toHaveBeenCalledWith(`${configPath}.tmp`, configPath); + expect(atomicTmpPath).toBeDefined(); + expect(atomicTmpPath).not.toBe(`${configPath}.tmp`); + expect(atomicTmpPath?.startsWith(`${configPath}.`)).toBe(true); + expect(atomicTmpPath?.endsWith(".tmp")).toBe(true); expect(readConfig(configPath)).toEqual({ organizationId: "org_1", apiKey: "sk_live_existing", diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts index 6794000011f..f084fb74160 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import * as fs from "node:fs"; import { dirname } from "node:path"; import { @@ -204,8 +205,11 @@ function writeConfigAtPath(configPath: string, config: SupersetConfig): void { if ((stat.mode & 0o077) !== 0) fs.chmodSync(configDir, 0o700); } catch {} - const tmpPath = `${configPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + const tmpPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { + mode: 0o600, + flag: "wx", + }); try { fs.chmodSync(tmpPath, 0o600); } catch {} From 6399a26b5597ec5f18978e76789a00f205d88ec5 Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Tue, 19 May 2026 19:50:14 -0700 Subject: [PATCH 10/13] fix: refresh host auth after 401 --- .../src/commands/auth/logout/command.test.ts | 155 --- .../cli/src/commands/auth/logout/command.ts | 48 - .../cli/src/commands/start/command.test.ts | 277 ------ packages/cli/src/commands/start/command.ts | 3 + packages/cli/src/lib/auth.test.ts | 44 + packages/cli/src/lib/config.test.ts | 153 ++- packages/cli/src/lib/config.ts | 92 +- packages/cli/src/lib/host/spawn.test.ts | 179 ++-- packages/cli/src/lib/host/spawn.ts | 53 +- packages/cli/src/lib/resolve-auth.test.ts | 320 +------ packages/cli/src/lib/resolve-auth.ts | 11 +- packages/host-service/src/app.ts | 2 - packages/host-service/src/env.test.ts | 79 +- packages/host-service/src/errors.ts | 29 - packages/host-service/src/events/event-bus.ts | 18 - packages/host-service/src/events/index.ts | 3 - packages/host-service/src/events/types.ts | 19 - .../ConfigFileSessionTokenSource.ts | 186 ++++ .../ConfigFileSessionTokenSource/index.ts | 1 + .../JwtAuthProvider/JwtAuthProvider.test.ts | 893 ++++++------------ .../auth/JwtAuthProvider/JwtAuthProvider.ts | 456 +-------- .../host-service/src/providers/auth/hint.ts | 2 - .../host-service/src/providers/auth/index.ts | 4 +- .../host-service/src/providers/auth/types.ts | 17 - packages/host-service/src/serve.ts | 19 +- .../host-service/src/terminal/env-strip.ts | 1 + .../host-service/src/terminal/env.test.ts | 2 + .../src/trpc/auth-expired-middleware.test.ts | 71 -- packages/host-service/src/trpc/error-types.ts | 8 - packages/host-service/src/trpc/index.ts | 7 - packages/host-service/src/types.ts | 2 - 31 files changed, 894 insertions(+), 2260 deletions(-) delete mode 100644 packages/cli/src/commands/auth/logout/command.test.ts delete mode 100644 packages/cli/src/commands/start/command.test.ts create mode 100644 packages/cli/src/lib/auth.test.ts delete mode 100644 packages/host-service/src/errors.ts create mode 100644 packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts create mode 100644 packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/index.ts delete mode 100644 packages/host-service/src/providers/auth/hint.ts delete mode 100644 packages/host-service/src/trpc/auth-expired-middleware.test.ts diff --git a/packages/cli/src/commands/auth/logout/command.test.ts b/packages/cli/src/commands/auth/logout/command.test.ts deleted file mode 100644 index 8a432a6407d..00000000000 --- a/packages/cli/src/commands/auth/logout/command.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { afterAll, afterEach, describe, expect, it, spyOn } from "bun:test"; -import * as fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; -const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "superset-cli-logout-")); -process.env.SUPERSET_HOME_DIR = tempHome; - -const { readConfig, writeConfig } = await import("../../../lib/config"); -const { writeManifest } = await import("../../../lib/host/manifest"); -const { default: logoutCommand } = await import("./command"); - -function noSuchProcessError(): NodeJS.ErrnoException { - const error = new Error("No such process") as NodeJS.ErrnoException; - error.code = "ESRCH"; - return error; -} - -function writeLoggedInConfig(): void { - writeConfig({ - organizationId: "org_1", - apiKey: "sk_live_existing", - auth: { - accessToken: "access-token", - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); -} - -function writeHostManifest(pid: number): void { - writeManifest({ - pid, - endpoint: "http://127.0.0.1:49152", - authToken: "host-token", - startedAt: Date.now(), - organizationId: "org_1", - }); -} - -async function runLogout(): Promise { - await logoutCommand.run({ - options: {}, - args: {}, - ctx: {}, - signal: new AbortController().signal, - }); -} - -afterEach(() => { - fs.rmSync(path.join(tempHome, "config.json"), { force: true }); - fs.rmSync(path.join(tempHome, "config.json.tmp"), { force: true }); - fs.rmSync(path.join(tempHome, "host"), { recursive: true, force: true }); -}); - -afterAll(() => { - fs.rmSync(tempHome, { recursive: true, force: true }); - if (originalSupersetHomeDir === undefined) { - delete process.env.SUPERSET_HOME_DIR; - } else { - process.env.SUPERSET_HOME_DIR = originalSupersetHomeDir; - } -}); - -describe("auth logout", () => { - it("sends SIGTERM to the running host before clearing credentials", async () => { - const pid = 91_001; - writeLoggedInConfig(); - writeHostManifest(pid); - const order: string[] = []; - let sigtermSent = false; - let checksAfterSigterm = 0; - const killSpy = spyOn(process, "kill").mockImplementation((( - targetPid: number, - signal?: NodeJS.Signals | number, - ) => { - expect(targetPid).toBe(pid); - if (signal === 0) { - order.push(sigtermSent ? "alive-after-sigterm" : "alive-before"); - if (!sigtermSent) return true; - checksAfterSigterm += 1; - if (checksAfterSigterm < 2) return true; - throw noSuchProcessError(); - } - - expect(signal).toBe("SIGTERM"); - order.push("sigterm"); - expect(readConfig().auth?.refreshToken).toBe("refresh-token"); - sigtermSent = true; - return true; - }) as typeof process.kill); - - try { - await runLogout(); - } finally { - killSpy.mockRestore(); - } - - expect(order).toEqual([ - "alive-before", - "sigterm", - "alive-after-sigterm", - "alive-after-sigterm", - ]); - expect(readConfig()).toEqual({ organizationId: "org_1" }); - }); - - it("fails logout and keeps credentials when host does not stop within timeout", async () => { - const pid = 91_002; - writeLoggedInConfig(); - writeHostManifest(pid); - let now = 1_700_000_000_000; - const nowSpy = spyOn(Date, "now").mockImplementation(() => now); - const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( - handler: Parameters[0], - timeout?: Parameters[1], - ...args: unknown[] - ) => { - if (typeof timeout === "number") now += timeout; - if (typeof handler === "function") { - const callback = handler as (...callbackArgs: unknown[]) => void; - callback(...args); - } - return 0 as unknown as ReturnType; - }) as typeof setTimeout); - const killSpy = spyOn(process, "kill").mockImplementation((( - targetPid: number, - signal?: NodeJS.Signals | number, - ) => { - expect(targetPid).toBe(pid); - expect(signal === 0 || signal === "SIGTERM").toBe(true); - return true; - }) as typeof process.kill); - - try { - await expect(runLogout()).rejects.toThrow("Host service did not stop"); - expect(timeoutSpy).toHaveBeenCalledTimes(50); - } finally { - killSpy.mockRestore(); - timeoutSpy.mockRestore(); - nowSpy.mockRestore(); - } - - expect(readConfig()).toEqual({ - organizationId: "org_1", - apiKey: "sk_live_existing", - auth: { - accessToken: "access-token", - refreshToken: "refresh-token", - expiresAt: expect.any(Number), - }, - }); - }); -}); diff --git a/packages/cli/src/commands/auth/logout/command.ts b/packages/cli/src/commands/auth/logout/command.ts index 6667dbc52c2..d103ff4dc3e 100644 --- a/packages/cli/src/commands/auth/logout/command.ts +++ b/packages/cli/src/commands/auth/logout/command.ts @@ -1,59 +1,11 @@ -import { CLIError } from "@superset/cli-framework"; import { command } from "../../../lib/command"; import { readConfig, writeConfig } from "../../../lib/config"; -import { isProcessAlive, readManifest } from "../../../lib/host/manifest"; - -const HOST_SHUTDOWN_TIMEOUT_MS = 5_000; -const HOST_SHUTDOWN_POLL_MS = 100; - -function isNoSuchProcessError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code: unknown }).code === "ESRCH" - ); -} - -async function stopRunningHost( - organizationId: string | undefined, -): Promise { - if (!organizationId) return; - - const manifest = readManifest(organizationId); - if (!manifest || !isProcessAlive(manifest.pid)) return; - - try { - process.kill(manifest.pid, "SIGTERM"); - } catch (error) { - if (isNoSuchProcessError(error)) return; - throw new CLIError( - `Failed to stop host service (pid ${manifest.pid}): ${ - error instanceof Error ? error.message : "unknown error" - }`, - ); - } - - const deadline = Date.now() + HOST_SHUTDOWN_TIMEOUT_MS; - while (Date.now() < deadline) { - if (!isProcessAlive(manifest.pid)) return; - await new Promise((resolve) => setTimeout(resolve, HOST_SHUTDOWN_POLL_MS)); - } - - if (isProcessAlive(manifest.pid)) { - throw new CLIError( - `Host service did not stop within ${HOST_SHUTDOWN_TIMEOUT_MS}ms (pid ${manifest.pid})`, - "Try: superset stop", - ); - } -} export default command({ description: "Clear stored credentials", skipMiddleware: true, run: async () => { const config = readConfig(); - await stopRunningHost(config.organizationId); delete config.auth; delete config.apiKey; writeConfig(config); diff --git a/packages/cli/src/commands/start/command.test.ts b/packages/cli/src/commands/start/command.test.ts deleted file mode 100644 index 3efd8667269..00000000000 --- a/packages/cli/src/commands/start/command.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { CommandTree } from "@superset/cli-framework"; -import type { ApiClient } from "../../lib/api-client"; -import type { LoginResult } from "../../lib/auth"; -import type { SpawnHostOptions, SpawnHostResult } from "../../lib/host/spawn"; - -const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; -const originalSupersetApiKey = process.env.SUPERSET_API_KEY; -const tempHome = fs.mkdtempSync( - path.join(os.tmpdir(), "superset-cli-start-auth-"), -); -process.env.SUPERSET_HOME_DIR = tempHome; -delete process.env.SUPERSET_API_KEY; - -const organization = { id: "org-1", name: "Acme" }; -const analyticsMutateMock = mock(async () => undefined); -const myOrganizationQueryMock = mock(async () => organization); -const fakeApi = { - analytics: { - captureEvent: { - mutate: analyticsMutateMock, - }, - }, - user: { - myOrganization: { - query: myOrganizationQueryMock, - }, - }, -} as unknown as ApiClient; - -const createApiClientMock = mock( - (_options: { bearer: string; organizationId?: string }): ApiClient => fakeApi, -); -const refreshAccessTokenMock = mock( - async (_refreshToken: string): Promise => ({ - accessToken: "refreshed-access-token", - refreshToken: "rotated-refresh-token", - expiresAt: Date.now() + 60 * 60 * 1000, - }), -); -const spawnHostServiceMock = mock( - async (_options: SpawnHostOptions): Promise => ({ - pid: 12_345, - port: 54_321, - secret: "host-secret", - }), -); - -const clackIntroMock = mock(() => undefined); -const clackOutroMock = mock(() => undefined); -const clackSpinnerStartMock = mock(() => undefined); -const clackSpinnerStopMock = mock(() => undefined); -const clackSpinnerMock = mock(() => ({ - start: clackSpinnerStartMock, - stop: clackSpinnerStopMock, -})); -const clackInfoMock = mock(() => undefined); - -mock.module("../../lib/api-client", () => ({ - createApiClient: createApiClientMock, -})); - -mock.module("../../lib/auth", () => ({ - refreshAccessToken: refreshAccessTokenMock, -})); - -mock.module("../../lib/host/spawn", () => ({ - spawnHostService: spawnHostServiceMock, -})); - -mock.module("@clack/prompts", () => ({ - intro: clackIntroMock, - outro: clackOutroMock, - spinner: clackSpinnerMock, - log: { - info: clackInfoMock, - }, -})); - -const { run } = await import("@superset/cli-framework"); -const { readConfig, writeConfig } = await import("../../lib/config"); -const startCommand = (await import("./command")).default; -const cliMiddleware = (await import("../middleware")).default; -const cliConfig = (await import("../../../cli.config")).default; -const commandTree: CommandTree = { - commands: [ - { - path: ["start"], - command: - startCommand as unknown as CommandTree["commands"][number]["command"], - }, - ], - groups: [], - middleware: cliMiddleware, -}; - -class ProcessExit extends Error { - constructor(public readonly code: number | string | null | undefined) { - super(`process.exit(${String(code)})`); - this.name = "ProcessExit"; - } -} - -type RunResult = { - exitCode?: number | string | null; - stderr: string; - stdout: string; -}; - -type WriteCallback = (error?: Error | null) => void; - -async function runStartCommand(args: string[]): Promise { - const originalArgv = process.argv; - const stderrChunks: string[] = []; - const stdoutChunks: string[] = []; - - const stderrSpy = spyOn(process.stderr, "write").mockImplementation((( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | WriteCallback, - callback?: WriteCallback, - ): boolean => { - stderrChunks.push( - typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(), - ); - const done = - typeof encodingOrCallback === "function" ? encodingOrCallback : callback; - done?.(); - return true; - }) as typeof process.stderr.write); - const logSpy = spyOn(console, "log").mockImplementation( - (...values: unknown[]): void => { - stdoutChunks.push(values.map(String).join(" ")); - }, - ); - const exitSpy = spyOn(process, "exit").mockImplementation((( - code?: number | string | null, - ): never => { - throw new ProcessExit(code); - }) as typeof process.exit); - - process.argv = ["bun", "superset", ...args]; - try { - await run({ - name: cliConfig.name, - version: cliConfig.version, - globals: cliConfig.globals, - tree: commandTree, - }); - return { stderr: stderrChunks.join(""), stdout: stdoutChunks.join("\n") }; - } catch (error) { - if (error instanceof ProcessExit) { - return { - exitCode: error.code, - stderr: stderrChunks.join(""), - stdout: stdoutChunks.join("\n"), - }; - } - throw error; - } finally { - process.argv = originalArgv; - stderrSpy.mockRestore(); - logSpy.mockRestore(); - exitSpy.mockRestore(); - } -} - -function clearConfig(): void { - writeConfig({}); -} - -beforeEach(() => { - clearConfig(); - delete process.env.SUPERSET_API_KEY; -}); - -afterEach(() => { - clearConfig(); - analyticsMutateMock.mockClear(); - myOrganizationQueryMock.mockClear(); - createApiClientMock.mockClear(); - refreshAccessTokenMock.mockClear(); - spawnHostServiceMock.mockClear(); - clackIntroMock.mockClear(); - clackOutroMock.mockClear(); - clackSpinnerStartMock.mockClear(); - clackSpinnerStopMock.mockClear(); - clackSpinnerMock.mockClear(); - clackInfoMock.mockClear(); -}); - -afterAll(() => { - fs.rmSync(tempHome, { recursive: true, force: true }); - if (originalSupersetHomeDir === undefined) { - delete process.env.SUPERSET_HOME_DIR; - } else { - process.env.SUPERSET_HOME_DIR = originalSupersetHomeDir; - } - if (originalSupersetApiKey === undefined) { - delete process.env.SUPERSET_API_KEY; - } else { - process.env.SUPERSET_API_KEY = originalSupersetApiKey; - } -}); - -describe("superset start auth middleware", () => { - it("exits non-zero with the login hint before spawning when no session exists", async () => { - const result = await runStartCommand(["start"]); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Run: superset auth login"); - expect(createApiClientMock).not.toHaveBeenCalled(); - expect(myOrganizationQueryMock).not.toHaveBeenCalled(); - expect(spawnHostServiceMock).not.toHaveBeenCalled(); - }); - - it("spawns the host with the on-disk access token when the session is valid", async () => { - writeConfig({ - auth: { - accessToken: "on-disk-access-token", - refreshToken: "stored-refresh-token", - expiresAt: Date.now() + 10 * 60 * 1000, - }, - organizationId: organization.id, - }); - - const result = await runStartCommand(["start", "--daemon"]); - - expect(result.exitCode).toBeUndefined(); - expect(refreshAccessTokenMock).not.toHaveBeenCalled(); - expect(myOrganizationQueryMock).toHaveBeenCalledTimes(1); - expect(spawnHostServiceMock).toHaveBeenCalledTimes(1); - expect(spawnHostServiceMock.mock.calls[0]?.[0]).toMatchObject({ - organizationId: organization.id, - sessionToken: "on-disk-access-token", - daemon: true, - }); - }); - - it("refreshes a near-expired session in middleware before spawning", async () => { - writeConfig({ - auth: { - accessToken: "stale-access-token", - refreshToken: "stored-refresh-token", - expiresAt: Date.now() + 60 * 1000, - }, - organizationId: organization.id, - }); - - const result = await runStartCommand(["start", "--daemon"]); - - expect(result.exitCode).toBeUndefined(); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - expect(refreshAccessTokenMock).toHaveBeenCalledWith("stored-refresh-token"); - expect(spawnHostServiceMock).toHaveBeenCalledTimes(1); - expect(spawnHostServiceMock.mock.calls[0]?.[0]).toMatchObject({ - organizationId: organization.id, - sessionToken: "refreshed-access-token", - daemon: true, - }); - expect(readConfig().auth).toMatchObject({ - accessToken: "refreshed-access-token", - refreshToken: "rotated-refresh-token", - }); - }); -}); diff --git a/packages/cli/src/commands/start/command.ts b/packages/cli/src/commands/start/command.ts index 6df1196e6bd..1b6155a3290 100644 --- a/packages/cli/src/commands/start/command.ts +++ b/packages/cli/src/commands/start/command.ts @@ -1,6 +1,7 @@ import * as p from "@clack/prompts"; import { boolean, CLIError, number } from "@superset/cli-framework"; import { command } from "../../lib/command"; +import { SUPERSET_CONFIG_PATH } from "../../lib/config"; import { isProcessAlive, readManifest } from "../../lib/host/manifest"; import { spawnHostService } from "../../lib/host/spawn"; @@ -31,6 +32,8 @@ export default command({ const result = await spawnHostService({ organizationId: organization.id, sessionToken: ctx.bearer, + authConfigPath: + ctx.authSource === "oauth" ? SUPERSET_CONFIG_PATH : undefined, api: ctx.api, port: options.port, daemon: options.daemon ?? false, diff --git a/packages/cli/src/lib/auth.test.ts b/packages/cli/src/lib/auth.test.ts new file mode 100644 index 00000000000..7e505c04ce7 --- /dev/null +++ b/packages/cli/src/lib/auth.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { CLIError } from "@superset/cli-framework"; +import { refreshAccessToken } from "./auth"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("refreshAccessToken", () => { + test("sanitizes OAuth refresh failure details", async () => { + globalThis.fetch = mock( + async () => + new Response( + JSON.stringify({ + error: "invalid_grant", + access_token: "access-secret", + refresh_token: "refresh-secret", + redirect: "https://app.superset.test/callback?code=code-secret", + cookie: "session=session-secret", + }), + { status: 400 }, + ), + ) as unknown as typeof fetch; + + let thrown: unknown; + try { + await refreshAccessToken("refresh-secret"); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CLIError); + const error = thrown as CLIError; + const visibleText = `${error.message} ${error.suggestion ?? ""}`; + expect(visibleText).toContain("Token refresh failed: 400"); + expect(visibleText).toContain("superset auth login"); + expect(visibleText).not.toContain("access-secret"); + expect(visibleText).not.toContain("refresh-secret"); + expect(visibleText).not.toContain("session-secret"); + expect(visibleText).not.toContain("code-secret"); + }); +}); diff --git a/packages/cli/src/lib/config.test.ts b/packages/cli/src/lib/config.test.ts index 5d9c6dd9d71..cd2a765a3bb 100644 --- a/packages/cli/src/lib/config.test.ts +++ b/packages/cli/src/lib/config.test.ts @@ -1,34 +1,19 @@ -import { afterAll, afterEach, describe, expect, it, spyOn } from "bun:test"; -import type { PathLike } from "node:fs"; +import { afterAll, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; -const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "superset-cli-config-")); +const tempHome = mkdtempSync(join(tmpdir(), "superset-cli-config-")); process.env.SUPERSET_HOME_DIR = tempHome; -const { readConfig, writeConfig } = await import("./config"); - -const configPath = path.join(tempHome, "config.json"); -const tmpPath = `${configPath}.tmp`; - -function removeConfigFiles(): void { - fs.rmSync(configPath, { force: true }); - fs.rmSync(tmpPath, { force: true }); - for (const file of fs.readdirSync(tempHome)) { - if (file.startsWith("config.json.") && file.endsWith(".tmp")) { - fs.rmSync(path.join(tempHome, file), { force: true }); - } - } -} - -afterEach(() => { - removeConfigFiles(); -}); +const { SUPERSET_CONFIG_PATH, writeConfig, writeConfigFile } = await import( + "./config" +); afterAll(() => { - fs.rmSync(tempHome, { recursive: true, force: true }); + rmSync(tempHome, { recursive: true, force: true }); if (originalSupersetHomeDir === undefined) { delete process.env.SUPERSET_HOME_DIR; } else { @@ -36,85 +21,73 @@ afterAll(() => { } }); -describe("writeConfig", () => { - it("writes config to a unique temp file before renaming it into place", () => { - const config = { - auth: { - accessToken: "access-token", - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, +describe("config writes", () => { + test("writeConfig uses unique temp files", () => { + const writtenPaths: string[] = []; + const configPath = join(tempHome, "unique-temp-config.json"); + const testFs = { + chmodSync: fs.chmodSync, + mkdirSync: fs.mkdirSync, + renameSync: fs.renameSync, + statSync: fs.statSync, + unlinkSync: fs.unlinkSync, + writeFileSync: ( + path: fs.PathOrFileDescriptor, + data: string | NodeJS.ArrayBufferView, + options?: fs.WriteFileOptions, + ) => { + writtenPaths.push(String(path)); + fs.writeFileSync(path, data, options); }, }; - const originalRenameSync = fs.renameSync; - const tempPaths: string[] = []; - const writeSpy = spyOn(fs, "writeFileSync"); - const renameSpy = spyOn(fs, "renameSync").mockImplementation( - (oldPath: PathLike, newPath: PathLike) => { - const tempPath = String(oldPath); - tempPaths.push(tempPath); - expect(tempPath).not.toBe(tmpPath); - expect(tempPath.startsWith(`${configPath}.`)).toBe(true); - expect(tempPath.endsWith(".tmp")).toBe(true); - expect(newPath).toBe(configPath); - expect(fs.existsSync(tempPath)).toBe(true); - originalRenameSync(oldPath, newPath); - }, - ); - writeConfig(config); + writeConfigFile(configPath, { apiKey: "sk_live_one" }, testFs); + writeConfigFile(configPath, { apiKey: "sk_live_two" }, testFs); - expect(writeSpy).toHaveBeenCalledWith( - tempPaths[0], - JSON.stringify(config, null, 2), - { mode: 0o600, flag: "wx" }, + expect(writtenPaths).toHaveLength(2); + expect(writtenPaths[0]).not.toBe(writtenPaths[1]); + expect(writtenPaths.every((path) => path.endsWith(".config.tmp"))).toBe( + true, ); - expect(renameSpy).toHaveBeenCalledTimes(1); - expect(tempPaths).toHaveLength(1); - expect(readConfig()).toEqual(config); - expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); - - writeSpy.mockRestore(); - renameSpy.mockRestore(); + expect(JSON.parse(readFileSync(configPath, "utf-8"))).toEqual({ + apiKey: "sk_live_two", + }); }); - it("leaves the previous config intact when the process stops before rename", () => { - const originalConfig = { - auth: { - accessToken: "old-access-token", - refreshToken: "old-refresh-token", - expiresAt: Date.now() + 60_000, + test("writeConfig preserves old config if rename fails", () => { + const configPath = join(tempHome, "rename-failure-config.json"); + writeFileSync(configPath, JSON.stringify({ apiKey: "sk_live_old" })); + const tempPaths: string[] = []; + const testFs = { + chmodSync: fs.chmodSync, + mkdirSync: fs.mkdirSync, + renameSync: () => { + throw new Error("rename failed"); }, - }; - const nextConfig = { - auth: { - accessToken: "new-access-token", - refreshToken: "new-refresh-token", - expiresAt: Date.now() + 120_000, + statSync: fs.statSync, + unlinkSync: (path: fs.PathLike) => { + tempPaths.push(String(path)); + fs.unlinkSync(path); }, + writeFileSync: fs.writeFileSync, }; - writeConfig(originalConfig); - let pendingTmpPath: string | undefined; - const renameSpy = spyOn(fs, "renameSync").mockImplementation( - (oldPath: PathLike) => { - pendingTmpPath = String(oldPath); - throw new Error("simulated crash before rename"); - }, - ); + expect(() => + writeConfigFile(configPath, { apiKey: "sk_live_new" }, testFs), + ).toThrow(/rename failed/); - expect(() => writeConfig(nextConfig)).toThrow( - "simulated crash before rename", - ); + expect(JSON.parse(readFileSync(configPath, "utf-8"))).toEqual({ + apiKey: "sk_live_old", + }); + expect(tempPaths).toHaveLength(1); + expect(fs.existsSync(tempPaths[0] ?? "")).toBe(false); + }); - expect(JSON.parse(fs.readFileSync(configPath, "utf-8"))).toEqual( - originalConfig, - ); - expect(pendingTmpPath).toBeDefined(); - expect(pendingTmpPath).not.toBe(tmpPath); - expect(JSON.parse(fs.readFileSync(pendingTmpPath!, "utf-8"))).toEqual( - nextConfig, - ); + test("writeConfig writes the exported Superset config path", () => { + writeConfig({ organizationId: "org_123" }); - renameSpy.mockRestore(); + expect(JSON.parse(readFileSync(SUPERSET_CONFIG_PATH, "utf-8"))).toEqual({ + organizationId: "org_123", + }); }); }); diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index e8623197f3d..e6f84c9bd9c 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -1,7 +1,16 @@ import { randomUUID } from "node:crypto"; -import * as fs from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { env } from "./env"; export type SupersetConfig = { @@ -16,38 +25,85 @@ export type SupersetConfig = { export const SUPERSET_HOME_DIR = process.env.SUPERSET_HOME_DIR ?? join(homedir(), ".superset"); -const CONFIG_PATH = join(SUPERSET_HOME_DIR, "config.json"); +export const SUPERSET_CONFIG_PATH = join(SUPERSET_HOME_DIR, "config.json"); function ensureDir() { - if (!fs.existsSync(SUPERSET_HOME_DIR)) { - fs.mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); + if (!existsSync(SUPERSET_HOME_DIR)) { + mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); } try { - const stat = fs.statSync(SUPERSET_HOME_DIR); - if ((stat.mode & 0o077) !== 0) fs.chmodSync(SUPERSET_HOME_DIR, 0o700); + const stat = statSync(SUPERSET_HOME_DIR); + if ((stat.mode & 0o077) !== 0) chmodSync(SUPERSET_HOME_DIR, 0o700); } catch {} } export function readConfig(): SupersetConfig { - if (!fs.existsSync(CONFIG_PATH)) return {}; + if (!existsSync(SUPERSET_CONFIG_PATH)) return {}; try { - const stat = fs.statSync(CONFIG_PATH); - if ((stat.mode & 0o077) !== 0) fs.chmodSync(CONFIG_PATH, 0o600); + const stat = statSync(SUPERSET_CONFIG_PATH); + if ((stat.mode & 0o077) !== 0) chmodSync(SUPERSET_CONFIG_PATH, 0o600); } catch {} - return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); + return JSON.parse(readFileSync(SUPERSET_CONFIG_PATH, "utf-8")); } -export function writeConfig(config: SupersetConfig): void { - ensureDir(); - const tmpPath = `${CONFIG_PATH}.${process.pid}.${randomUUID()}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { +type ConfigWriterFs = { + chmodSync(path: string, mode: number): void; + mkdirSync(path: string, options: { recursive: true; mode: number }): unknown; + renameSync(oldPath: string, newPath: string): void; + statSync(path: string): { mode: number }; + unlinkSync(path: string): void; + writeFileSync(path: string, data: string, options: { mode: number }): void; +}; + +const defaultConfigWriterFs: ConfigWriterFs = { + chmodSync, + mkdirSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +}; + +export function writeConfigFile( + configPath: string, + config: SupersetConfig, + fs: ConfigWriterFs = defaultConfigWriterFs, +): void { + const configDir = dirname(configPath); + if (!existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + } + try { + const stat = fs.statSync(configDir); + if ((stat.mode & 0o077) !== 0) fs.chmodSync(configDir, 0o700); + } catch {} + + const tempPath = join( + configDir, + `.${randomUUID()}.${process.pid}.config.tmp`, + ); + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), { mode: 0o600, - flag: "wx", }); try { - fs.chmodSync(tmpPath, 0o600); + fs.chmodSync(tempPath, 0o600); + } catch {} + try { + fs.renameSync(tempPath, configPath); + } catch (error) { + try { + fs.unlinkSync(tempPath); + } catch {} + throw error; + } + try { + fs.chmodSync(configPath, 0o600); } catch {} - fs.renameSync(tmpPath, CONFIG_PATH); +} + +export function writeConfig(config: SupersetConfig): void { + ensureDir(); + writeConfigFile(SUPERSET_CONFIG_PATH, config); } export function getApiUrl(): string { diff --git a/packages/cli/src/lib/host/spawn.test.ts b/packages/cli/src/lib/host/spawn.test.ts index 390eb38a467..2628f00f577 100644 --- a/packages/cli/src/lib/host/spawn.test.ts +++ b/packages/cli/src/lib/host/spawn.test.ts @@ -1,137 +1,114 @@ -import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; -import type { SpawnOptions } from "node:child_process"; -import * as fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { ApiClient } from "../api-client"; -const originalEnv = { - SUPERSET_HOME_DIR: process.env.SUPERSET_HOME_DIR, - SUPERSET_HOST_BIN: process.env.SUPERSET_HOST_BIN, - SUPERSET_API_URL: process.env.SUPERSET_API_URL, - RELAY_URL: process.env.RELAY_URL, -}; -const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "superset-cli-spawn-")); -const hostBin = path.join(tempHome, "superset-host"); -fs.writeFileSync(hostBin, ""); +const originalFetch = globalThis.fetch; +const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; +const originalHostBin = process.env.SUPERSET_HOST_BIN; +const originalSupersetRefreshToken = process.env.SUPERSET_REFRESH_TOKEN; +const originalOAuthRefreshToken = process.env.OAUTH_REFRESH_TOKEN; +const tempHome = mkdtempSync(join(tmpdir(), "superset-cli-spawn-")); +const hostBin = join(tempHome, "superset-host"); process.env.SUPERSET_HOME_DIR = tempHome; process.env.SUPERSET_HOST_BIN = hostBin; -process.env.SUPERSET_API_URL = "https://api.example.com"; -process.env.RELAY_URL = "https://relay.example.com"; +writeFileSync(hostBin, ""); -const childProcess = { - pid: 24_680, - kill: mock(() => true), - unref: mock(() => {}), +type SpawnOptions = { + env?: NodeJS.ProcessEnv; + detached?: boolean; + stdio?: unknown; }; + +const spawnCalls: Array<{ + command: string; + args: string[]; + options: SpawnOptions; +}> = []; + const spawnMock = mock( - (_command: string, _args: string[], _options: SpawnOptions) => childProcess, + (command: string, args: string[], options: SpawnOptions) => { + spawnCalls.push({ command, args, options }); + return { + pid: 12345, + kill: mock(() => undefined), + unref: mock(() => undefined), + }; + }, ); + mock.module("node:child_process", () => ({ spawn: spawnMock, })); -mock.module("./relay-url", () => ({ - getRelayUrl: mock(async () => "https://relay.example.com"), -})); - -const originalFetch = globalThis.fetch; -const fetchMock = mock(async () => new Response(null, { status: 200 })); -globalThis.fetch = fetchMock as unknown as typeof fetch; +const { SUPERSET_CONFIG_PATH } = await import("../config"); const { spawnHostService } = await import("./spawn"); -const { SUPERSET_HOME_DIR, writeConfig } = await import("../config"); -function createApiClient(): ApiClient { +function createApi(): ApiClient { return { analytics: { featureFlagPayload: { - query: mock(async () => ({ url: "https://relay.example.com" })), + query: async () => null, }, }, } as unknown as ApiClient; } -function restoreEnvValue(key: keyof typeof originalEnv): void { - const value = originalEnv[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } -} - -function lastSpawnEnv(): NodeJS.ProcessEnv { - const call = spawnMock.mock.calls.at(-1); - if (!call) throw new Error("expected host service to be spawned"); - const options = call[2]; - if (!options?.env) throw new Error("expected spawn env to be present"); - return options.env; -} - -function activeConfigPath(): string { - return path.join(SUPERSET_HOME_DIR, "config.json"); -} - -async function spawnWithToken(sessionToken: string) { - return spawnHostService({ - organizationId: "org_test", - sessionToken, - api: createApiClient(), - port: 49_321, - daemon: false, - }); -} - afterEach(() => { + spawnCalls.length = 0; spawnMock.mockClear(); - childProcess.kill.mockClear(); - childProcess.unref.mockClear(); - fetchMock.mockClear(); - fs.rmSync(path.join(tempHome, "host"), { recursive: true, force: true }); - fs.rmSync(activeConfigPath(), { force: true }); - fs.rmSync(`${activeConfigPath()}.tmp`, { force: true }); + globalThis.fetch = originalFetch; + if (originalSupersetRefreshToken === undefined) { + delete process.env.SUPERSET_REFRESH_TOKEN; + } else { + process.env.SUPERSET_REFRESH_TOKEN = originalSupersetRefreshToken; + } + if (originalOAuthRefreshToken === undefined) { + delete process.env.OAUTH_REFRESH_TOKEN; + } else { + process.env.OAUTH_REFRESH_TOKEN = originalOAuthRefreshToken; + } }); afterAll(() => { - globalThis.fetch = originalFetch; - restoreEnvValue("SUPERSET_HOME_DIR"); - restoreEnvValue("SUPERSET_HOST_BIN"); - restoreEnvValue("SUPERSET_API_URL"); - restoreEnvValue("RELAY_URL"); - fs.rmSync(tempHome, { recursive: true, force: true }); + rmSync(tempHome, { recursive: true, force: true }); + if (originalSupersetHomeDir === undefined) { + delete process.env.SUPERSET_HOME_DIR; + } else { + process.env.SUPERSET_HOME_DIR = originalSupersetHomeDir; + } + if (originalHostBin === undefined) { + delete process.env.SUPERSET_HOST_BIN; + } else { + process.env.SUPERSET_HOST_BIN = originalHostBin; + } }); describe("spawnHostService", () => { - it("passes the current auth config path and access token to the host child", async () => { - await spawnWithToken("access-token-for-bootstrap"); - - const env = lastSpawnEnv(); - expect(env.AUTH_TOKEN).toBe("access-token-for-bootstrap"); - expect(env.SUPERSET_AUTH_CONFIG_PATH).toBe(activeConfigPath()); - }); - - it("does not pass the stored refresh token through the host child env", async () => { - const refreshToken = - "refresh-token-value-that-should-not-leak-HOST-AUTH-001"; - writeConfig({ - auth: { - accessToken: "access-token", - refreshToken, - expiresAt: Date.now() + 60_000, - }, + test("passes SUPERSET_AUTH_CONFIG_PATH when provided", async () => { + process.env.SUPERSET_REFRESH_TOKEN = "superset-refresh-secret"; + process.env.OAUTH_REFRESH_TOKEN = "oauth-refresh-secret"; + globalThis.fetch = mock( + async () => new Response("ok", { status: 200 }), + ) as unknown as typeof fetch; + + await spawnHostService({ + organizationId: "00000000-0000-0000-0000-000000000001", + sessionToken: "session-token", + authConfigPath: SUPERSET_CONFIG_PATH, + api: createApi(), + port: 54879, + daemon: true, }); - await spawnWithToken("access-token"); - - const env = lastSpawnEnv(); - const leakedEntries = Object.entries(env).filter( - ([, value]) => value === refreshToken, + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnCalls[0]?.options.env?.SUPERSET_AUTH_CONFIG_PATH).toBe( + SUPERSET_CONFIG_PATH, ); - - expect(leakedEntries).toEqual([]); - expect(env.SUPERSET_AUTH_REFRESH_TOKEN).toBeUndefined(); - expect(env.REFRESH_TOKEN).toBeUndefined(); - expect(env.OAUTH_REFRESH).toBeUndefined(); + expect(spawnCalls[0]?.options.env?.AUTH_TOKEN).toBe("session-token"); + expect(spawnCalls[0]?.options.env?.SUPERSET_REFRESH_TOKEN).toBeUndefined(); + expect(spawnCalls[0]?.options.env?.OAUTH_REFRESH_TOKEN).toBeUndefined(); }); }); diff --git a/packages/cli/src/lib/host/spawn.ts b/packages/cli/src/lib/host/spawn.ts index 0cf55631aa8..94163bd8589 100644 --- a/packages/cli/src/lib/host/spawn.ts +++ b/packages/cli/src/lib/host/spawn.ts @@ -4,7 +4,6 @@ import { existsSync } from "node:fs"; import { createServer } from "node:net"; import { dirname, join } from "node:path"; import type { ApiClient } from "../api-client"; -import { SUPERSET_HOME_DIR } from "../config"; import { env } from "../env"; import { type HostServiceManifest, @@ -19,6 +18,7 @@ const HEALTH_POLL_TIMEOUT_MS = 10_000; export interface SpawnHostOptions { organizationId: string; sessionToken: string; + authConfigPath?: string; api: ApiClient; port?: number; daemon: boolean; @@ -89,6 +89,37 @@ function resolveMigrationsFolder(): string { return join(bundleRoot, "share", "migrations"); } +function createHostServiceEnv( + options: SpawnHostOptions, + port: number, + secret: string, + relayUrl: string, + migrationsFolder: string, +): NodeJS.ProcessEnv { + const childEnv: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(childEnv)) { + if (key.toUpperCase().includes("REFRESH_TOKEN")) { + delete childEnv[key]; + } + } + + return { + ...childEnv, + ORGANIZATION_ID: options.organizationId, + AUTH_TOKEN: options.sessionToken, + ...(options.authConfigPath + ? { SUPERSET_AUTH_CONFIG_PATH: options.authConfigPath } + : {}), + SUPERSET_API_URL: env.SUPERSET_API_URL, + RELAY_URL: relayUrl, + PORT: String(port), + HOST_SERVICE_PORT: String(port), + HOST_SERVICE_SECRET: secret, + HOST_DB_PATH: hostDbPath(options.organizationId), + HOST_MIGRATIONS_FOLDER: migrationsFolder, + }; +} + export async function spawnHostService( options: SpawnHostOptions, ): Promise { @@ -107,19 +138,13 @@ export async function spawnHostService( const child = spawn(hostBin, [], { stdio: options.daemon ? "ignore" : "inherit", detached: options.daemon, - env: { - ...process.env, - ORGANIZATION_ID: options.organizationId, - AUTH_TOKEN: options.sessionToken, - SUPERSET_AUTH_CONFIG_PATH: join(SUPERSET_HOME_DIR, "config.json"), - SUPERSET_API_URL: env.SUPERSET_API_URL, - RELAY_URL: relayUrl, - PORT: String(port), - HOST_SERVICE_PORT: String(port), - HOST_SERVICE_SECRET: secret, - HOST_DB_PATH: hostDbPath(options.organizationId), - HOST_MIGRATIONS_FOLDER: migrationsFolder, - }, + env: createHostServiceEnv( + options, + port, + secret, + relayUrl, + migrationsFolder, + ), }); if (!child.pid) { diff --git a/packages/cli/src/lib/resolve-auth.test.ts b/packages/cli/src/lib/resolve-auth.test.ts index fa0b4b254ec..8825d60d555 100644 --- a/packages/cli/src/lib/resolve-auth.test.ts +++ b/packages/cli/src/lib/resolve-auth.test.ts @@ -1,308 +1,90 @@ -import { afterAll, describe, expect, it } from "bun:test"; -import { spawnSync } from "node:child_process"; +import { afterAll, afterEach, describe, expect, it } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -const tempHomes: string[] = []; +const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; +const tempHome = fs.mkdtempSync( + path.join(os.tmpdir(), "superset-cli-resolve-auth-"), +); +process.env.SUPERSET_HOME_DIR = tempHome; -type ScenarioResult = Record; +const { resolveAuth } = await import("./resolve-auth"); +const { writeConfig } = await import("./config"); -function runScenario(source: string): ScenarioResult { - const tempHome = fs.mkdtempSync( - path.join(os.tmpdir(), "superset-cli-resolve-auth-"), - ); - tempHomes.push(tempHome); - - const result = spawnSync(process.execPath, ["--eval", source], { - cwd: process.cwd(), - env: { - ...process.env, - SUPERSET_HOME_DIR: tempHome, - SUPERSET_API_URL: "https://api.example.com", - }, - encoding: "utf-8", - maxBuffer: 1024 * 1024, - }); - - if (result.status !== 0) { - throw new Error( - [ - `scenario failed with exit ${result.status}`, - "--- stdout ---", - result.stdout, - "--- stderr ---", - result.stderr, - ].join("\n"), - ); - } - - const output = result.stdout.trim().split("\n").at(-1); - if (!output) { - throw new Error("scenario produced no JSON output"); - } - return JSON.parse(output) as ScenarioResult; +function clearConfig(): void { + writeConfig({}); } -function scenario(body: string): ScenarioResult { - return runScenario(` - const { resolveAuth } = await import("./src/lib/resolve-auth.ts"); - const { readConfig, writeConfig } = await import("./src/lib/config.ts"); - const { writeManifest } = await import("./src/lib/host/manifest.ts"); - - ${body} - `); -} +afterEach(() => { + clearConfig(); +}); afterAll(() => { - for (const tempHome of tempHomes) { - fs.rmSync(tempHome, { recursive: true, force: true }); + fs.rmSync(tempHome, { recursive: true, force: true }); + if (originalSupersetHomeDir === undefined) { + delete process.env.SUPERSET_HOME_DIR; + } else { + process.env.SUPERSET_HOME_DIR = originalSupersetHomeDir; } }); describe("resolveAuth", () => { - it("throws when no override and no stored credentials", () => { - const result = scenario(` - try { - await resolveAuth(undefined); - console.log(JSON.stringify({ ok: true })); - } catch (error) { - console.log(JSON.stringify({ - ok: false, - message: error instanceof Error ? error.message : String(error), - })); - } - `); - - expect(result.ok).toBe(false); - expect(result.message).toMatch(/Not logged in/); + it("throws when no override and no stored credentials", async () => { + await expect(resolveAuth(undefined)).rejects.toThrow(/Not logged in/); }); - it("uses an override api key with 'override' source", () => { - const result = scenario(` - const resolved = await resolveAuth("sk_live_override"); - console.log(JSON.stringify({ - bearer: resolved.bearer, - authSource: resolved.authSource, - })); - `); - + it("uses an override api key with 'override' source", async () => { + const result = await resolveAuth("sk_live_override"); expect(result.bearer).toBe("sk_live_override"); expect(result.authSource).toBe("override"); }); - it("uses a stored apiKey from config with 'config' source", () => { - const result = scenario(` - writeConfig({ apiKey: "sk_live_stored", organizationId: "org_1" }); - const resolved = await resolveAuth(undefined); - console.log(JSON.stringify({ - bearer: resolved.bearer, - authSource: resolved.authSource, - organizationId: resolved.config.organizationId, - })); - `); - + it("uses a stored apiKey from config with 'config' source", async () => { + writeConfig({ apiKey: "sk_live_stored", organizationId: "org_1" }); + const result = await resolveAuth(undefined); expect(result.bearer).toBe("sk_live_stored"); expect(result.authSource).toBe("config"); - expect(result.organizationId).toBe("org_1"); + expect(result.config.organizationId).toBe("org_1"); }); - it("uses a stored OAuth session when present and unexpired", () => { - const result = scenario(` - writeConfig({ - auth: { - accessToken: "oauth-token", - refreshToken: "oauth-refresh", - expiresAt: Date.now() + 60 * 60 * 1000, - }, - }); - const resolved = await resolveAuth(undefined); - console.log(JSON.stringify({ - bearer: resolved.bearer, - authSource: resolved.authSource, - })); - `); - - expect(result.bearer).toBe("oauth-token"); - expect(result.authSource).toBe("oauth"); - }); - - it("defers OAuth refresh to the host while the host process is alive", () => { - const result = scenario(` - let refreshCalls = 0; - globalThis.fetch = async () => { - refreshCalls += 1; - throw new Error("refresh should be deferred to the host"); - }; - writeConfig({ - organizationId: "org_1", - auth: { - accessToken: "near-expiry-access-token", - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - writeManifest({ - pid: process.pid, - endpoint: "http://127.0.0.1:4879", - authToken: "host-secret", - startedAt: Date.now(), - organizationId: "org_1", - }); - const resolved = await resolveAuth(undefined); - console.log(JSON.stringify({ - bearer: resolved.bearer, - authSource: resolved.authSource, - refreshCalls, - config: readConfig(), - })); - `); - - expect(result.bearer).toBe("near-expiry-access-token"); - expect(result.authSource).toBe("oauth"); - expect(result.refreshCalls).toBe(0); - expect(result.config).toMatchObject({ - auth: { accessToken: "near-expiry-access-token" }, - }); - }); - - it("refreshes OAuth credentials when the host manifest process is not alive", () => { - const result = scenario(` - let refreshCalls = 0; - globalThis.fetch = async () => { - refreshCalls += 1; - return new Response(JSON.stringify({ - access_token: "refreshed-access-token", - token_type: "Bearer", - expires_in: 3600, - refresh_token: "rotated-refresh-token", - }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }; - writeConfig({ - organizationId: "org_1", - auth: { - accessToken: "near-expiry-access-token", - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - writeManifest({ - pid: 99999999, - endpoint: "http://127.0.0.1:4879", - authToken: "host-secret", - startedAt: Date.now(), - organizationId: "org_1", - }); - const resolved = await resolveAuth(undefined); - console.log(JSON.stringify({ - bearer: resolved.bearer, - authSource: resolved.authSource, - refreshCalls, - config: readConfig(), - })); - `); - - expect(result.bearer).toBe("refreshed-access-token"); - expect(result.authSource).toBe("oauth"); - expect(result.refreshCalls).toBe(1); - expect(result.config).toMatchObject({ + it("uses a stored OAuth session when present and unexpired", async () => { + const future = Date.now() + 60 * 60 * 1000; + writeConfig({ auth: { - accessToken: "refreshed-access-token", - refreshToken: "rotated-refresh-token", + accessToken: "oauth-token", + refreshToken: "oauth-refresh", + expiresAt: future, }, }); - }); - - it("refreshes OAuth credentials when no host manifest exists", () => { - const result = scenario(` - let refreshCalls = 0; - globalThis.fetch = async () => { - refreshCalls += 1; - return new Response(JSON.stringify({ - access_token: "refreshed-access-token", - token_type: "Bearer", - expires_in: 3600, - refresh_token: "rotated-refresh-token", - }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }; - writeConfig({ - organizationId: "org_1", - auth: { - accessToken: "near-expiry-access-token", - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const resolved = await resolveAuth(undefined); - console.log(JSON.stringify({ - bearer: resolved.bearer, - authSource: resolved.authSource, - refreshCalls, - config: readConfig(), - })); - `); - - expect(result.bearer).toBe("refreshed-access-token"); + const result = await resolveAuth(undefined); + expect(result.bearer).toBe("oauth-token"); expect(result.authSource).toBe("oauth"); - expect(result.refreshCalls).toBe(1); }); - it("throws when OAuth session is expired and there is no refresh token", () => { - const result = scenario(` - writeConfig({ - auth: { accessToken: "stale", expiresAt: Date.now() - 1000 }, - }); - try { - await resolveAuth(undefined); - console.log(JSON.stringify({ ok: true })); - } catch (error) { - console.log(JSON.stringify({ - ok: false, - message: error instanceof Error ? error.message : String(error), - })); - } - `); - - expect(result.ok).toBe(false); - expect(result.message).toMatch(/Session expired/); + it("throws when OAuth session is expired and there is no refresh token", async () => { + writeConfig({ + auth: { accessToken: "stale", expiresAt: Date.now() - 1000 }, + }); + await expect(resolveAuth(undefined)).rejects.toThrow(/Session expired/); }); - it("prefers an override over a stored apiKey", () => { - const result = scenario(` - writeConfig({ apiKey: "sk_live_stored" }); - const resolved = await resolveAuth("sk_live_override"); - console.log(JSON.stringify({ - bearer: resolved.bearer, - authSource: resolved.authSource, - })); - `); - + it("prefers an override over a stored apiKey", async () => { + writeConfig({ apiKey: "sk_live_stored" }); + const result = await resolveAuth("sk_live_override"); expect(result.bearer).toBe("sk_live_override"); expect(result.authSource).toBe("override"); }); - it("prefers a stored apiKey over a stored OAuth session", () => { - const result = scenario(` - writeConfig({ - apiKey: "sk_live_stored", - auth: { - accessToken: "oauth-token", - expiresAt: Date.now() + 60 * 60 * 1000, - }, - }); - const resolved = await resolveAuth(undefined); - console.log(JSON.stringify({ - bearer: resolved.bearer, - authSource: resolved.authSource, - })); - `); - + it("prefers a stored apiKey over a stored OAuth session", async () => { + writeConfig({ + apiKey: "sk_live_stored", + auth: { + accessToken: "oauth-token", + expiresAt: Date.now() + 60 * 60 * 1000, + }, + }); + const result = await resolveAuth(undefined); expect(result.bearer).toBe("sk_live_stored"); expect(result.authSource).toBe("config"); }); diff --git a/packages/cli/src/lib/resolve-auth.ts b/packages/cli/src/lib/resolve-auth.ts index 034728154bd..d979b888bc1 100644 --- a/packages/cli/src/lib/resolve-auth.ts +++ b/packages/cli/src/lib/resolve-auth.ts @@ -2,7 +2,6 @@ import { CLIError } from "@superset/cli-framework"; import { type ApiClient, createApiClient } from "./api-client"; import { refreshAccessToken } from "./auth"; import { readConfig, type SupersetConfig, writeConfig } from "./config"; -import { isProcessAlive, readManifest } from "./host/manifest"; export type AuthSource = "override" | "config" | "oauth"; @@ -15,12 +14,6 @@ export type ResolvedAuth = { const REFRESH_LEEWAY_MS = 5 * 60 * 1000; -function isHostAlive(organizationId: string | undefined): boolean { - if (!organizationId) return false; - const manifest = readManifest(organizationId); - return manifest ? isProcessAlive(manifest.pid) : false; -} - export async function resolveAuth( apiKeyOption: string | undefined, ): Promise { @@ -38,9 +31,7 @@ export async function resolveAuth( authSource = "config"; } else if (config.auth) { const auth = config.auth; - if (isHostAlive(config.organizationId)) { - bearer = auth.accessToken; - } else if (auth.expiresAt - REFRESH_LEEWAY_MS < Date.now()) { + if (auth.expiresAt - REFRESH_LEEWAY_MS < Date.now()) { if (!auth.refreshToken) { throw new CLIError("Session expired", "Run: superset auth login"); } diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 0c91932c4f8..41e8d331f56 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -135,7 +135,6 @@ export function createApp(options: CreateAppOptions): CreateAppResult { const eventBus = new EventBus({ db, filesystem, gitWatcher }); eventBus.start(); - providers.auth.setEventBus?.(eventBus); // Backfill `kind='main'` v2 workspaces for projects already set up before // this column shipped. Idempotent; runs in the background so it doesn't @@ -193,7 +192,6 @@ export function createApp(options: CreateAppOptions): CreateAppResult { db, runtime, eventBus, - authProvider: providers.auth, organizationId: config.organizationId, isAuthenticated, } as Record; diff --git a/packages/host-service/src/env.test.ts b/packages/host-service/src/env.test.ts index 9adc3592571..7bc717d1373 100644 --- a/packages/host-service/src/env.test.ts +++ b/packages/host-service/src/env.test.ts @@ -1,63 +1,40 @@ -import { afterAll, describe, expect, it } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; + +const originalEnv = { + AUTH_TOKEN: process.env.AUTH_TOKEN, + HOST_DB_PATH: process.env.HOST_DB_PATH, + HOST_MIGRATIONS_FOLDER: process.env.HOST_MIGRATIONS_FOLDER, + HOST_SERVICE_SECRET: process.env.HOST_SERVICE_SECRET, + ORGANIZATION_ID: process.env.ORGANIZATION_ID, + PORT: process.env.PORT, + SUPERSET_API_URL: process.env.SUPERSET_API_URL, + SUPERSET_AUTH_CONFIG_PATH: process.env.SUPERSET_AUTH_CONFIG_PATH, +}; + +process.env.AUTH_TOKEN = "access-token"; +process.env.HOST_DB_PATH = "/tmp/superset-host.db"; +process.env.HOST_MIGRATIONS_FOLDER = "/tmp/superset-migrations"; +process.env.HOST_SERVICE_SECRET = "host-secret"; +process.env.ORGANIZATION_ID = "00000000-0000-4000-8000-000000000001"; +process.env.PORT = "4879"; +process.env.SUPERSET_API_URL = "https://api.superset.test"; +delete process.env.SUPERSET_AUTH_CONFIG_PATH; + +const { env } = await import("./env"); -const originalEnv = { ...process.env }; - -const requiredEnv = { - ORGANIZATION_ID: "00000000-0000-4000-8000-000000000000", - HOST_DB_PATH: "/tmp/superset-host-test.db", - HOST_MIGRATIONS_FOLDER: "/tmp/superset-host-migrations", - AUTH_TOKEN: "access-token", - SUPERSET_API_URL: "https://api.example.com", -} satisfies Record; - -function restoreOriginalEnv(): void { - for (const key of Object.keys(process.env)) { - delete process.env[key]; - } - Object.assign(process.env, originalEnv); -} - -function setEnv(overrides: Record): void { - restoreOriginalEnv(); - Object.assign(process.env, requiredEnv); - for (const [key, value] of Object.entries(overrides)) { +afterAll(() => { + for (const [key, value] of Object.entries(originalEnv)) { if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } -} - -async function loadEnv(suffix: string): Promise { - const module = (await import(`./env.ts?${suffix}`)) as typeof import("./env"); - return module.env; -} - -afterAll(() => { - restoreOriginalEnv(); }); -describe("env", () => { - it("parses SUPERSET_AUTH_CONFIG_PATH while keeping AUTH_TOKEN required", async () => { - const configPath = "/tmp/superset/config.json"; - setEnv({ - AUTH_TOKEN: "bootstrap-access-token", - SUPERSET_AUTH_CONFIG_PATH: configPath, - }); - - const env = await loadEnv("with-auth-config-path"); - - expect(env.AUTH_TOKEN).toBe("bootstrap-access-token"); - expect(env.SUPERSET_AUTH_CONFIG_PATH).toBe(configPath); - }); - - it("does not require SUPERSET_AUTH_CONFIG_PATH", async () => { - setEnv({ SUPERSET_AUTH_CONFIG_PATH: undefined }); - - const env = await loadEnv("without-auth-config-path"); - - expect(env.AUTH_TOKEN).toBe("access-token"); +describe("host-service env", () => { + test("SUPERSET_AUTH_CONFIG_PATH is optional", () => { expect(env.SUPERSET_AUTH_CONFIG_PATH).toBeUndefined(); + expect(env.AUTH_TOKEN).toBe("access-token"); }); }); diff --git a/packages/host-service/src/errors.ts b/packages/host-service/src/errors.ts deleted file mode 100644 index 6b2b9aa83db..00000000000 --- a/packages/host-service/src/errors.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { SESSION_EXPIRED_HINT } from "./providers/auth/hint"; - -export { SESSION_EXPIRED_HINT }; - -export const AUTH_REFRESH_FAILED_MESSAGE = SESSION_EXPIRED_HINT; - -export type AuthRefreshFailureReason = - | "invalid_grant" - | "network_error" - | "http_error"; - -export interface AuthRefreshFailedErrorOptions { - reason: AuthRefreshFailureReason; - statusCode?: number; -} - -export class AuthRefreshFailedError extends Error { - readonly reason: AuthRefreshFailureReason; - readonly statusCode?: number; - - constructor(options: AuthRefreshFailedErrorOptions) { - super(AUTH_REFRESH_FAILED_MESSAGE); - this.name = "AuthRefreshFailedError"; - this.reason = options.reason; - if (options.statusCode !== undefined) { - this.statusCode = options.statusCode; - } - } -} diff --git a/packages/host-service/src/events/event-bus.ts b/packages/host-service/src/events/event-bus.ts index 3605e4a587b..a010a88f04e 100644 --- a/packages/host-service/src/events/event-bus.ts +++ b/packages/host-service/src/events/event-bus.ts @@ -182,24 +182,6 @@ export class EventBus { this.broadcast({ type: "terminal:lifecycle", ...message }); } - broadcastAuthSessionExpired( - message: Omit< - Extract, - "type" - >, - ): void { - this.broadcast({ type: "auth:session_expired", ...message }); - } - - broadcastAuthSessionRestored( - message: Omit< - Extract, - "type" - >, - ): void { - this.broadcast({ type: "auth:session_restored", ...message }); - } - /** * Fan out port add/remove events discovered by the host-service scanner. * Renderer clients use this to patch their host snapshot immediately while diff --git a/packages/host-service/src/events/index.ts b/packages/host-service/src/events/index.ts index 6f44489b483..585bde04ada 100644 --- a/packages/host-service/src/events/index.ts +++ b/packages/host-service/src/events/index.ts @@ -6,9 +6,6 @@ export { } from "./map-event-type.ts"; export type { AgentLifecycleMessage, - AuthSessionExpiredMessage, - AuthSessionExpiredReason, - AuthSessionRestoredMessage, ClientMessage, EventBusErrorMessage, FsEventsMessage, diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index 5dff3c272e5..a26139820d2 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -53,23 +53,6 @@ export interface PortChangedMessage { occurredAt: number; } -export type AuthSessionExpiredReason = - | "invalid_grant" - | "network_error" - | "http_error"; - -export interface AuthSessionExpiredMessage { - type: "auth:session_expired"; - reason: AuthSessionExpiredReason; - hint: string; - occurredAt: number; -} - -export interface AuthSessionRestoredMessage { - type: "auth:session_restored"; - occurredAt: number; -} - export interface EventBusErrorMessage { type: "error"; message: string; @@ -81,8 +64,6 @@ export type ServerMessage = | AgentLifecycleMessage | TerminalLifecycleMessage | PortChangedMessage - | AuthSessionExpiredMessage - | AuthSessionRestoredMessage | EventBusErrorMessage; // ── Client → Server ──────────────────────────────────────────────── diff --git a/packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts b/packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts new file mode 100644 index 00000000000..a7b04cf91fc --- /dev/null +++ b/packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts @@ -0,0 +1,186 @@ +import { randomUUID } from "node:crypto"; +import { + chmod, + mkdir, + readFile, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; + +const CLIENT_ID = "superset-cli"; +const LOGIN_AGAIN_MESSAGE = "Session expired. Run: superset auth login"; + +type SupersetAuthConfig = { + auth?: { + accessToken: string; + refreshToken?: string; + expiresAt: number; + }; + apiKey?: string; + organizationId?: string; +}; + +type OAuthRefreshResponse = { + access_token: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; +}; + +export type ConfigFileSessionTokenSourceOptions = { + configPath: string; + apiUrl: string; +}; + +async function readConfig(configPath: string): Promise { + try { + const fileStat = await stat(configPath); + if ((fileStat.mode & 0o077) !== 0) { + await chmod(configPath, 0o600).catch(() => undefined); + } + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return {}; + } + throw error; + } + + const raw = await readFile(configPath, "utf-8"); + return JSON.parse(raw) as SupersetAuthConfig; +} + +async function writeConfig( + configPath: string, + config: SupersetAuthConfig, +): Promise { + const configDir = dirname(configPath); + await mkdir(configDir, { recursive: true, mode: 0o700 }); + + const tempPath = join( + configDir, + `.${randomUUID()}.${process.pid}.config.tmp`, + ); + await writeFile(tempPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + await chmod(tempPath, 0o600).catch(() => undefined); + try { + await rename(tempPath, configPath); + } catch (error) { + await unlink(tempPath).catch(() => undefined); + throw error; + } + await chmod(configPath, 0o600).catch(() => undefined); +} + +function loginAgainError(): Error { + return new Error(LOGIN_AGAIN_MESSAGE); +} + +function authMatches( + left: NonNullable, + right: NonNullable, +): boolean { + return ( + left.accessToken === right.accessToken && + left.refreshToken === right.refreshToken && + left.expiresAt === right.expiresAt + ); +} + +export class ConfigFileSessionTokenSource { + private readonly configPath: string; + private readonly apiUrl: string; + private refreshPromise: Promise | null = null; + private refreshNeeded = false; + + constructor(options: ConfigFileSessionTokenSourceOptions) { + this.configPath = options.configPath; + this.apiUrl = options.apiUrl; + } + + invalidateCache(): void { + this.refreshNeeded = true; + } + + async getSessionToken(): Promise { + const config = await readConfig(this.configPath); + + const apiKey = config.apiKey?.trim(); + if (apiKey) return apiKey; + + const auth = config.auth; + if (!auth) throw loginAgainError(); + if (!this.refreshNeeded) return auth.accessToken; + + if (this.refreshPromise) return this.refreshPromise; + + if (!auth.refreshToken) throw loginAgainError(); + this.refreshPromise = this.refreshAccessToken(auth).finally(() => { + this.refreshPromise = null; + }); + + return this.refreshPromise; + } + + private async refreshAccessToken( + auth: NonNullable, + ): Promise { + if (!auth.refreshToken) throw loginAgainError(); + + let response: Response; + try { + response = await fetch(`${this.apiUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: auth.refreshToken, + client_id: CLIENT_ID, + resource: this.apiUrl, + }), + }); + } catch { + throw loginAgainError(); + } + + if (!response.ok) throw loginAgainError(); + + let data: OAuthRefreshResponse; + try { + data = (await response.json()) as OAuthRefreshResponse; + } catch { + throw loginAgainError(); + } + + if (!data.access_token) throw loginAgainError(); + + const nextAuth = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? auth.refreshToken, + expiresAt: Date.now() + (data.expires_in ?? 60 * 60) * 1000, + }; + + const latestConfig = await readConfig(this.configPath); + const latestApiKey = latestConfig.apiKey?.trim(); + if (latestApiKey) { + this.refreshNeeded = false; + return latestApiKey; + } + if (!latestConfig.auth) throw loginAgainError(); + + if (!authMatches(latestConfig.auth, auth)) { + this.refreshNeeded = false; + return latestConfig.auth.accessToken; + } + + await writeConfig(this.configPath, { + ...latestConfig, + auth: nextAuth, + }); + + this.refreshNeeded = false; + return nextAuth.accessToken; + } +} diff --git a/packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/index.ts b/packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/index.ts new file mode 100644 index 00000000000..46d2daf43ab --- /dev/null +++ b/packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/index.ts @@ -0,0 +1 @@ +export { ConfigFileSessionTokenSource } from "./ConfigFileSessionTokenSource"; diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts index 008f3e262e2..ca8cbb7fb0e 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts @@ -1,709 +1,378 @@ -import { - afterAll, - afterEach, - describe, - expect, - it, - mock, - spyOn, -} from "bun:test"; -import type { PathLike } from "node:fs"; -import * as fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { - AuthSessionExpiredMessage, - AuthSessionRestoredMessage, -} from "../../../events"; -import type { AuthSessionEventPublisher } from "../types"; - -type LoginResult = { - accessToken: string; - refreshToken?: string; - expiresAt: number; -}; - -let refreshAccessTokenImpl = async ( - refreshToken: string, -): Promise => ({ - accessToken: jwtWithExp(Date.now() + 60 * 60 * 1000), - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, -}); -const refreshAccessTokenMock = mock((refreshToken: string) => - refreshAccessTokenImpl(refreshToken), -); - -const { JwtApiAuthProvider } = await import("./JwtAuthProvider"); -const { - AUTH_REFRESH_FAILED_MESSAGE, - AuthRefreshFailedError, - SESSION_EXPIRED_HINT, -} = await import("../../../errors"); - -const tempRoot = fs.mkdtempSync( - path.join(os.tmpdir(), "superset-host-jwt-api-auth-"), -); - -function jwtWithExp(expiresAtMs: number): string { - const header = Buffer.from(JSON.stringify({ alg: "none" })).toString( - "base64url", - ); - const payload = Buffer.from( - JSON.stringify({ exp: Math.floor(expiresAtMs / 1000) }), - ).toString("base64url"); - return `${header}.${payload}.signature`; -} - -function createConfigPath(): string { - const dir = fs.mkdtempSync(path.join(tempRoot, "case-")); - return path.join(dir, "config.json"); -} - -function writeConfig( - configPath: string, - config: { - auth: { - accessToken: string; - refreshToken?: string; - expiresAt: number; - }; - organizationId?: string; - apiKey?: string; - }, -): void { - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { - mode: 0o600, - }); -} - -function readConfig(configPath: string): { +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ORGANIZATION_HEADER } from "@superset/shared/constants"; +import { initTRPC, TRPCError } from "@trpc/server"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import SuperJSON from "superjson"; +import { createApiClient } from "../../../api"; +import { ConfigFileSessionTokenSource } from "../ConfigFileSessionTokenSource"; +import { JwtApiAuthProvider } from "./JwtAuthProvider"; + +const originalFetch = globalThis.fetch; +const API_URL = "https://api.superset.test"; +const ORGANIZATION_ID = "00000000-0000-0000-0000-000000000001"; + +type SupersetTestConfig = { auth?: { accessToken: string; refreshToken?: string; expiresAt: number; }; - organizationId?: string; apiKey?: string; -} { - return JSON.parse(fs.readFileSync(configPath, "utf-8")) as { - auth?: { - accessToken: string; - refreshToken?: string; - expiresAt: number; - }; - organizationId?: string; - apiKey?: string; - }; + organizationId?: string; +}; + +type TestConfigFile = { + dir: string; + configPath: string; +}; + +function createConfigFile(config: SupersetTestConfig): TestConfigFile { + const dir = mkdtempSync(join(tmpdir(), "host-auth-config-")); + const configPath = join(dir, "config.json"); + writeFileSync(configPath, JSON.stringify(config, null, 2)); + return { dir, configPath }; } -interface RecordedAuthEvents { - eventBus: AuthSessionEventPublisher; - expired: Array>; - restored: Array>; +function readConfig(configPath: string): SupersetTestConfig { + return JSON.parse(readFileSync(configPath, "utf-8")) as SupersetTestConfig; } -function createAuthEvents(): RecordedAuthEvents { - const expired: Array> = []; - const restored: Array> = []; - return { - expired, - restored, - eventBus: { - broadcastAuthSessionExpired: (message) => expired.push(message), - broadcastAuthSessionRestored: (message) => restored.push(message), - }, - }; +function writeConfig(configPath: string, config: SupersetTestConfig): void { + writeFileSync(configPath, JSON.stringify(config, null, 2)); } -function createProvider( - configPath: string, - eventBus?: AuthSessionEventPublisher, -): InstanceType { +function createConfigBackedProvider(configPath: string): JwtApiAuthProvider { + const tokenSource = new ConfigFileSessionTokenSource({ + configPath, + apiUrl: API_URL, + }); return new JwtApiAuthProvider({ - getSessionToken: async () => "bootstrap-access-token", - apiUrl: "https://api.example.com", - authConfigPath: configPath, - eventBus, - refreshAccessToken: refreshAccessTokenMock, + getSessionToken: () => tokenSource.getSessionToken(), + onInvalidateCache: () => tokenSource.invalidateCache(), + apiUrl: API_URL, }); } -function mockNow(initialNow: number): { - advance: (ms: number) => void; - restore: () => void; -} { - let now = initialNow; - const nowSpy = spyOn(Date, "now").mockImplementation(() => now); - return { - advance: (ms: number) => { - now += ms; - }, - restore: () => nowSpy.mockRestore(), - }; -} - -async function captureProcessErrors( - run: () => Promise, -): Promise { - const errors: unknown[] = []; - const onUnhandledRejection = (reason: unknown) => { - errors.push(reason); - }; - const onUncaughtException = (error: Error) => { - errors.push(error); - }; - - process.on("unhandledRejection", onUnhandledRejection); - process.on("uncaughtException", onUncaughtException); - try { - await run(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - process.off("uncaughtException", onUncaughtException); - } - return errors; +function mockFetch( + impl: ( + input: Parameters[0], + init?: Parameters[1], + ) => Promise, +) { + const fetchMock = mock(impl); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; } afterEach(() => { - refreshAccessTokenMock.mockClear(); - refreshAccessTokenImpl = async (refreshToken: string) => ({ - accessToken: jwtWithExp(Date.now() + 60 * 60 * 1000), - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, - }); -}); - -afterAll(() => { - fs.rmSync(tempRoot, { recursive: true, force: true }); + globalThis.fetch = originalFetch; }); -describe("JwtApiAuthProvider", () => { - it("delegates the JWT branch to getSessionToken once per invocation without caching", async () => { - const accessToken = jwtWithExp(Date.now() + 60 * 60 * 1000); - const getSessionToken = mock(async () => accessToken); - const originalFetch = globalThis.fetch; - const fetchMock = mock(async () => new Response(null, { status: 500 })); - globalThis.fetch = fetchMock as unknown as typeof fetch; - const provider = new JwtApiAuthProvider({ - getSessionToken, - apiUrl: "https://api.example.com", +describe("JwtApiAuthProvider with config-backed host auth", () => { + test("returns the config access token before 401 invalidation", async () => { + const { dir, configPath } = createConfigFile({ + auth: { + accessToken: "stored.jwt.token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 1000, + }, + }); + const fetchMock = mockFetch(async () => { + throw new Error("unexpected fetch"); }); try { - expect(await provider.getJwt()).toBe(accessToken); - expect(await provider.getJwt()).toBe(accessToken); - expect(getSessionToken).toHaveBeenCalledTimes(2); + await expect( + createConfigBackedProvider(configPath).getHeaders(), + ).resolves.toEqual({ + Authorization: "Bearer stored.jwt.token", + }); expect(fetchMock).not.toHaveBeenCalled(); } finally { - globalThis.fetch = originalFetch; + rmSync(dir, { recursive: true, force: true }); } }); - it("refreshes a JWT within the leeway and persists the rotated credential atomically", async () => { - const configPath = createConfigPath(); - const oldToken = jwtWithExp(Date.now() + 60_000); - const refreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); - const refreshedExpiresAt = Date.now() + 60 * 60 * 1000; - refreshAccessTokenImpl = async () => ({ - accessToken: refreshedToken, - refreshToken: "rotated-refresh-token", - expiresAt: refreshedExpiresAt, - }); - writeConfig(configPath, { - organizationId: "org_1", - apiKey: "sk_live_existing", + test("refreshes from config after 401 invalidation and persists rotated auth", async () => { + const { dir, configPath } = createConfigFile({ auth: { - accessToken: oldToken, - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, + accessToken: "stale.jwt.token", + refreshToken: "old-refresh-token", + expiresAt: Date.now() - 1000, }, + organizationId: ORGANIZATION_ID, }); - const originalRenameSync = fs.renameSync; - let atomicTmpPath: string | undefined; - const renameSpy = spyOn(fs, "renameSync").mockImplementation( - (oldPath: PathLike, newPath: PathLike) => { - atomicTmpPath = String(oldPath); - expect(newPath).toBe(configPath); - originalRenameSync(oldPath, newPath); - }, + const fetchMock = mockFetch(async () => + Response.json({ + access_token: "refreshed.jwt.token", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }), ); + const authProvider = createConfigBackedProvider(configPath); - const token = await createProvider(configPath).getSessionToken(); + try { + await expect(authProvider.getHeaders()).resolves.toEqual({ + Authorization: "Bearer stale.jwt.token", + }); - expect(token).toBe(refreshedToken); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - expect(refreshAccessTokenMock).toHaveBeenCalledWith("refresh-token"); - expect(atomicTmpPath).toBeDefined(); - expect(atomicTmpPath).not.toBe(`${configPath}.tmp`); - expect(atomicTmpPath?.startsWith(`${configPath}.`)).toBe(true); - expect(atomicTmpPath?.endsWith(".tmp")).toBe(true); - expect(readConfig(configPath)).toEqual({ - organizationId: "org_1", - apiKey: "sk_live_existing", - auth: { - accessToken: refreshedToken, - refreshToken: "rotated-refresh-token", - expiresAt: refreshedExpiresAt, - }, - }); + authProvider.invalidateCache(); - renameSpy.mockRestore(); + await expect(authProvider.getHeaders()).resolves.toEqual({ + Authorization: "Bearer refreshed.jwt.token", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const updated = readConfig(configPath); + expect(updated.organizationId).toBe(ORGANIZATION_ID); + expect(updated.auth?.accessToken).toBe("refreshed.jwt.token"); + expect(updated.auth?.refreshToken).toBe("rotated-refresh-token"); + expect(updated.auth?.expiresAt ?? 0).toBeGreaterThan(Date.now()); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); - it("returns the in-memory token without refresh or config re-read when the JWT is fresh", async () => { - const configPath = createConfigPath(); - const freshToken = jwtWithExp(Date.now() + 60 * 60 * 1000); - writeConfig(configPath, { + test("a cloud 401 retries once with a refreshed config token", async () => { + const { dir, configPath } = createConfigFile({ auth: { - accessToken: freshToken, + accessToken: "stale.jwt.token", refreshToken: "refresh-token", - expiresAt: Date.now() + 60 * 60 * 1000, + expiresAt: Date.now() - 1000, }, }); - const provider = createProvider(configPath); - const readSpy = spyOn(fs, "readFileSync"); - - expect(await provider.getSessionToken()).toBe(freshToken); - readSpy.mockClear(); - - expect(await provider.getSessionToken()).toBe(freshToken); - expect(refreshAccessTokenMock).not.toHaveBeenCalled(); - expect(readSpy).not.toHaveBeenCalled(); + const t = initTRPC.context<{ headers: Headers }>().create({ + transformer: SuperJSON, + }); + const seenAuthHeaders: string[] = []; + const cloudRouter = t.router({ + user: t.router({ + me: t.procedure.query(({ ctx }) => { + const authorization = ctx.headers.get("authorization") ?? ""; + seenAuthHeaders.push(authorization); + expect(ctx.headers.get(ORGANIZATION_HEADER)).toBe(ORGANIZATION_ID); + + if (authorization === "Bearer stale.jwt.token") { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "stale token", + }); + } + + expect(authorization).toBe("Bearer refreshed.jwt.token"); + return { id: "user-1", email: "test@superset.local" }; + }), + }), + }); + const fetchMock = mockFetch(async (input, init) => { + const request = + input instanceof Request ? input : new Request(input.toString(), init); + const url = new URL(request.url); + if (url.pathname === "/api/auth/oauth2/token") { + return Response.json({ + access_token: "refreshed.jwt.token", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }); + } + if (url.pathname.startsWith("/api/trpc")) { + return fetchRequestHandler({ + endpoint: "/api/trpc", + req: request, + router: cloudRouter, + createContext: () => ({ headers: request.headers }), + }); + } + return new Response("not found", { status: 404 }); + }); + const api = createApiClient( + API_URL, + createConfigBackedProvider(configPath), + ORGANIZATION_ID, + ); - readSpy.mockRestore(); + try { + await expect(api.user.me.query()).resolves.toMatchObject({ + id: "user-1", + }); + expect(seenAuthHeaders).toEqual([ + "Bearer stale.jwt.token", + "Bearer refreshed.jwt.token", + ]); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(readConfig(configPath).auth?.refreshToken).toBe( + "rotated-refresh-token", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); - it("coalesces concurrent refresh callers into one in-flight refresh", async () => { - const configPath = createConfigPath(); - const oldToken = jwtWithExp(Date.now() + 60_000); - const firstRefreshedToken = jwtWithExp(Date.now() + 60_000); - const secondRefreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); - let refreshCount = 0; - refreshAccessTokenImpl = async (refreshToken: string) => { - refreshCount += 1; - await new Promise((resolve) => setTimeout(resolve, 10)); - return { - accessToken: - refreshCount === 1 ? firstRefreshedToken : secondRefreshedToken, - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, - }; - }; - writeConfig(configPath, { + test("concurrent retry callers perform one refresh", async () => { + const { dir, configPath } = createConfigFile({ auth: { - accessToken: oldToken, + accessToken: "stale.jwt.token", refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, + expiresAt: Date.now() - 1000, }, }); - const provider = createProvider(configPath); - - const results = await Promise.all( - Array.from({ length: 50 }, () => provider.getSessionToken()), - ); + let releaseRefresh: (() => void) | undefined; + const refreshStarted = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const fetchMock = mockFetch(async () => { + await refreshStarted; + return Response.json({ + access_token: "refreshed.jwt.token", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }); + }); + const authProvider = createConfigBackedProvider(configPath); - expect(new Set(results)).toEqual(new Set([firstRefreshedToken])); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + try { + authProvider.invalidateCache(); + const resultsPromise = Promise.all([ + authProvider.getHeaders(), + authProvider.getHeaders(), + authProvider.getHeaders(), + ]); + await Promise.resolve(); + releaseRefresh?.(); - await expect(provider.getSessionToken()).resolves.toBe( - secondRefreshedToken, - ); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + await expect(resultsPromise).resolves.toEqual([ + { Authorization: "Bearer refreshed.jwt.token" }, + { Authorization: "Bearer refreshed.jwt.token" }, + { Authorization: "Bearer refreshed.jwt.token" }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); - it("throws invalid_grant AuthRefreshFailedError on a 401 refresh response", async () => { - const configPath = createConfigPath(); - refreshAccessTokenImpl = async () => { - throw new Error("Token refresh failed: 401"); - }; - writeConfig(configPath, { + test("missing refresh token fails with login guidance after invalidation", async () => { + const { dir, configPath } = createConfigFile({ auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, + accessToken: "stale.jwt.token", + expiresAt: Date.now() - 1000, }, }); - - await expect( - createProvider(configPath).getSessionToken(), - ).rejects.toMatchObject({ - message: AUTH_REFRESH_FAILED_MESSAGE, - reason: "invalid_grant", - statusCode: 401, + const fetchMock = mockFetch(async () => { + throw new Error("unexpected fetch"); }); + const authProvider = createConfigBackedProvider(configPath); + + try { + authProvider.invalidateCache(); + await expect(authProvider.getHeaders()).rejects.toThrow( + /superset auth login/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); - it("classifies invalid_grant from the local OAuth refresh request without leaking the response body", async () => { - const configPath = createConfigPath(); - writeConfig(configPath, { + test("failed refresh errors are sanitized", async () => { + const { dir, configPath } = createConfigFile({ auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token-secret", - expiresAt: Date.now() + 60_000, + accessToken: "stale.jwt.token", + refreshToken: "refresh-secret", + expiresAt: Date.now() - 1000, }, }); - const originalFetch = globalThis.fetch; - const fetchMock = mock( + mockFetch( async () => new Response( JSON.stringify({ - error: "invalid_grant", - refresh_token: "refresh-token-secret", - redirect: - "https://api.example.com/callback?code=authorization-code-secret", + access_token: "access-secret", + refresh_token: "refresh-secret", + redirect: "https://app.superset.test/callback?code=code-secret", + cookie: "session=session-secret", }), { status: 400 }, ), ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - const provider = new JwtApiAuthProvider({ - getSessionToken: async () => "bootstrap-access-token", - apiUrl: "https://api.example.com", - authConfigPath: configPath, - }); - - try { - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: AUTH_REFRESH_FAILED_MESSAGE, - reason: "invalid_grant", - statusCode: 400, - }); - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: AUTH_REFRESH_FAILED_MESSAGE, - reason: "invalid_grant", - statusCode: 400, - }); - expect(fetchMock).toHaveBeenCalledTimes(1); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - expect(message).not.toContain("refresh-token-secret"); - expect(message).not.toContain("authorization-code-secret"); - throw error; - } finally { - globalThis.fetch = originalFetch; - } - }); + const authProvider = createConfigBackedProvider(configPath); - it("emits one auth:session_expired event with the exact hint and wipes refresh token on invalid_grant", async () => { - const clock = mockNow(1_700_000_000_000); try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - refreshAccessTokenImpl = async () => { - throw new Error("Token refresh failed: 401 invalid_grant"); - }; - writeConfig(configPath, { - organizationId: "org_1", - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "invalid_grant", - statusCode: 401, - }); - - expect(provider.getAuthState()).toMatchObject({ - kind: "expired_permanent", - reason: "invalid_grant", - statusCode: 401, - }); - expect(provider.isInAnyExpiredState()).toBe(true); - expect(events.expired).toEqual([ - { - reason: "invalid_grant", - hint: SESSION_EXPIRED_HINT, - occurredAt: Date.now(), - }, - ]); - expect(events.restored).toEqual([]); - expect(readConfig(configPath)).toEqual({ - organizationId: "org_1", - auth: { - accessToken: expect.any(String), - expiresAt: expect.any(Number), - }, - }); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "invalid_grant", - statusCode: 401, - }); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - expect(events.expired).toHaveLength(1); - expect(events.restored).toHaveLength(0); - } finally { - clock.restore(); - } - }); - - it("records transient network failures, preserves refresh token, and suppresses retry inside 60 seconds", async () => { - const clock = mockNow(1_700_000_000_000); - try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - refreshAccessTokenImpl = async () => { - throw new TypeError("fetch failed"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "network_error", - }); - - expect(provider.getAuthState()).toEqual({ - kind: "expired_transient", - reason: "network_error", - lastFailureAt: Date.now(), - statusCode: undefined, - }); - expect(readConfig(configPath).auth?.refreshToken).toBe("refresh-token"); - expect(events.expired).toEqual([ - { - reason: "network_error", - hint: SESSION_EXPIRED_HINT, - occurredAt: Date.now(), - }, - ]); - - for (let i = 0; i < 20; i += 1) { - clock.advance(1_000); - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "network_error", - }); + authProvider.invalidateCache(); + let thrown: unknown; + try { + await authProvider.getHeaders(); + } catch (error) { + thrown = error; } - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - expect(events.expired).toHaveLength(1); - expect(events.restored).toHaveLength(0); - } finally { - clock.restore(); - } - }); - it("records transient 5xx failures and preserves the refresh token", async () => { - const clock = mockNow(1_700_000_000_000); - try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - refreshAccessTokenImpl = async () => { - throw new Error("Token refresh failed: 503"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - message: SESSION_EXPIRED_HINT, - reason: "http_error", - statusCode: 503, - }); - - expect(provider.getAuthState()).toEqual({ - kind: "expired_transient", - reason: "http_error", - lastFailureAt: Date.now(), - statusCode: 503, - }); - expect(readConfig(configPath).auth?.refreshToken).toBe("refresh-token"); - expect(events.expired).toEqual([ - { - reason: "http_error", - hint: SESSION_EXPIRED_HINT, - occurredAt: Date.now(), - }, - ]); - expect(events.restored).toEqual([]); + const message = thrown instanceof Error ? thrown.message : String(thrown); + expect(message).toContain("superset auth login"); + expect(message).not.toContain("access-secret"); + expect(message).not.toContain("refresh-secret"); + expect(message).not.toContain("session-secret"); + expect(message).not.toContain("code-secret"); } finally { - clock.restore(); + rmSync(dir, { recursive: true, force: true }); } }); - it("retries a transient failure after 60 seconds and broadcasts auth:session_restored once on success", async () => { - const clock = mockNow(1_700_000_000_000); - try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - const refreshedToken = jwtWithExp(Date.now() + 60 * 60 * 1000); - refreshAccessTokenImpl = async () => { - throw new TypeError("fetch failed"); - }; + test("skips writing refreshed auth if on-disk auth changed during refresh", async () => { + const { dir, configPath } = createConfigFile({ + auth: { + accessToken: "stale.jwt.token", + refreshToken: "old-refresh-token", + expiresAt: Date.now() - 1000, + }, + }); + const fetchMock = mockFetch(async () => { writeConfig(configPath, { auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, + accessToken: "external.jwt.token", + refreshToken: "external-refresh-token", + expiresAt: Date.now() + 60 * 60 * 1000, }, }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - reason: "network_error", - }); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); - - clock.advance(61_000); - refreshAccessTokenImpl = async (refreshToken: string) => ({ - accessToken: refreshedToken, - refreshToken, - expiresAt: Date.now() + 60 * 60 * 1000, + return Response.json({ + access_token: "refreshed.jwt.token", + refresh_token: "rotated-refresh-token", + expires_in: 3600, }); + }); + const authProvider = createConfigBackedProvider(configPath); - await expect(provider.getSessionToken()).resolves.toBe(refreshedToken); - - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); - expect(provider.getAuthState()).toEqual({ kind: "healthy" }); - expect(provider.isInAnyExpiredState()).toBe(false); - expect(events.expired).toHaveLength(1); - expect(events.restored).toEqual([{ occurredAt: Date.now() }]); - - await expect(provider.getSessionToken()).resolves.toBe(refreshedToken); - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); - expect(events.restored).toHaveLength(1); - } finally { - clock.restore(); - } - }); - - it("updates transient lastFailureAt after a retry failure without re-emitting auth:session_expired", async () => { - const clock = mockNow(1_700_000_000_000); try { - const configPath = createConfigPath(); - const events = createAuthEvents(); - refreshAccessTokenImpl = async () => { - throw new TypeError("fetch failed"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - const provider = createProvider(configPath, events.eventBus); - - await expect(provider.getSessionToken()).rejects.toMatchObject({ - reason: "network_error", + authProvider.invalidateCache(); + await expect(authProvider.getHeaders()).resolves.toEqual({ + Authorization: "Bearer external.jwt.token", }); - clock.advance(61_000); - await expect(provider.getSessionToken()).rejects.toMatchObject({ - reason: "network_error", - }); - - expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); - expect(provider.getAuthState()).toEqual({ - kind: "expired_transient", - reason: "network_error", - lastFailureAt: Date.now(), - statusCode: undefined, + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(readConfig(configPath).auth).toMatchObject({ + accessToken: "external.jwt.token", + refreshToken: "external-refresh-token", }); - expect(events.expired).toHaveLength(1); - expect(events.restored).toHaveLength(0); } finally { - clock.restore(); + rmSync(dir, { recursive: true, force: true }); } }); - it("does not emit process-level error events for permanent or transient refresh failures", async () => { - const errors = await captureProcessErrors(async () => { - for (const failure of [ - () => new Error("Token refresh failed: 401 invalid_grant"), - () => new TypeError("fetch failed"), - () => new Error("Token refresh failed: 503"), - ]) { - const configPath = createConfigPath(); - refreshAccessTokenImpl = async () => { - throw failure(); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, - }); - await expect( - createProvider(configPath).getSessionToken(), - ).rejects.toBeInstanceOf(AuthRefreshFailedError); - } + test("static AUTH_TOKEN behavior is unchanged without a config source", async () => { + const fetchMock = mockFetch(async () => { + throw new Error("unexpected fetch"); }); - - expect(errors).toEqual([]); - }); - - it("uses the exact refresh failure hint without leaking token, URL, or response body", async () => { - const configPath = createConfigPath(); - const leakedToken = "refresh-token-secret"; - const leakedUrl = - "https://api.example.com/api/auth/oauth2/token?refresh_token=secret"; - const leakedBody = "raw invalid_grant response body"; - refreshAccessTokenImpl = async () => { - throw new Error( - `Token refresh failed: 500 ${leakedToken} ${leakedUrl} ${leakedBody}`, - ); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: leakedToken, - expiresAt: Date.now() + 60_000, - }, + const authProvider = new JwtApiAuthProvider({ + getSessionToken: async () => "static.jwt.token", + apiUrl: API_URL, }); - try { - await createProvider(configPath).getSessionToken(); - throw new Error("expected getSessionToken to throw"); - } catch (error) { - expect(error).toBeInstanceOf(AuthRefreshFailedError); - const refreshError = error as InstanceType; - expect(refreshError.message).toBe(AUTH_REFRESH_FAILED_MESSAGE); - expect(refreshError.reason).toBe("http_error"); - expect(refreshError.statusCode).toBe(500); - expect(refreshError.message).not.toContain(leakedToken); - expect(refreshError.message).not.toContain(leakedUrl); - expect(refreshError.message).not.toContain(leakedBody); - } - }); - - it("classifies thrown fetch failures as network_error", async () => { - const configPath = createConfigPath(); - refreshAccessTokenImpl = async () => { - throw new TypeError("fetch failed"); - }; - writeConfig(configPath, { - auth: { - accessToken: jwtWithExp(Date.now() + 60_000), - refreshToken: "refresh-token", - expiresAt: Date.now() + 60_000, - }, + await expect(authProvider.getHeaders()).resolves.toEqual({ + Authorization: "Bearer static.jwt.token", }); - - await expect( - createProvider(configPath).getSessionToken(), - ).rejects.toMatchObject({ - message: AUTH_REFRESH_FAILED_MESSAGE, - reason: "network_error", + authProvider.invalidateCache(); + await expect(authProvider.getHeaders()).resolves.toEqual({ + Authorization: "Bearer static.jwt.token", }); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts index f084fb74160..fb3e6143df1 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts @@ -1,243 +1,35 @@ -import { randomUUID } from "node:crypto"; -import * as fs from "node:fs"; -import { dirname } from "node:path"; -import { - AuthRefreshFailedError, - type AuthRefreshFailureReason, -} from "../../../errors"; -import { SESSION_EXPIRED_HINT } from "../hint"; -import type { ApiAuthProvider, AuthSessionEventPublisher } from "../types"; +import type { ApiAuthProvider } from "../types"; const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000; const JWT_CACHE_DURATION_MS = 55 * 60 * 1000; -const TRANSIENT_RETRY_INTERVAL_MS = 60 * 1000; -const CLIENT_ID = "superset-cli"; -interface SupersetAuthConfig { - accessToken: string; - refreshToken?: string; - expiresAt: number; -} - -type RefreshAccessToken = (refreshToken: string) => Promise; - -interface SupersetConfig { - auth?: SupersetAuthConfig; - apiKey?: string; - organizationId?: string; - [key: string]: unknown; -} - -interface RefreshFailureClassification { - reason: AuthRefreshFailureReason; - statusCode?: number; +function looksLikeJwt(token: string): boolean { + const parts = token.split("."); + return parts.length === 3 && parts.every(Boolean); } -export type JwtApiAuthProviderExpiredState = - | { - kind: "expired_permanent"; - reason: "invalid_grant"; - statusCode?: number; - } - | { - kind: "expired_transient"; - reason: "network_error" | "http_error"; - lastFailureAt: number; - statusCode?: number; - }; - -export type JwtApiAuthProviderAuthState = - | { kind: "healthy" } - | JwtApiAuthProviderExpiredState; - export interface JwtApiAuthProviderOptions { /** * Returns the current session/api-key/JWT token to authenticate with. - * Used directly when no auth config path is available, and as a fallback - * when the config file has not been written yet. + * Called whenever a fresh JWT needs to be minted, so token rotations + * (re-login, refresh) are picked up without restarting the host-service. */ getSessionToken: () => Promise; + onInvalidateCache?: () => void; apiUrl: string; - authConfigPath?: string; - eventBus?: AuthSessionEventPublisher; - refreshAccessToken?: RefreshAccessToken; -} - -function looksLikeJwt(token: string): boolean { - const parts = token.split("."); - return parts.length === 3 && parts.every(Boolean); -} - -function readJwtExp(token: string): number | null { - const parts = token.split("."); - if (parts.length !== 3) return null; - - const payload = parts[1]; - if (!payload) return null; - - try { - const parsed: unknown = JSON.parse( - Buffer.from(payload, "base64url").toString("utf8"), - ); - if ( - typeof parsed === "object" && - parsed !== null && - !Array.isArray(parsed) && - typeof (parsed as { exp?: unknown }).exp === "number" - ) { - return (parsed as { exp: number }).exp * 1000; - } - return null; - } catch { - return null; - } -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isSupersetAuthConfig(value: unknown): value is SupersetAuthConfig { - if (!isObject(value)) return false; - return ( - typeof value.accessToken === "string" && - typeof value.expiresAt === "number" && - (value.refreshToken === undefined || typeof value.refreshToken === "string") - ); -} - -function readStatusCode(error: unknown): number | undefined { - if (isObject(error) && typeof error.statusCode === "number") { - return error.statusCode; - } - const message = error instanceof Error ? error.message : String(error); - const match = /Token refresh failed:\s*(\d{3})/.exec(message); - if (!match?.[1]) return undefined; - return Number.parseInt(match[1], 10); -} - -function errorIndicatesInvalidGrant(error: unknown): boolean { - if (isObject(error) && error.invalidGrant === true) { - return true; - } - const message = error instanceof Error ? error.message : String(error); - const suggestion = - isObject(error) && typeof error.suggestion === "string" - ? error.suggestion - : ""; - return /\binvalid_grant\b/i.test(`${message}\n${suggestion}`); -} - -function reasonForRefreshError(error: unknown): RefreshFailureClassification { - const statusCode = readStatusCode(error); - if (statusCode === undefined) { - return { reason: "network_error" }; - } - if ( - statusCode === 401 || - ((statusCode === 400 || statusCode === 403) && - errorIndicatesInvalidGrant(error)) - ) { - return { reason: "invalid_grant", statusCode }; - } - return { reason: "http_error", statusCode }; -} - -class OAuthRefreshRequestError extends Error { - constructor( - readonly statusCode: number, - readonly invalidGrant: boolean, - ) { - super(`Token refresh failed: ${statusCode}`); - this.name = "OAuthRefreshRequestError"; - } -} - -async function refreshAccessTokenFromOAuth( - apiUrl: string, - refreshToken: string, -): Promise { - const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - resource: apiUrl, - }), - }); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new OAuthRefreshRequestError( - response.status, - /\binvalid_grant\b/i.test(body), - ); - } - - const data = (await response.json()) as { - access_token: string; - expires_in?: number; - refresh_token?: string; - }; - const expiresIn = data.expires_in ?? 60 * 60; - return { - accessToken: data.access_token, - refreshToken: data.refresh_token ?? refreshToken, - expiresAt: Date.now() + expiresIn * 1000, - }; -} - -function readConfigAtPath(configPath: string): SupersetConfig { - if (!fs.existsSync(configPath)) return {}; - const parsed: unknown = JSON.parse(fs.readFileSync(configPath, "utf-8")); - return isObject(parsed) ? parsed : {}; -} - -function writeConfigAtPath(configPath: string, config: SupersetConfig): void { - const configDir = dirname(configPath); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); - } - try { - const stat = fs.statSync(configDir); - if ((stat.mode & 0o077) !== 0) fs.chmodSync(configDir, 0o700); - } catch {} - - const tmpPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), { - mode: 0o600, - flag: "wx", - }); - try { - fs.chmodSync(tmpPath, 0o600); - } catch {} - fs.renameSync(tmpPath, configPath); } export class JwtApiAuthProvider implements ApiAuthProvider { - private readonly loadSessionToken: () => Promise; + private readonly getSessionToken: () => Promise; + private readonly onInvalidateCache?: () => void; private readonly apiUrl: string; - private readonly authConfigPath: string | undefined; - private readonly refreshAccessToken: RefreshAccessToken; - private eventBus: AuthSessionEventPublisher | undefined; private cachedJwt: string | null = null; - private cachedJwtSessionToken: string | null = null; private cachedJwtExpiresAt = 0; - private currentCredential: SupersetAuthConfig | null = null; - private inflightRefresh: Promise | null = null; - private expired: JwtApiAuthProviderExpiredState | null = null; constructor(options: JwtApiAuthProviderOptions) { - this.loadSessionToken = options.getSessionToken; + this.getSessionToken = options.getSessionToken; + this.onInvalidateCache = options.onInvalidateCache; this.apiUrl = options.apiUrl; - this.authConfigPath = options.authConfigPath; - this.eventBus = options.eventBus; - this.refreshAccessToken = - options.refreshAccessToken ?? - ((refreshToken) => - refreshAccessTokenFromOAuth(this.apiUrl, refreshToken)); } async getHeaders(): Promise> { @@ -247,91 +39,30 @@ export class JwtApiAuthProvider implements ApiAuthProvider { invalidateCache(): void { this.cachedJwt = null; - this.cachedJwtSessionToken = null; this.cachedJwtExpiresAt = 0; - this.currentCredential = null; - } - - setEventBus(eventBus: AuthSessionEventPublisher): void { - this.eventBus = eventBus; - } - - isInAnyExpiredState(): boolean { - return this.expired !== null; - } - - isInExpiredState(): boolean { - return this.isInAnyExpiredState(); - } - - getAuthState(): JwtApiAuthProviderAuthState { - if (!this.expired) return { kind: "healthy" }; - return { ...this.expired }; - } - - async getSessionToken(): Promise { - if (!this.authConfigPath) { - return this.loadSessionToken(); - } - - if (this.expired?.kind === "expired_permanent") { - throw new AuthRefreshFailedError({ - reason: this.expired.reason, - statusCode: this.expired.statusCode, - }); - } - - if (this.expired?.kind === "expired_transient") { - const elapsedMs = Date.now() - this.expired.lastFailureAt; - if (elapsedMs < TRANSIENT_RETRY_INTERVAL_MS) { - throw new AuthRefreshFailedError({ - reason: this.expired.reason, - statusCode: this.expired.statusCode, - }); - } - } - - const credential = this.currentCredential ?? this.readCurrentCredential(); - if (!credential) { - return this.loadSessionToken(); - } - this.currentCredential = credential; - - const expiresAt = readJwtExp(credential.accessToken); - const needsRefresh = - this.expired !== null || - (expiresAt !== null && expiresAt - Date.now() <= JWT_REFRESH_BUFFER_MS); - if (!needsRefresh) { - return credential.accessToken; - } - - if (this.inflightRefresh) { - return this.inflightRefresh; - } - - this.inflightRefresh = this.refreshCredential(credential).finally(() => { - this.inflightRefresh = null; - }); - return this.inflightRefresh; + this.onInvalidateCache?.(); } async getJwt(): Promise { - const sessionToken = await this.getSessionToken(); - - // OAuth access tokens are already JWTs. Delegate to getSessionToken so - // host-owned refresh and single-flight behavior run before pass-through. - if (looksLikeJwt(sessionToken)) { - return sessionToken; - } - if ( this.cachedJwt && - this.cachedJwtSessionToken === sessionToken && Date.now() < this.cachedJwtExpiresAt - JWT_REFRESH_BUFFER_MS ) { return this.cachedJwt; } + const sessionToken = await this.getSessionToken(); + + // CLI OAuth code+PKCE login stores the OAuth access token directly, + // which is already a JWT signed by the same JWKS the relay verifies + // against and carries `organizationIds` via customAccessTokenClaims. + // Pass it through — no /api/auth/token exchange needed (and the + // better-auth jwt plugin endpoint doesn't accept OAuth tokens + // anyway, only sessions and api keys). + if (looksLikeJwt(sessionToken)) { + return sessionToken; + } + // better-auth's apiKey plugin reads `sk_live_…` from x-api-key, not // Authorization: Bearer; mirror what the CLI's tRPC client does in // packages/cli/src/lib/api-client.ts. @@ -345,146 +76,7 @@ export class JwtApiAuthProvider implements ApiAuthProvider { } const data = (await response.json()) as { token: string }; this.cachedJwt = data.token; - this.cachedJwtSessionToken = sessionToken; this.cachedJwtExpiresAt = Date.now() + JWT_CACHE_DURATION_MS; return data.token; } - - private readCurrentCredential(): SupersetAuthConfig | null { - if (!this.authConfigPath) return null; - const config = readConfigAtPath(this.authConfigPath); - return isSupersetAuthConfig(config.auth) ? config.auth : null; - } - - private async refreshCredential( - credential: SupersetAuthConfig, - ): Promise { - if (!credential.refreshToken) { - this.transitionToPermanent({ reason: "invalid_grant" }); - this.wipeRefreshToken(); - throw new AuthRefreshFailedError({ reason: "invalid_grant" }); - } - - let refreshed: SupersetAuthConfig; - try { - refreshed = await this.runRefresh(credential.refreshToken); - } catch (error) { - const failure = - error instanceof AuthRefreshFailedError - ? { reason: error.reason, statusCode: error.statusCode } - : reasonForRefreshError(error); - this.handleRefreshFailure(failure); - throw new AuthRefreshFailedError(failure); - } - - const nextCredential: SupersetAuthConfig = { - accessToken: refreshed.accessToken, - refreshToken: refreshed.refreshToken ?? credential.refreshToken, - expiresAt: refreshed.expiresAt, - }; - - if (this.authConfigPath) { - const latestConfig = readConfigAtPath(this.authConfigPath); - writeConfigAtPath(this.authConfigPath, { - ...latestConfig, - auth: nextCredential, - }); - } - - this.currentCredential = nextCredential; - this.cachedJwt = null; - this.cachedJwtSessionToken = null; - this.cachedJwtExpiresAt = 0; - this.transitionToHealthy(); - return nextCredential.accessToken; - } - - private async runRefresh(refreshToken: string): Promise { - const refreshed = await this.refreshAccessToken(refreshToken); - return { - accessToken: refreshed.accessToken, - refreshToken: refreshed.refreshToken ?? refreshToken, - expiresAt: refreshed.expiresAt, - }; - } - - private handleRefreshFailure(failure: RefreshFailureClassification): void { - if (failure.reason === "invalid_grant") { - this.transitionToPermanent({ - reason: failure.reason, - statusCode: failure.statusCode, - }); - this.wipeRefreshToken(); - return; - } - - this.transitionToTransient({ - reason: failure.reason, - statusCode: failure.statusCode, - }); - } - - private transitionToPermanent(failure: { - reason: "invalid_grant"; - statusCode?: number; - }): void { - const wasHealthy = this.expired === null; - const occurredAt = Date.now(); - this.expired = { - kind: "expired_permanent", - reason: failure.reason, - statusCode: failure.statusCode, - }; - if (wasHealthy) { - this.eventBus?.broadcastAuthSessionExpired({ - reason: failure.reason, - hint: SESSION_EXPIRED_HINT, - occurredAt, - }); - } - } - - private transitionToTransient(failure: { - reason: "network_error" | "http_error"; - statusCode?: number; - }): void { - const wasHealthy = this.expired === null; - const occurredAt = Date.now(); - this.expired = { - kind: "expired_transient", - reason: failure.reason, - lastFailureAt: occurredAt, - statusCode: failure.statusCode, - }; - if (wasHealthy) { - this.eventBus?.broadcastAuthSessionExpired({ - reason: failure.reason, - hint: SESSION_EXPIRED_HINT, - occurredAt, - }); - } - } - - private transitionToHealthy(): void { - const wasExpiredTransient = this.expired?.kind === "expired_transient"; - const occurredAt = Date.now(); - this.expired = null; - if (wasExpiredTransient) { - this.eventBus?.broadcastAuthSessionRestored({ occurredAt }); - } - } - - private wipeRefreshToken(): void { - if (!this.authConfigPath) return; - - const latestConfig = readConfigAtPath(this.authConfigPath); - if (!latestConfig.auth?.refreshToken) return; - - const nextAuth = { ...latestConfig.auth }; - delete nextAuth.refreshToken; - writeConfigAtPath(this.authConfigPath, { - ...latestConfig, - auth: nextAuth, - }); - } } diff --git a/packages/host-service/src/providers/auth/hint.ts b/packages/host-service/src/providers/auth/hint.ts deleted file mode 100644 index 3b1a505a868..00000000000 --- a/packages/host-service/src/providers/auth/hint.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const SESSION_EXPIRED_HINT = - "Superset session expired — run `superset auth login`"; diff --git a/packages/host-service/src/providers/auth/index.ts b/packages/host-service/src/providers/auth/index.ts index fe2f13880c2..73ba8f1e97c 100644 --- a/packages/host-service/src/providers/auth/index.ts +++ b/packages/host-service/src/providers/auth/index.ts @@ -1,4 +1,4 @@ +export { ConfigFileSessionTokenSource } from "./ConfigFileSessionTokenSource"; export { DeviceKeyApiAuthProvider } from "./DeviceKeyAuthProvider"; -export { SESSION_EXPIRED_HINT } from "./hint"; export { JwtApiAuthProvider } from "./JwtAuthProvider"; -export type { ApiAuthProvider, AuthSessionEventPublisher } from "./types"; +export type { ApiAuthProvider } from "./types"; diff --git a/packages/host-service/src/providers/auth/types.ts b/packages/host-service/src/providers/auth/types.ts index 4877e6e831b..e43e3ff75b7 100644 --- a/packages/host-service/src/providers/auth/types.ts +++ b/packages/host-service/src/providers/auth/types.ts @@ -1,17 +1,3 @@ -import type { - AuthSessionExpiredMessage, - AuthSessionRestoredMessage, -} from "../../events/types"; - -export interface AuthSessionEventPublisher { - broadcastAuthSessionExpired( - message: Omit, - ): void; - broadcastAuthSessionRestored( - message: Omit, - ): void; -} - export interface ApiAuthProvider { getHeaders(): Promise>; /** @@ -21,7 +7,4 @@ export interface ApiAuthProvider { * rotated, JWKS rolled). */ invalidateCache(): void; - isInAnyExpiredState?(): boolean; - isInExpiredState?(): boolean; - setEventBus?(eventBus: AuthSessionEventPublisher): void; } diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index d910f9bbd6c..39cae36e756 100644 --- a/packages/host-service/src/serve.ts +++ b/packages/host-service/src/serve.ts @@ -2,7 +2,10 @@ import { serve } from "@hono/node-server"; import { createApp } from "./app"; import { getSupervisor, startDaemonBootstrap } from "./daemon"; import { env } from "./env"; -import { JwtApiAuthProvider } from "./providers/auth"; +import { + ConfigFileSessionTokenSource, + JwtApiAuthProvider, +} from "./providers/auth"; import { LocalGitCredentialProvider } from "./providers/git"; import { PskHostAuthProvider } from "./providers/host-auth"; import { LocalModelProvider } from "./providers/model-providers"; @@ -26,10 +29,20 @@ async function main(): Promise { // daemon takes time to come up or fails entirely. startDaemonBootstrap(env.ORGANIZATION_ID); + const configTokenSource = env.SUPERSET_AUTH_CONFIG_PATH + ? new ConfigFileSessionTokenSource({ + configPath: env.SUPERSET_AUTH_CONFIG_PATH, + apiUrl: env.SUPERSET_API_URL, + }) + : null; const authProvider = new JwtApiAuthProvider({ - getSessionToken: async () => env.AUTH_TOKEN, + getSessionToken: configTokenSource + ? () => configTokenSource.getSessionToken() + : async () => env.AUTH_TOKEN, + onInvalidateCache: configTokenSource + ? () => configTokenSource.invalidateCache() + : undefined, apiUrl: env.SUPERSET_API_URL, - authConfigPath: env.SUPERSET_AUTH_CONFIG_PATH, }); const { app, injectWebSocket, api } = createApp({ diff --git a/packages/host-service/src/terminal/env-strip.ts b/packages/host-service/src/terminal/env-strip.ts index 3e2c4247aca..71d6b887035 100644 --- a/packages/host-service/src/terminal/env-strip.ts +++ b/packages/host-service/src/terminal/env-strip.ts @@ -15,6 +15,7 @@ */ const HOST_SERVICE_RUNTIME_KEYS = new Set([ "AUTH_TOKEN", + "SUPERSET_AUTH_CONFIG_PATH", "SUPERSET_API_URL", "DESKTOP_VITE_PORT", "HOST_CLIENT_ID", diff --git a/packages/host-service/src/terminal/env.test.ts b/packages/host-service/src/terminal/env.test.ts index 14d01ea72d5..8903e7e95a4 100644 --- a/packages/host-service/src/terminal/env.test.ts +++ b/packages/host-service/src/terminal/env.test.ts @@ -80,6 +80,7 @@ describe("stripTerminalRuntimeEnv", () => { const secretsEnv: Record = { // Host-service runtime keys that must not leak AUTH_TOKEN: "secret-token", + SUPERSET_AUTH_CONFIG_PATH: "/Users/test/.superset/config.json", HOST_SERVICE_SECRET: "secret", ORGANIZATION_ID: "org-123", HOST_CLIENT_ID: "device-abc", @@ -124,6 +125,7 @@ describe("stripTerminalRuntimeEnv", () => { test("app/runtime secrets do not reach PTY env", () => { const result = stripTerminalRuntimeEnv(secretsEnv); expect(result.AUTH_TOKEN).toBeUndefined(); + expect(result.SUPERSET_AUTH_CONFIG_PATH).toBeUndefined(); expect(result.HOST_SERVICE_SECRET).toBeUndefined(); expect(result.ORGANIZATION_ID).toBeUndefined(); expect(result.HOST_CLIENT_ID).toBeUndefined(); diff --git a/packages/host-service/src/trpc/auth-expired-middleware.test.ts b/packages/host-service/src/trpc/auth-expired-middleware.test.ts deleted file mode 100644 index 0d43a72243c..00000000000 --- a/packages/host-service/src/trpc/auth-expired-middleware.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import type { ApiAuthProvider } from "../providers/auth"; -import { SESSION_EXPIRED_HINT } from "../providers/auth/hint"; -import type { HostServiceContext } from "../types"; -import { protectedProcedure, publicProcedure, router } from "./index"; - -function createContext(authProvider: ApiAuthProvider): HostServiceContext { - return { - isAuthenticated: true, - authProvider, - } as unknown as HostServiceContext; -} - -function createAuthProvider(expired: boolean): ApiAuthProvider { - return { - getHeaders: mock(async () => { - throw new Error("middleware must not refresh credentials"); - }), - invalidateCache: mock(() => {}), - isInAnyExpiredState: mock(() => expired), - }; -} - -describe("auth expired tRPC middleware", () => { - it("short-circuits expired_permanent protected procedures without invoking the resolver", async () => { - const resolver = mock(() => "resolved"); - const authProvider = createAuthProvider(true); - const testRouter = router({ - secured: protectedProcedure.query(() => resolver()), - public: publicProcedure.query(() => "public"), - }); - const caller = testRouter.createCaller(createContext(authProvider)); - - await expect(caller.secured()).rejects.toMatchObject({ - code: "UNAUTHORIZED", - message: SESSION_EXPIRED_HINT, - }); - await expect(caller.public()).resolves.toBe("public"); - expect(resolver).not.toHaveBeenCalled(); - expect(authProvider.getHeaders).not.toHaveBeenCalled(); - }); - - it("short-circuits expired_transient protected procedures without invoking the resolver", async () => { - const resolver = mock(() => "resolved"); - const authProvider = createAuthProvider(true); - const testRouter = router({ - secured: protectedProcedure.query(() => resolver()), - }); - const caller = testRouter.createCaller(createContext(authProvider)); - - await expect(caller.secured()).rejects.toMatchObject({ - code: "UNAUTHORIZED", - message: SESSION_EXPIRED_HINT, - }); - expect(resolver).not.toHaveBeenCalled(); - expect(authProvider.getHeaders).not.toHaveBeenCalled(); - }); - - it("invokes protected resolvers normally when auth state is healthy", async () => { - const resolver = mock(() => "resolved"); - const authProvider = createAuthProvider(false); - const testRouter = router({ - secured: protectedProcedure.query(() => resolver()), - }); - const caller = testRouter.createCaller(createContext(authProvider)); - - await expect(caller.secured()).resolves.toBe("resolved"); - expect(resolver).toHaveBeenCalledTimes(1); - expect(authProvider.getHeaders).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/host-service/src/trpc/error-types.ts b/packages/host-service/src/trpc/error-types.ts index 834f60ade6d..40ba5d0768b 100644 --- a/packages/host-service/src/trpc/error-types.ts +++ b/packages/host-service/src/trpc/error-types.ts @@ -2,14 +2,6 @@ * Cross-cutting error shapes surfaced via the tRPC error formatter. * Lives here (not in a router) to avoid circular imports with `trpc/index.ts`. */ -import { SESSION_EXPIRED_HINT } from "../providers/auth/hint"; - -export { SESSION_EXPIRED_HINT }; - -export interface SessionExpiredUnauthorizedError { - code: "UNAUTHORIZED"; - message: typeof SESSION_EXPIRED_HINT; -} export interface TeardownFailureCause { kind: "TEARDOWN_FAILED"; diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index 00a9b531661..c469ac1cffa 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -1,6 +1,5 @@ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; -import { SESSION_EXPIRED_HINT } from "../providers/auth/hint"; import type { HostServiceContext } from "../types"; import { type DeleteInProgressCause, @@ -75,12 +74,6 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { message: "Invalid or missing authentication token.", }); } - if (ctx.authProvider?.isInAnyExpiredState?.()) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: SESSION_EXPIRED_HINT, - }); - } return next({ ctx }); }); diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index c5b9af7b883..6a054eb2a0c 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -4,7 +4,6 @@ import type { AppRouter } from "@superset/trpc"; import type { TRPCClient } from "@trpc/client"; import type { HostDb } from "./db"; import type { EventBus } from "./events"; -import type { ApiAuthProvider } from "./providers/auth"; import type { ChatRuntimeManager } from "./runtime/chat"; import type { WorkspaceFilesystemManager } from "./runtime/filesystem"; import type { GitFactory } from "./runtime/git"; @@ -28,7 +27,6 @@ export interface HostServiceContext { db: HostDb; runtime: HostServiceRuntime; eventBus: EventBus; - authProvider: ApiAuthProvider; organizationId: string; isAuthenticated: boolean; } From 8718c9fc53725c4728e2982f044117a525af5ddd Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Wed, 20 May 2026 08:43:01 -0700 Subject: [PATCH 11/13] refactor(cli): inline writeConfigFile into writeConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the `writeConfigFile` / `ConfigWriterFs` / `defaultConfigWriterFs` exports. Atomic write logic moves directly into `writeConfig` — the only public API callers actually use. Reverts the public surface to match status-quo (one exported writer). Tests use `mock.module("node:fs", ...)` with snapshotted real impls captured before the mock takes effect. Same three behavioral assertions: unique temp filenames, rename-failure preserves old config, writes to exported path. 12/12 tests pass across the cli package. --- packages/cli/src/lib/config.test.ts | 136 ++++++++++++++++------------ packages/cli/src/lib/config.ts | 55 ++--------- 2 files changed, 88 insertions(+), 103 deletions(-) diff --git a/packages/cli/src/lib/config.test.ts b/packages/cli/src/lib/config.test.ts index cd2a765a3bb..b854dc59d4b 100644 --- a/packages/cli/src/lib/config.test.ts +++ b/packages/cli/src/lib/config.test.ts @@ -1,19 +1,65 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import * as fs from "node:fs"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import * as nodeFs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +// Snapshot real fs functions BEFORE `mock.module` so test setup + assertions +// keep working even though `node:fs` is about to be replaced for the SUT. +const realFs = { + chmodSync: nodeFs.chmodSync, + existsSync: nodeFs.existsSync, + mkdirSync: nodeFs.mkdirSync, + mkdtempSync: nodeFs.mkdtempSync, + readFileSync: nodeFs.readFileSync, + renameSync: nodeFs.renameSync, + rmSync: nodeFs.rmSync, + statSync: nodeFs.statSync, + unlinkSync: nodeFs.unlinkSync, + writeFileSync: nodeFs.writeFileSync, +}; + const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; -const tempHome = mkdtempSync(join(tmpdir(), "superset-cli-config-")); +const tempHome = realFs.mkdtempSync(join(tmpdir(), "superset-cli-config-")); process.env.SUPERSET_HOME_DIR = tempHome; -const { SUPERSET_CONFIG_PATH, writeConfig, writeConfigFile } = await import( - "./config" -); +// Per-test mutable state for the mocked fs. +let renameShouldFail = false; +const writtenPaths: string[] = []; +const unlinkedPaths: string[] = []; + +mock.module("node:fs", () => ({ + ...nodeFs, + writeFileSync: ( + path: nodeFs.PathOrFileDescriptor, + data: string | NodeJS.ArrayBufferView, + options?: nodeFs.WriteFileOptions, + ) => { + writtenPaths.push(String(path)); + return realFs.writeFileSync(path, data, options); + }, + renameSync: (oldPath: nodeFs.PathLike, newPath: nodeFs.PathLike) => { + if (renameShouldFail) throw new Error("rename failed"); + return realFs.renameSync(oldPath, newPath); + }, + unlinkSync: (path: nodeFs.PathLike) => { + unlinkedPaths.push(String(path)); + return realFs.unlinkSync(path); + }, +})); + +const { SUPERSET_CONFIG_PATH, writeConfig } = await import("./config"); + +beforeEach(() => { + writtenPaths.length = 0; + unlinkedPaths.length = 0; + renameShouldFail = false; + if (realFs.existsSync(SUPERSET_CONFIG_PATH)) { + realFs.unlinkSync(SUPERSET_CONFIG_PATH); + } +}); afterAll(() => { - rmSync(tempHome, { recursive: true, force: true }); + realFs.rmSync(tempHome, { recursive: true, force: true }); if (originalSupersetHomeDir === undefined) { delete process.env.SUPERSET_HOME_DIR; } else { @@ -23,70 +69,46 @@ afterAll(() => { describe("config writes", () => { test("writeConfig uses unique temp files", () => { - const writtenPaths: string[] = []; - const configPath = join(tempHome, "unique-temp-config.json"); - const testFs = { - chmodSync: fs.chmodSync, - mkdirSync: fs.mkdirSync, - renameSync: fs.renameSync, - statSync: fs.statSync, - unlinkSync: fs.unlinkSync, - writeFileSync: ( - path: fs.PathOrFileDescriptor, - data: string | NodeJS.ArrayBufferView, - options?: fs.WriteFileOptions, - ) => { - writtenPaths.push(String(path)); - fs.writeFileSync(path, data, options); - }, - }; + writeConfig({ apiKey: "sk_live_one" }); + writeConfig({ apiKey: "sk_live_two" }); - writeConfigFile(configPath, { apiKey: "sk_live_one" }, testFs); - writeConfigFile(configPath, { apiKey: "sk_live_two" }, testFs); - - expect(writtenPaths).toHaveLength(2); - expect(writtenPaths[0]).not.toBe(writtenPaths[1]); - expect(writtenPaths.every((path) => path.endsWith(".config.tmp"))).toBe( - true, - ); - expect(JSON.parse(readFileSync(configPath, "utf-8"))).toEqual({ + const tempWrites = writtenPaths.filter((p) => p.endsWith(".config.tmp")); + expect(tempWrites).toHaveLength(2); + expect(tempWrites[0]).not.toBe(tempWrites[1]); + expect( + JSON.parse(realFs.readFileSync(SUPERSET_CONFIG_PATH, "utf-8")), + ).toEqual({ apiKey: "sk_live_two", }); }); test("writeConfig preserves old config if rename fails", () => { - const configPath = join(tempHome, "rename-failure-config.json"); - writeFileSync(configPath, JSON.stringify({ apiKey: "sk_live_old" })); - const tempPaths: string[] = []; - const testFs = { - chmodSync: fs.chmodSync, - mkdirSync: fs.mkdirSync, - renameSync: () => { - throw new Error("rename failed"); - }, - statSync: fs.statSync, - unlinkSync: (path: fs.PathLike) => { - tempPaths.push(String(path)); - fs.unlinkSync(path); - }, - writeFileSync: fs.writeFileSync, - }; + realFs.writeFileSync( + SUPERSET_CONFIG_PATH, + JSON.stringify({ apiKey: "sk_live_old" }), + ); - expect(() => - writeConfigFile(configPath, { apiKey: "sk_live_new" }, testFs), - ).toThrow(/rename failed/); + renameShouldFail = true; + + expect(() => writeConfig({ apiKey: "sk_live_new" })).toThrow( + /rename failed/, + ); - expect(JSON.parse(readFileSync(configPath, "utf-8"))).toEqual({ + expect( + JSON.parse(realFs.readFileSync(SUPERSET_CONFIG_PATH, "utf-8")), + ).toEqual({ apiKey: "sk_live_old", }); - expect(tempPaths).toHaveLength(1); - expect(fs.existsSync(tempPaths[0] ?? "")).toBe(false); + expect(unlinkedPaths).toHaveLength(1); + expect(realFs.existsSync(unlinkedPaths[0] ?? "")).toBe(false); }); test("writeConfig writes the exported Superset config path", () => { writeConfig({ organizationId: "org_123" }); - expect(JSON.parse(readFileSync(SUPERSET_CONFIG_PATH, "utf-8"))).toEqual({ + expect( + JSON.parse(realFs.readFileSync(SUPERSET_CONFIG_PATH, "utf-8")), + ).toEqual({ organizationId: "org_123", }); }); diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index e6f84c9bd9c..3569baaf1f0 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -10,7 +10,7 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { env } from "./env"; export type SupersetConfig = { @@ -46,66 +46,29 @@ export function readConfig(): SupersetConfig { return JSON.parse(readFileSync(SUPERSET_CONFIG_PATH, "utf-8")); } -type ConfigWriterFs = { - chmodSync(path: string, mode: number): void; - mkdirSync(path: string, options: { recursive: true; mode: number }): unknown; - renameSync(oldPath: string, newPath: string): void; - statSync(path: string): { mode: number }; - unlinkSync(path: string): void; - writeFileSync(path: string, data: string, options: { mode: number }): void; -}; - -const defaultConfigWriterFs: ConfigWriterFs = { - chmodSync, - mkdirSync, - renameSync, - statSync, - unlinkSync, - writeFileSync, -}; - -export function writeConfigFile( - configPath: string, - config: SupersetConfig, - fs: ConfigWriterFs = defaultConfigWriterFs, -): void { - const configDir = dirname(configPath); - if (!existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); - } - try { - const stat = fs.statSync(configDir); - if ((stat.mode & 0o077) !== 0) fs.chmodSync(configDir, 0o700); - } catch {} - +export function writeConfig(config: SupersetConfig): void { + ensureDir(); const tempPath = join( - configDir, + SUPERSET_HOME_DIR, `.${randomUUID()}.${process.pid}.config.tmp`, ); - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), { - mode: 0o600, - }); + writeFileSync(tempPath, JSON.stringify(config, null, 2), { mode: 0o600 }); try { - fs.chmodSync(tempPath, 0o600); + chmodSync(tempPath, 0o600); } catch {} try { - fs.renameSync(tempPath, configPath); + renameSync(tempPath, SUPERSET_CONFIG_PATH); } catch (error) { try { - fs.unlinkSync(tempPath); + unlinkSync(tempPath); } catch {} throw error; } try { - fs.chmodSync(configPath, 0o600); + chmodSync(SUPERSET_CONFIG_PATH, 0o600); } catch {} } -export function writeConfig(config: SupersetConfig): void { - ensureDir(); - writeConfigFile(SUPERSET_CONFIG_PATH, config); -} - export function getApiUrl(): string { return env.SUPERSET_API_URL; } From c03ba5c2b80a415949a38513afbe6f957113975a Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Wed, 20 May 2026 10:30:17 -0700 Subject: [PATCH 12/13] refactor(host-auth): centralize refresh-token strip in env-strip; pass auth config path from desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: - Replace ad-hoc REFRESH_TOKEN scrub in createHostServiceEnv with explicit protection at the canonical host-service-to-terminal boundary (SENSITIVE_AUTH_KEYS in env-strip.ts). Equivalent test coverage moves from spawn.test.ts to env.test.ts. - Mirror SUPERSET_AUTH_CONFIG_PATH propagation in the desktop's HostServiceCoordinator.buildEnv() so a desktop-launched host-service can honor an existing config-file refresh token (no-op when the file lacks one; ConfigFileSessionTokenSource gates on auth.refreshToken). Verification: - bun test packages/host-service/src/terminal/env.test.ts (40 pass) - bun test packages/cli/src/lib/host/spawn.test.ts (1 pass) Pre-existing failures (proven via git stash; not from these changes): - bun run typecheck fails in @superset/db (Cannot find module 'pg') — worktree node_modules is missing pg entirely; environment setup issue. - bun run lint fails on .claude/settings.json formatting (Biome wants multi-line objects expanded) — a Claude Code local-config file outside this PR's scope. --- .../src/main/lib/host-service-coordinator.ts | 1 + packages/cli/src/lib/host/spawn.test.ts | 16 ---------------- packages/cli/src/lib/host/spawn.ts | 9 +-------- packages/host-service/src/terminal/env-strip.ts | 13 +++++++++++++ packages/host-service/src/terminal/env.test.ts | 9 +++++++++ 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index 283776c51d3..856cabe681a 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -467,6 +467,7 @@ export class HostServiceCoordinator extends EventEmitter { SUPERSET_AGENT_HOOK_PORT: String(sharedEnv.DESKTOP_NOTIFICATIONS_PORT), SUPERSET_AGENT_HOOK_VERSION: HOOK_PROTOCOL_VERSION, AUTH_TOKEN: config.authToken, + SUPERSET_AUTH_CONFIG_PATH: path.join(SUPERSET_HOME_DIR, "config.json"), SUPERSET_API_URL: config.cloudApiUrl, // Read by the child's parent watchdog so it can self-exit if // Electron crashes without sending SIGTERM (orphan reparenting). diff --git a/packages/cli/src/lib/host/spawn.test.ts b/packages/cli/src/lib/host/spawn.test.ts index 2628f00f577..1bb68f16b33 100644 --- a/packages/cli/src/lib/host/spawn.test.ts +++ b/packages/cli/src/lib/host/spawn.test.ts @@ -7,8 +7,6 @@ import type { ApiClient } from "../api-client"; const originalFetch = globalThis.fetch; const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; const originalHostBin = process.env.SUPERSET_HOST_BIN; -const originalSupersetRefreshToken = process.env.SUPERSET_REFRESH_TOKEN; -const originalOAuthRefreshToken = process.env.OAUTH_REFRESH_TOKEN; const tempHome = mkdtempSync(join(tmpdir(), "superset-cli-spawn-")); const hostBin = join(tempHome, "superset-host"); @@ -60,16 +58,6 @@ afterEach(() => { spawnCalls.length = 0; spawnMock.mockClear(); globalThis.fetch = originalFetch; - if (originalSupersetRefreshToken === undefined) { - delete process.env.SUPERSET_REFRESH_TOKEN; - } else { - process.env.SUPERSET_REFRESH_TOKEN = originalSupersetRefreshToken; - } - if (originalOAuthRefreshToken === undefined) { - delete process.env.OAUTH_REFRESH_TOKEN; - } else { - process.env.OAUTH_REFRESH_TOKEN = originalOAuthRefreshToken; - } }); afterAll(() => { @@ -88,8 +76,6 @@ afterAll(() => { describe("spawnHostService", () => { test("passes SUPERSET_AUTH_CONFIG_PATH when provided", async () => { - process.env.SUPERSET_REFRESH_TOKEN = "superset-refresh-secret"; - process.env.OAUTH_REFRESH_TOKEN = "oauth-refresh-secret"; globalThis.fetch = mock( async () => new Response("ok", { status: 200 }), ) as unknown as typeof fetch; @@ -108,7 +94,5 @@ describe("spawnHostService", () => { SUPERSET_CONFIG_PATH, ); expect(spawnCalls[0]?.options.env?.AUTH_TOKEN).toBe("session-token"); - expect(spawnCalls[0]?.options.env?.SUPERSET_REFRESH_TOKEN).toBeUndefined(); - expect(spawnCalls[0]?.options.env?.OAUTH_REFRESH_TOKEN).toBeUndefined(); }); }); diff --git a/packages/cli/src/lib/host/spawn.ts b/packages/cli/src/lib/host/spawn.ts index 94163bd8589..e1c72135dc7 100644 --- a/packages/cli/src/lib/host/spawn.ts +++ b/packages/cli/src/lib/host/spawn.ts @@ -96,15 +96,8 @@ function createHostServiceEnv( relayUrl: string, migrationsFolder: string, ): NodeJS.ProcessEnv { - const childEnv: NodeJS.ProcessEnv = { ...process.env }; - for (const key of Object.keys(childEnv)) { - if (key.toUpperCase().includes("REFRESH_TOKEN")) { - delete childEnv[key]; - } - } - return { - ...childEnv, + ...process.env, ORGANIZATION_ID: options.organizationId, AUTH_TOKEN: options.sessionToken, ...(options.authConfigPath diff --git a/packages/host-service/src/terminal/env-strip.ts b/packages/host-service/src/terminal/env-strip.ts index 71d6b887035..f893de6ad80 100644 --- a/packages/host-service/src/terminal/env-strip.ts +++ b/packages/host-service/src/terminal/env-strip.ts @@ -42,12 +42,25 @@ const SUPERSET_KEEP_KEYS = new Set([ "SUPERSET_AGENT_HOOK_VERSION", ]); +/** + * Auth secrets that must never leak from host-service into spawned PTYs. + * Parent CLI/desktop may have these in process.env; they pass through to + * host-service but stop here. SUPERSET_REFRESH_TOKEN would already be caught + * by the SUPERSET_ prefix rule, but listing it explicitly keeps the + * protection load-bearing if SUPERSET_KEEP_KEYS ever changes. + */ +const SENSITIVE_AUTH_KEYS = new Set([ + "OAUTH_REFRESH_TOKEN", + "SUPERSET_REFRESH_TOKEN", +]); + export function stripTerminalRuntimeEnv( baseEnv: Record, ): Record { const result: Record = {}; for (const [key, value] of Object.entries(baseEnv)) { + if (SENSITIVE_AUTH_KEYS.has(key)) continue; if (HOST_SERVICE_RUNTIME_KEYS.has(key)) continue; if (NODE_APP_KEYS.has(key)) continue; if (STRIP_PREFIXES.some((prefix) => key.startsWith(prefix))) continue; diff --git a/packages/host-service/src/terminal/env.test.ts b/packages/host-service/src/terminal/env.test.ts index 8903e7e95a4..3295a58b23b 100644 --- a/packages/host-service/src/terminal/env.test.ts +++ b/packages/host-service/src/terminal/env.test.ts @@ -112,6 +112,9 @@ describe("stripTerminalRuntimeEnv", () => { SUPERSET_PORT: "51741", SUPERSET_HOOK_VERSION: "2", SUPERSET_WORKSPACE_NAME: "my-ws", + // Auth refresh tokens inherited from parent (CLI/desktop) env + OAUTH_REFRESH_TOKEN: "oauth-refresh-secret", + SUPERSET_REFRESH_TOKEN: "superset-refresh-secret", // Keys that SHOULD survive HOME: "/Users/test", PATH: "/usr/bin:/usr/local/bin", @@ -159,6 +162,12 @@ describe("stripTerminalRuntimeEnv", () => { expect(result.ELECTRON_ENABLE_LOGGING).toBeUndefined(); }); + test("refresh tokens do not reach PTY env", () => { + const result = stripTerminalRuntimeEnv(secretsEnv); + expect(result.OAUTH_REFRESH_TOKEN).toBeUndefined(); + expect(result.SUPERSET_REFRESH_TOKEN).toBeUndefined(); + }); + test("HOST_* prefix is stripped, DESKTOP_* exact keys only", () => { const env: Record = { // HOST_* prefix: all stripped (including HOST_CLIENT_ID, HOST_NAME) From 72312223a0e324d86888c918eb83f557dc488554 Mon Sep 17 00:00:00 2001 From: Justin Rich Date: Wed, 20 May 2026 15:51:42 -0700 Subject: [PATCH 13/13] refactor(cli): inline host-service env construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the createHostServiceEnv helper added during HOST-AUTH work — it added an unnecessary level of indirection for a one-line conditional spread. The fix (passing SUPERSET_AUTH_CONFIG_PATH only when authConfigPath is provided) is preserved inline in spawnHostService. Behavior unchanged; spawn.test.ts still passes. --- packages/cli/src/lib/host/spawn.ts | 46 ++++++++++-------------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/lib/host/spawn.ts b/packages/cli/src/lib/host/spawn.ts index e1c72135dc7..ccccde29932 100644 --- a/packages/cli/src/lib/host/spawn.ts +++ b/packages/cli/src/lib/host/spawn.ts @@ -89,30 +89,6 @@ function resolveMigrationsFolder(): string { return join(bundleRoot, "share", "migrations"); } -function createHostServiceEnv( - options: SpawnHostOptions, - port: number, - secret: string, - relayUrl: string, - migrationsFolder: string, -): NodeJS.ProcessEnv { - return { - ...process.env, - ORGANIZATION_ID: options.organizationId, - AUTH_TOKEN: options.sessionToken, - ...(options.authConfigPath - ? { SUPERSET_AUTH_CONFIG_PATH: options.authConfigPath } - : {}), - SUPERSET_API_URL: env.SUPERSET_API_URL, - RELAY_URL: relayUrl, - PORT: String(port), - HOST_SERVICE_PORT: String(port), - HOST_SERVICE_SECRET: secret, - HOST_DB_PATH: hostDbPath(options.organizationId), - HOST_MIGRATIONS_FOLDER: migrationsFolder, - }; -} - export async function spawnHostService( options: SpawnHostOptions, ): Promise { @@ -131,13 +107,21 @@ export async function spawnHostService( const child = spawn(hostBin, [], { stdio: options.daemon ? "ignore" : "inherit", detached: options.daemon, - env: createHostServiceEnv( - options, - port, - secret, - relayUrl, - migrationsFolder, - ), + env: { + ...process.env, + ORGANIZATION_ID: options.organizationId, + AUTH_TOKEN: options.sessionToken, + ...(options.authConfigPath + ? { SUPERSET_AUTH_CONFIG_PATH: options.authConfigPath } + : {}), + SUPERSET_API_URL: env.SUPERSET_API_URL, + RELAY_URL: relayUrl, + PORT: String(port), + HOST_SERVICE_PORT: String(port), + HOST_SERVICE_SECRET: secret, + HOST_DB_PATH: hostDbPath(options.organizationId), + HOST_MIGRATIONS_FOLDER: migrationsFolder, + }, }); if (!child.pid) {