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/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/auth.ts b/packages/cli/src/lib/auth.ts index ef33f5ec9d4..fe9b8658085 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -8,6 +8,7 @@ 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; @@ -222,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, ); } @@ -260,10 +260,9 @@ export async function refreshAccessToken( }); if (!response.ok) { - const body = await response.text(); throw new CLIError( `Token refresh failed: ${response.status}`, - body || "Run `superset auth login` again.", + LOGIN_AGAIN_SUGGESTION, ); } diff --git a/packages/cli/src/lib/config.test.ts b/packages/cli/src/lib/config.test.ts new file mode 100644 index 00000000000..b854dc59d4b --- /dev/null +++ b/packages/cli/src/lib/config.test.ts @@ -0,0 +1,115 @@ +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 = realFs.mkdtempSync(join(tmpdir(), "superset-cli-config-")); +process.env.SUPERSET_HOME_DIR = tempHome; + +// 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(() => { + realFs.rmSync(tempHome, { recursive: true, force: true }); + if (originalSupersetHomeDir === undefined) { + delete process.env.SUPERSET_HOME_DIR; + } else { + process.env.SUPERSET_HOME_DIR = originalSupersetHomeDir; + } +}); + +describe("config writes", () => { + test("writeConfig uses unique temp files", () => { + writeConfig({ apiKey: "sk_live_one" }); + writeConfig({ apiKey: "sk_live_two" }); + + 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", () => { + realFs.writeFileSync( + SUPERSET_CONFIG_PATH, + JSON.stringify({ apiKey: "sk_live_old" }), + ); + + renameShouldFail = true; + + expect(() => writeConfig({ apiKey: "sk_live_new" })).toThrow( + /rename failed/, + ); + + expect( + JSON.parse(realFs.readFileSync(SUPERSET_CONFIG_PATH, "utf-8")), + ).toEqual({ + apiKey: "sk_live_old", + }); + 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(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 c2cf2cd42c9..3569baaf1f0 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -1,9 +1,12 @@ +import { randomUUID } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, + renameSync, statSync, + unlinkSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; @@ -22,7 +25,7 @@ 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 (!existsSync(SUPERSET_HOME_DIR)) { @@ -35,21 +38,34 @@ function ensureDir() { } export function readConfig(): SupersetConfig { - if (!existsSync(CONFIG_PATH)) return {}; + if (!existsSync(SUPERSET_CONFIG_PATH)) return {}; try { - const stat = statSync(CONFIG_PATH); - if ((stat.mode & 0o077) !== 0) chmodSync(CONFIG_PATH, 0o600); + const stat = statSync(SUPERSET_CONFIG_PATH); + if ((stat.mode & 0o077) !== 0) chmodSync(SUPERSET_CONFIG_PATH, 0o600); } catch {} - return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); + return JSON.parse(readFileSync(SUPERSET_CONFIG_PATH, "utf-8")); } export function writeConfig(config: SupersetConfig): void { ensureDir(); - writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { - mode: 0o600, - }); + const tempPath = join( + SUPERSET_HOME_DIR, + `.${randomUUID()}.${process.pid}.config.tmp`, + ); + writeFileSync(tempPath, JSON.stringify(config, null, 2), { mode: 0o600 }); try { - chmodSync(CONFIG_PATH, 0o600); + chmodSync(tempPath, 0o600); + } catch {} + try { + renameSync(tempPath, SUPERSET_CONFIG_PATH); + } catch (error) { + try { + unlinkSync(tempPath); + } catch {} + throw error; + } + try { + chmodSync(SUPERSET_CONFIG_PATH, 0o600); } catch {} } 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..1bb68f16b33 --- /dev/null +++ b/packages/cli/src/lib/host/spawn.test.ts @@ -0,0 +1,98 @@ +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 originalFetch = globalThis.fetch; +const originalSupersetHomeDir = process.env.SUPERSET_HOME_DIR; +const originalHostBin = process.env.SUPERSET_HOST_BIN; +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; +writeFileSync(hostBin, ""); + +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) => { + spawnCalls.push({ command, args, options }); + return { + pid: 12345, + kill: mock(() => undefined), + unref: mock(() => undefined), + }; + }, +); + +mock.module("node:child_process", () => ({ + spawn: spawnMock, +})); + +const { SUPERSET_CONFIG_PATH } = await import("../config"); +const { spawnHostService } = await import("./spawn"); + +function createApi(): ApiClient { + return { + analytics: { + featureFlagPayload: { + query: async () => null, + }, + }, + } as unknown as ApiClient; +} + +afterEach(() => { + spawnCalls.length = 0; + spawnMock.mockClear(); + globalThis.fetch = originalFetch; +}); + +afterAll(() => { + 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", () => { + test("passes SUPERSET_AUTH_CONFIG_PATH when provided", async () => { + 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, + }); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnCalls[0]?.options.env?.SUPERSET_AUTH_CONFIG_PATH).toBe( + SUPERSET_CONFIG_PATH, + ); + expect(spawnCalls[0]?.options.env?.AUTH_TOKEN).toBe("session-token"); + }); +}); diff --git a/packages/cli/src/lib/host/spawn.ts b/packages/cli/src/lib/host/spawn.ts index 60dc4cce076..ccccde29932 100644 --- a/packages/cli/src/lib/host/spawn.ts +++ b/packages/cli/src/lib/host/spawn.ts @@ -18,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; @@ -110,6 +111,9 @@ export async function spawnHostService( ...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), diff --git a/packages/host-service/src/env.test.ts b/packages/host-service/src/env.test.ts new file mode 100644 index 00000000000..7bc717d1373 --- /dev/null +++ b/packages/host-service/src/env.test.ts @@ -0,0 +1,40 @@ +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"); + +afterAll(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +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/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() 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 new file mode 100644 index 00000000000..ca8cbb7fb0e --- /dev/null +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts @@ -0,0 +1,378 @@ +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; + }; + 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 }; +} + +function readConfig(configPath: string): SupersetTestConfig { + return JSON.parse(readFileSync(configPath, "utf-8")) as SupersetTestConfig; +} + +function writeConfig(configPath: string, config: SupersetTestConfig): void { + writeFileSync(configPath, JSON.stringify(config, null, 2)); +} + +function createConfigBackedProvider(configPath: string): JwtApiAuthProvider { + const tokenSource = new ConfigFileSessionTokenSource({ + configPath, + apiUrl: API_URL, + }); + return new JwtApiAuthProvider({ + getSessionToken: () => tokenSource.getSessionToken(), + onInvalidateCache: () => tokenSource.invalidateCache(), + apiUrl: API_URL, + }); +} + +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(() => { + globalThis.fetch = originalFetch; +}); + +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 { + await expect( + createConfigBackedProvider(configPath).getHeaders(), + ).resolves.toEqual({ + Authorization: "Bearer stored.jwt.token", + }); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("refreshes from config after 401 invalidation and persists rotated auth", async () => { + const { dir, configPath } = createConfigFile({ + auth: { + accessToken: "stale.jwt.token", + refreshToken: "old-refresh-token", + expiresAt: Date.now() - 1000, + }, + organizationId: ORGANIZATION_ID, + }); + const fetchMock = mockFetch(async () => + Response.json({ + access_token: "refreshed.jwt.token", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }), + ); + const authProvider = createConfigBackedProvider(configPath); + + try { + await expect(authProvider.getHeaders()).resolves.toEqual({ + Authorization: "Bearer stale.jwt.token", + }); + + authProvider.invalidateCache(); + + 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 }); + } + }); + + test("a cloud 401 retries once with a refreshed config token", async () => { + const { dir, configPath } = createConfigFile({ + auth: { + accessToken: "stale.jwt.token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 1000, + }, + }); + 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, + ); + + 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 }); + } + }); + + test("concurrent retry callers perform one refresh", async () => { + const { dir, configPath } = createConfigFile({ + auth: { + accessToken: "stale.jwt.token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 1000, + }, + }); + 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); + + try { + authProvider.invalidateCache(); + const resultsPromise = Promise.all([ + authProvider.getHeaders(), + authProvider.getHeaders(), + authProvider.getHeaders(), + ]); + await Promise.resolve(); + releaseRefresh?.(); + + 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 }); + } + }); + + test("missing refresh token fails with login guidance after invalidation", async () => { + const { dir, configPath } = createConfigFile({ + auth: { + accessToken: "stale.jwt.token", + expiresAt: Date.now() - 1000, + }, + }); + 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 }); + } + }); + + test("failed refresh errors are sanitized", async () => { + const { dir, configPath } = createConfigFile({ + auth: { + accessToken: "stale.jwt.token", + refreshToken: "refresh-secret", + expiresAt: Date.now() - 1000, + }, + }); + mockFetch( + async () => + new Response( + JSON.stringify({ + access_token: "access-secret", + refresh_token: "refresh-secret", + redirect: "https://app.superset.test/callback?code=code-secret", + cookie: "session=session-secret", + }), + { status: 400 }, + ), + ); + const authProvider = createConfigBackedProvider(configPath); + + try { + authProvider.invalidateCache(); + let thrown: unknown; + try { + await authProvider.getHeaders(); + } catch (error) { + thrown = error; + } + + 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 { + rmSync(dir, { recursive: true, force: true }); + } + }); + + 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: "external.jwt.token", + refreshToken: "external-refresh-token", + 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); + + try { + authProvider.invalidateCache(); + await expect(authProvider.getHeaders()).resolves.toEqual({ + Authorization: "Bearer external.jwt.token", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(readConfig(configPath).auth).toMatchObject({ + accessToken: "external.jwt.token", + refreshToken: "external-refresh-token", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("static AUTH_TOKEN behavior is unchanged without a config source", async () => { + const fetchMock = mockFetch(async () => { + throw new Error("unexpected fetch"); + }); + const authProvider = new JwtApiAuthProvider({ + getSessionToken: async () => "static.jwt.token", + apiUrl: API_URL, + }); + + await expect(authProvider.getHeaders()).resolves.toEqual({ + Authorization: "Bearer static.jwt.token", + }); + 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 f2119bab5f3..fb3e6143df1 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts @@ -15,17 +15,20 @@ export interface JwtApiAuthProviderOptions { * (re-login, refresh) are picked up without restarting the host-service. */ getSessionToken: () => Promise; + onInvalidateCache?: () => void; apiUrl: string; } export class JwtApiAuthProvider implements ApiAuthProvider { private readonly getSessionToken: () => Promise; + private readonly onInvalidateCache?: () => void; private readonly apiUrl: string; private cachedJwt: string | null = null; private cachedJwtExpiresAt = 0; constructor(options: JwtApiAuthProviderOptions) { this.getSessionToken = options.getSessionToken; + this.onInvalidateCache = options.onInvalidateCache; this.apiUrl = options.apiUrl; } @@ -37,6 +40,7 @@ export class JwtApiAuthProvider implements ApiAuthProvider { invalidateCache(): void { this.cachedJwt = null; this.cachedJwtExpiresAt = 0; + this.onInvalidateCache?.(); } async getJwt(): Promise { diff --git a/packages/host-service/src/providers/auth/index.ts b/packages/host-service/src/providers/auth/index.ts index 3f509288c27..73ba8f1e97c 100644 --- a/packages/host-service/src/providers/auth/index.ts +++ b/packages/host-service/src/providers/auth/index.ts @@ -1,3 +1,4 @@ +export { ConfigFileSessionTokenSource } from "./ConfigFileSessionTokenSource"; export { DeviceKeyApiAuthProvider } from "./DeviceKeyAuthProvider"; export { JwtApiAuthProvider } from "./JwtAuthProvider"; export type { ApiAuthProvider } from "./types"; diff --git a/packages/host-service/src/serve.ts b/packages/host-service/src/serve.ts index 760139a447d..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,8 +29,19 @@ 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, }); diff --git a/packages/host-service/src/terminal/env-strip.ts b/packages/host-service/src/terminal/env-strip.ts index 3e2c4247aca..f893de6ad80 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", @@ -41,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 14d01ea72d5..3295a58b23b 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", @@ -111,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", @@ -124,6 +128,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(); @@ -157,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)