From b8411a4e455b34ec1f3d82ee59703d67eea52b9b Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 12 May 2026 16:23:33 -0700 Subject: [PATCH 1/4] feat(cli): add --api-key option to auth login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `superset auth login --api-key sk_live_…` validates the key, stores it at `~/.superset/config.json`, and clears any prior OAuth session. Subsequent commands resolve auth from the stored key with a new `config` auth source. `auth logout` now clears the stored key alongside the OAuth session, and `auth whoami` surfaces the new source. --- apps/docs/content/docs/cli/cli-reference.mdx | 21 +- .../docs/content/docs/cli/getting-started.mdx | 10 +- packages/cli/CLI_SPEC_TARGET.md | 21 +- .../cli/src/commands/auth/login/command.ts | 223 +++++++++++++----- .../cli/src/commands/auth/logout/command.ts | 1 + .../cli/src/commands/auth/whoami/command.ts | 4 +- packages/cli/src/lib/config.ts | 1 + packages/cli/src/lib/resolve-auth.test.ts | 112 +++++++++ packages/cli/src/lib/resolve-auth.ts | 38 +-- 9 files changed, 345 insertions(+), 86 deletions(-) create mode 100644 packages/cli/src/lib/resolve-auth.test.ts diff --git a/apps/docs/content/docs/cli/cli-reference.mdx b/apps/docs/content/docs/cli/cli-reference.mdx index 35e895155d1..c2c932bfbe3 100644 --- a/apps/docs/content/docs/cli/cli-reference.mdx +++ b/apps/docs/content/docs/cli/cli-reference.mdx @@ -43,6 +43,10 @@ Authentication and session inspection. flag: "--organization ", description: "Selects the active organization without prompting. Required for non-TTY logins when you belong to multiple orgs.", }, + { + flag: "--api-key ", + description: "Store a Superset API key (`sk_live_…`) at `~/.superset/config.json` instead of running the OAuth flow.", + }, ]} > Authenticate via browser OAuth and store a session token at @@ -57,6 +61,16 @@ superset auth login superset auth login --organization acme ``` +To sign in with an API key — useful for CI or any environment where the +OAuth browser flow isn't an option — pass `--api-key`. The CLI validates +the key, stores it at `~/.superset/config.json`, and clears any prior +OAuth session. + +```bash +superset auth login --api-key sk_live_… +superset auth login --api-key sk_live_… --organization acme +``` + The newly authenticated user and active organization. @@ -82,8 +96,9 @@ The newly authenticated user and active organization. -Clear the stored session. Does not call the API and does not clear the -active organization (your preferred org persists across re-logins). +Clear stored credentials — both an OAuth session and a stored API key. +Does not call the API and does not clear the active organization (your +preferred org persists across re-logins). ```bash superset auth logout @@ -132,7 +147,7 @@ Auth: Session (expires in 32 min) name: string; organizationId: string; organizationName: string; - authSource: "flag" | "env" | "oauth"; + authSource: "flag" | "env" | "oauth" | "config"; } ``` diff --git a/apps/docs/content/docs/cli/getting-started.mdx b/apps/docs/content/docs/cli/getting-started.mdx index 600c9d2e379..17fb5e0fd5b 100644 --- a/apps/docs/content/docs/cli/getting-started.mdx +++ b/apps/docs/content/docs/cli/getting-started.mdx @@ -60,7 +60,15 @@ superset auth login --organization acme ``` For CI or other non-interactive environments — where there's no browser to -complete the OAuth flow — use an API key instead: +complete the OAuth flow — use an API key instead. You can either store it +once with `auth login --api-key`: + +```bash +superset auth login --api-key sk_live_… +superset auth whoami +``` + +…or set `SUPERSET_API_KEY` per-invocation without writing anything to disk: ```bash export SUPERSET_API_KEY=sk_live_… diff --git a/packages/cli/CLI_SPEC_TARGET.md b/packages/cli/CLI_SPEC_TARGET.md index f0da789f9f7..248a751e68d 100644 --- a/packages/cli/CLI_SPEC_TARGET.md +++ b/packages/cli/CLI_SPEC_TARGET.md @@ -213,8 +213,9 @@ Authenticate via browser OAuth and store a session token locally. | Option | Required | Description | | --- | --- | --- | | `--organization ` | When stdout is non-TTY and the user belongs to multiple orgs | Selects the active organization without prompting. Optional but supported when stdout is a TTY (skips the picker). | +| `--api-key ` | No | Store a Superset API key (`sk_live_…`) at `~/.superset/config.json` instead of running the OAuth flow. Validates via `user.me` before writing. Mutually exclusive with the OAuth flow — passing this clears any stored `auth` session. | -Flow: +Flow (OAuth): 1. Loopback callback server on `127.0.0.1:51789` or `51790`. 2. Opens `${WEB_URL}/cli/authorize?...`. `WEB_URL` is a build-time constant @@ -222,9 +223,18 @@ Flow: 3. Web posts to `/api/cli/create-code`. 4. CLI receives the code on the loopback callback (5-minute timeout). 5. CLI exchanges via `/api/cli/exchange`. -6. CLI stores `auth.accessToken` and `auth.expiresAt`. +6. CLI stores `auth.accessToken` and `auth.expiresAt`, clears `apiKey`. 7. CLI calls `user.me`, `user.myOrganizations`. +Flow (`--api-key`): + +1. CLI validates the supplied key by calling `user.me` with the key in the + `x-api-key` header. +2. On success, CLI writes `apiKey` to `~/.superset/config.json` and + deletes any stored OAuth `auth`. On failure, CLI exits 1 without + writing. +3. CLI continues with the same org-selection rules as the OAuth flow. + Org selection rules: - Single organization → selected automatically. @@ -248,8 +258,9 @@ Side effects: writes `~/.superset/config.json` with `auth` and ### `superset auth logout` -Clear `auth` from `~/.superset/config.json`. Does not call the API. Does not -clear `organizationId` — the user's preferred org persists across re-logins. +Clear `auth` and `apiKey` from `~/.superset/config.json`. Does not call +the API. Does not clear `organizationId` — the user's preferred org +persists across re-logins. Output: @@ -272,7 +283,7 @@ Output: name: string; organizationId: string; organizationName: string; - authSource: "flag" | "env" | "oauth"; + authSource: "flag" | "env" | "oauth" | "config"; } ``` diff --git a/packages/cli/src/commands/auth/login/command.ts b/packages/cli/src/commands/auth/login/command.ts index 8d8cb788e97..7bd5d2e0834 100644 --- a/packages/cli/src/commands/auth/login/command.ts +++ b/packages/cli/src/commands/auth/login/command.ts @@ -2,13 +2,148 @@ import * as p from "@clack/prompts"; import { CLIError, string } from "@superset/cli-framework"; import { render } from "ink"; import { createElement } from "react"; -import { createApiClient } from "../../../lib/api-client"; +import { type ApiClient, createApiClient } from "../../../lib/api-client"; import { login } from "../../../lib/auth"; import { command } from "../../../lib/command"; -import { readConfig, writeConfig } from "../../../lib/config"; +import { + readConfig, + type SupersetConfig, + writeConfig, +} from "../../../lib/config"; import { copyToClipboard } from "./copyToClipboard"; import { LoginUI, type LoginUIProps } from "./LoginUI"; +type LoginOutput = + | { loggedIn: false } + | { loggedIn: true } + | { + userId: string; + organizationId: string; + organizationName: string; + }; + +function apiKeyFlagInArgv(): boolean { + return process.argv.some( + (arg) => arg === "--api-key" || arg.startsWith("--api-key="), + ); +} + +async function selectOrganization({ + api, + config, + requested, +}: { + api: ApiClient; + config: SupersetConfig; + requested: string | undefined; +}): Promise<{ + output: LoginOutput; + cancelled: boolean; +}> { + const organizations = await api.user.myOrganizations.query(); + const sessionActive = await api.user.myOrganization.query(); + + const explicitChoice = requested + ? organizations.find( + (org) => org.id === requested || org.slug === requested, + ) + : undefined; + + if (requested && !explicitChoice) { + throw new CLIError( + `Organization not found: ${requested}`, + `Available: ${organizations.map((o) => o.slug).join(", ")}`, + ); + } + + let chosen = explicitChoice ?? sessionActive ?? null; + + if (!chosen) { + if (organizations.length === 1) { + chosen = organizations[0] ?? null; + } else if (organizations.length > 1) { + if (!process.stdout.isTTY) { + throw new CLIError( + "Multiple organizations available; pass --organization ", + `Available: ${organizations.map((o) => o.slug).join(", ")}`, + ); + } + const selection = await p.select({ + message: "Select organization for this CLI", + initialValue: organizations[0]?.id, + options: organizations.map((organization) => ({ + value: organization.id, + label: `${organization.name} (${organization.slug})`, + })), + }); + if (p.isCancel(selection)) { + p.cancel("Login cancelled"); + return { output: { loggedIn: true }, cancelled: true }; + } + chosen = organizations.find((o) => o.id === selection) ?? null; + } + } + + if (chosen) { + config.organizationId = chosen.id; + writeConfig(config); + p.log.info(`Organization: ${chosen.name}`); + const me = await api.user.me.query(); + return { + output: { + userId: me.id, + organizationId: chosen.id, + organizationName: chosen.name, + }, + cancelled: false, + }; + } + + return { output: { loggedIn: true }, cancelled: false }; +} + +async function runApiKeyLogin({ + apiKey, + requestedOrganization, +}: { + apiKey: string; + requestedOrganization: string | undefined; +}): Promise { + p.intro("superset auth login"); + + const api = createApiClient({ bearer: apiKey }); + + let user: Awaited>; + try { + user = await api.user.me.query(); + } catch (error) { + throw new CLIError( + "API key rejected", + error instanceof Error ? error.message : String(error), + ); + } + + const config = readConfig(); + config.apiKey = apiKey; + delete config.auth; + writeConfig(config); + + p.log.success("API key stored."); + p.log.info(`${user.name} (${user.email})`); + + const { output, cancelled } = await selectOrganization({ + api, + config, + requested: requestedOrganization, + }); + + if (cancelled) { + return output; + } + p.outro("Logged in successfully."); + return output; +} + export default command({ description: "Authenticate with Superset. Re-run to switch organizations.", skipMiddleware: true, @@ -16,8 +151,24 @@ export default command({ organization: string().desc( "Organization id or slug — required for non-TTY logins when you belong to multiple orgs", ), + apiKey: string().desc( + "Store a Superset API key (sk_live_…) at ~/.superset/config.json instead of running the OAuth flow", + ), }, run: async (opts) => { + const requestedOrganization = opts.options.organization; + const apiKeyFromCli = apiKeyFlagInArgv() + ? opts.options.apiKey?.trim() + : undefined; + + if (apiKeyFromCli) { + const data = await runApiKeyLogin({ + apiKey: apiKeyFromCli, + requestedOrganization, + }); + return { data }; + } + const config = readConfig(); const useInk = process.stdout.isTTY && process.stdin.isTTY && !process.env.CI; @@ -115,6 +266,7 @@ export default command({ refreshToken: result.refreshToken, expiresAt: result.expiresAt, }; + delete config.apiKey; writeConfig(config); p.log.success("Authorized!"); @@ -130,67 +282,16 @@ export default command({ ); } - const organizations = await api.user.myOrganizations.query(); - const sessionActive = await api.user.myOrganization.query(); - - const explicitChoice = opts.options.organization - ? organizations.find( - (org) => - org.id === opts.options.organization || - org.slug === opts.options.organization, - ) - : undefined; - - if (opts.options.organization && !explicitChoice) { - throw new CLIError( - `Organization not found: ${opts.options.organization}`, - `Available: ${organizations.map((o) => o.slug).join(", ")}`, - ); - } - - let chosen = explicitChoice ?? sessionActive ?? null; - - if (!chosen) { - if (organizations.length === 1) { - chosen = organizations[0] ?? null; - } else if (organizations.length > 1) { - if (!process.stdout.isTTY) { - throw new CLIError( - "Multiple organizations available; pass --organization ", - `Available: ${organizations.map((o) => o.slug).join(", ")}`, - ); - } - const selection = await p.select({ - message: "Select organization for this CLI", - initialValue: organizations[0]?.id, - options: organizations.map((organization) => ({ - value: organization.id, - label: `${organization.name} (${organization.slug})`, - })), - }); - if (p.isCancel(selection)) { - p.cancel("Login cancelled"); - return { data: { loggedIn: true } }; - } - chosen = organizations.find((o) => o.id === selection) ?? null; - } - } + const { output, cancelled: orgCancelled } = await selectOrganization({ + api, + config, + requested: requestedOrganization, + }); - if (chosen) { - config.organizationId = chosen.id; - writeConfig(config); - p.log.info(`Organization: ${chosen.name}`); + if (orgCancelled) { + return { data: output }; } - p.outro("Logged in successfully."); - return { - data: chosen - ? { - userId: (await api.user.me.query()).id, - organizationId: chosen.id, - organizationName: chosen.name, - } - : { loggedIn: true }, - }; + return { data: output }; }, }); diff --git a/packages/cli/src/commands/auth/logout/command.ts b/packages/cli/src/commands/auth/logout/command.ts index 4d80eb620b0..d103ff4dc3e 100644 --- a/packages/cli/src/commands/auth/logout/command.ts +++ b/packages/cli/src/commands/auth/logout/command.ts @@ -7,6 +7,7 @@ export default command({ run: async () => { const config = readConfig(); delete config.auth; + delete config.apiKey; writeConfig(config); return { message: "Logged out." }; }, diff --git a/packages/cli/src/commands/auth/whoami/command.ts b/packages/cli/src/commands/auth/whoami/command.ts index 82df6b129e4..1012ef0e1eb 100644 --- a/packages/cli/src/commands/auth/whoami/command.ts +++ b/packages/cli/src/commands/auth/whoami/command.ts @@ -13,8 +13,10 @@ export default command({ authLine = "Session"; } else if (ctx.authSource === "flag") { authLine = "API key (from --api-key flag)"; - } else { + } else if (ctx.authSource === "env") { authLine = "API key (from SUPERSET_API_KEY env)"; + } else { + authLine = "API key (stored via auth login --api-key)"; } return { diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 1bf9536bc53..c2cf2cd42c9 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -16,6 +16,7 @@ export type SupersetConfig = { refreshToken?: string; expiresAt: number; }; + apiKey?: string; organizationId?: string; }; diff --git a/packages/cli/src/lib/resolve-auth.test.ts b/packages/cli/src/lib/resolve-auth.test.ts new file mode 100644 index 00000000000..08b7ee8a7e1 --- /dev/null +++ b/packages/cli/src/lib/resolve-auth.test.ts @@ -0,0 +1,112 @@ +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 tempHome = fs.mkdtempSync( + path.join(os.tmpdir(), "superset-cli-resolve-auth-"), +); +process.env.SUPERSET_HOME_DIR = tempHome; + +const { resolveAuth } = await import("./resolve-auth"); +const { writeConfig } = await import("./config"); + +const originalArgv = process.argv; + +function setArgv(args: string[]): void { + process.argv = ["bun", "superset", ...args]; +} + +function clearConfig(): void { + writeConfig({}); +} + +afterEach(() => { + process.argv = originalArgv; + clearConfig(); +}); + +afterAll(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +describe("resolveAuth", () => { + it("throws when no override and no stored credentials", async () => { + setArgv(["status"]); + await expect(resolveAuth(undefined)).rejects.toThrow(/Not logged in/); + }); + + it("uses an explicit --api-key flag with 'flag' source", async () => { + setArgv(["status", "--api-key", "sk_live_flag"]); + const result = await resolveAuth("sk_live_flag"); + expect(result.bearer).toBe("sk_live_flag"); + expect(result.authSource).toBe("flag"); + }); + + it("uses an env-supplied api key with 'env' source", async () => { + setArgv(["status"]); + const result = await resolveAuth("sk_live_envvar"); + expect(result.bearer).toBe("sk_live_envvar"); + expect(result.authSource).toBe("env"); + }); + + it("uses a stored apiKey from config with 'config' source", async () => { + setArgv(["status"]); + 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.config.organizationId).toBe("org_1"); + }); + + it("uses a stored OAuth session when present and unexpired", async () => { + setArgv(["status"]); + const future = Date.now() + 60 * 60 * 1000; + writeConfig({ + auth: { + accessToken: "oauth-token", + refreshToken: "oauth-refresh", + expiresAt: future, + }, + }); + const result = await resolveAuth(undefined); + expect(result.bearer).toBe("oauth-token"); + expect(result.authSource).toBe("oauth"); + }); + + it("throws when OAuth session is expired and there is no refresh token", async () => { + setArgv(["status"]); + writeConfig({ + auth: { accessToken: "stale", expiresAt: Date.now() - 1000 }, + }); + await expect(resolveAuth(undefined)).rejects.toThrow(/Session expired/); + }); + + it("prefers an explicit override over a stored apiKey", async () => { + setArgv(["status", "--api-key", "sk_live_flag"]); + writeConfig({ apiKey: "sk_live_stored" }); + const result = await resolveAuth("sk_live_flag"); + expect(result.bearer).toBe("sk_live_flag"); + expect(result.authSource).toBe("flag"); + }); + + it("prefers a stored apiKey over a stored OAuth session", async () => { + setArgv(["status"]); + 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"); + }); + + it("treats --api-key=value form as flag, not env", async () => { + setArgv(["status", "--api-key=sk_live_eq"]); + const result = await resolveAuth("sk_live_eq"); + expect(result.authSource).toBe("flag"); + }); +}); diff --git a/packages/cli/src/lib/resolve-auth.ts b/packages/cli/src/lib/resolve-auth.ts index e98c31b41f8..d148f41bcc8 100644 --- a/packages/cli/src/lib/resolve-auth.ts +++ b/packages/cli/src/lib/resolve-auth.ts @@ -3,7 +3,7 @@ import { type ApiClient, createApiClient } from "./api-client"; import { refreshAccessToken } from "./auth"; import { readConfig, type SupersetConfig, writeConfig } from "./config"; -export type AuthSource = "flag" | "env" | "oauth"; +export type AuthSource = "flag" | "env" | "oauth" | "config"; export type ResolvedAuth = { config: SupersetConfig; @@ -14,26 +14,28 @@ export type ResolvedAuth = { const REFRESH_LEEWAY_MS = 5 * 60 * 1000; +function flagApiKeyFromArgv(): boolean { + return process.argv.some( + (arg) => arg === "--api-key" || arg.startsWith("--api-key="), + ); +} + export async function resolveAuth( apiKeyOption: string | undefined, ): Promise { let config = readConfig(); - let bearer = apiKeyOption?.trim(); - let authSource: AuthSource = bearer ? "flag" : "oauth"; - - if (bearer && !process.argv.some((arg) => arg.startsWith("--api-key"))) { - authSource = "env"; - } - - if (!bearer) { - if (!config.auth) { - throw new CLIError( - "Not logged in", - "Run: superset auth login (or set SUPERSET_API_KEY)", - ); - } + const overrideKey = apiKeyOption?.trim(); + let bearer: string | undefined; + let authSource: AuthSource; + if (overrideKey) { + bearer = overrideKey; + authSource = flagApiKeyFromArgv() ? "flag" : "env"; + } else if (config.apiKey) { + bearer = config.apiKey; + authSource = "config"; + } else if (config.auth) { const auth = config.auth; if (auth.expiresAt - REFRESH_LEEWAY_MS < Date.now()) { if (!auth.refreshToken) { @@ -57,6 +59,12 @@ export async function resolveAuth( } else { bearer = auth.accessToken; } + authSource = "oauth"; + } else { + throw new CLIError( + "Not logged in", + "Run: superset auth login (or set SUPERSET_API_KEY)", + ); } const api = createApiClient({ From 836a608844df86cd114b8beded0127f5d8133295 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 12 May 2026 16:41:34 -0700 Subject: [PATCH 2/4] refactor(cli): collapse flag/env auth sources into 'override' `--api-key` and `SUPERSET_API_KEY` are resolved at the same step already; tracking them as separate `authSource` values added a process.argv sniff in resolveAuth purely to label the same key differently in whoami. Collapse to one value. --- apps/docs/content/docs/cli/cli-reference.mdx | 2 +- packages/cli/CLI_SPEC_TARGET.md | 2 +- .../cli/src/commands/auth/whoami/command.ts | 6 +-- packages/cli/src/lib/resolve-auth.test.ts | 43 ++++--------------- packages/cli/src/lib/resolve-auth.ts | 10 +---- 5 files changed, 14 insertions(+), 49 deletions(-) diff --git a/apps/docs/content/docs/cli/cli-reference.mdx b/apps/docs/content/docs/cli/cli-reference.mdx index c2c932bfbe3..d5cd2200ffd 100644 --- a/apps/docs/content/docs/cli/cli-reference.mdx +++ b/apps/docs/content/docs/cli/cli-reference.mdx @@ -147,7 +147,7 @@ Auth: Session (expires in 32 min) name: string; organizationId: string; organizationName: string; - authSource: "flag" | "env" | "oauth" | "config"; + authSource: "override" | "config" | "oauth"; } ``` diff --git a/packages/cli/CLI_SPEC_TARGET.md b/packages/cli/CLI_SPEC_TARGET.md index 248a751e68d..62f3555e4b1 100644 --- a/packages/cli/CLI_SPEC_TARGET.md +++ b/packages/cli/CLI_SPEC_TARGET.md @@ -283,7 +283,7 @@ Output: name: string; organizationId: string; organizationName: string; - authSource: "flag" | "env" | "oauth" | "config"; + authSource: "override" | "config" | "oauth"; } ``` diff --git a/packages/cli/src/commands/auth/whoami/command.ts b/packages/cli/src/commands/auth/whoami/command.ts index 1012ef0e1eb..1405444470e 100644 --- a/packages/cli/src/commands/auth/whoami/command.ts +++ b/packages/cli/src/commands/auth/whoami/command.ts @@ -11,10 +11,8 @@ export default command({ let authLine: string; if (ctx.authSource === "oauth") { authLine = "Session"; - } else if (ctx.authSource === "flag") { - authLine = "API key (from --api-key flag)"; - } else if (ctx.authSource === "env") { - authLine = "API key (from SUPERSET_API_KEY env)"; + } else if (ctx.authSource === "override") { + authLine = "API key (from --api-key flag or SUPERSET_API_KEY env)"; } else { authLine = "API key (stored via auth login --api-key)"; } diff --git a/packages/cli/src/lib/resolve-auth.test.ts b/packages/cli/src/lib/resolve-auth.test.ts index 08b7ee8a7e1..ead54159193 100644 --- a/packages/cli/src/lib/resolve-auth.test.ts +++ b/packages/cli/src/lib/resolve-auth.test.ts @@ -11,18 +11,11 @@ process.env.SUPERSET_HOME_DIR = tempHome; const { resolveAuth } = await import("./resolve-auth"); const { writeConfig } = await import("./config"); -const originalArgv = process.argv; - -function setArgv(args: string[]): void { - process.argv = ["bun", "superset", ...args]; -} - function clearConfig(): void { writeConfig({}); } afterEach(() => { - process.argv = originalArgv; clearConfig(); }); @@ -32,26 +25,16 @@ afterAll(() => { describe("resolveAuth", () => { it("throws when no override and no stored credentials", async () => { - setArgv(["status"]); await expect(resolveAuth(undefined)).rejects.toThrow(/Not logged in/); }); - it("uses an explicit --api-key flag with 'flag' source", async () => { - setArgv(["status", "--api-key", "sk_live_flag"]); - const result = await resolveAuth("sk_live_flag"); - expect(result.bearer).toBe("sk_live_flag"); - expect(result.authSource).toBe("flag"); - }); - - it("uses an env-supplied api key with 'env' source", async () => { - setArgv(["status"]); - const result = await resolveAuth("sk_live_envvar"); - expect(result.bearer).toBe("sk_live_envvar"); - expect(result.authSource).toBe("env"); + 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", async () => { - setArgv(["status"]); writeConfig({ apiKey: "sk_live_stored", organizationId: "org_1" }); const result = await resolveAuth(undefined); expect(result.bearer).toBe("sk_live_stored"); @@ -60,7 +43,6 @@ describe("resolveAuth", () => { }); it("uses a stored OAuth session when present and unexpired", async () => { - setArgv(["status"]); const future = Date.now() + 60 * 60 * 1000; writeConfig({ auth: { @@ -75,23 +57,20 @@ describe("resolveAuth", () => { }); it("throws when OAuth session is expired and there is no refresh token", async () => { - setArgv(["status"]); writeConfig({ auth: { accessToken: "stale", expiresAt: Date.now() - 1000 }, }); await expect(resolveAuth(undefined)).rejects.toThrow(/Session expired/); }); - it("prefers an explicit override over a stored apiKey", async () => { - setArgv(["status", "--api-key", "sk_live_flag"]); + it("prefers an override over a stored apiKey", async () => { writeConfig({ apiKey: "sk_live_stored" }); - const result = await resolveAuth("sk_live_flag"); - expect(result.bearer).toBe("sk_live_flag"); - expect(result.authSource).toBe("flag"); + 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", async () => { - setArgv(["status"]); writeConfig({ apiKey: "sk_live_stored", auth: { @@ -103,10 +82,4 @@ describe("resolveAuth", () => { expect(result.bearer).toBe("sk_live_stored"); expect(result.authSource).toBe("config"); }); - - it("treats --api-key=value form as flag, not env", async () => { - setArgv(["status", "--api-key=sk_live_eq"]); - const result = await resolveAuth("sk_live_eq"); - expect(result.authSource).toBe("flag"); - }); }); diff --git a/packages/cli/src/lib/resolve-auth.ts b/packages/cli/src/lib/resolve-auth.ts index d148f41bcc8..e4b66dd62dd 100644 --- a/packages/cli/src/lib/resolve-auth.ts +++ b/packages/cli/src/lib/resolve-auth.ts @@ -3,7 +3,7 @@ import { type ApiClient, createApiClient } from "./api-client"; import { refreshAccessToken } from "./auth"; import { readConfig, type SupersetConfig, writeConfig } from "./config"; -export type AuthSource = "flag" | "env" | "oauth" | "config"; +export type AuthSource = "override" | "config" | "oauth"; export type ResolvedAuth = { config: SupersetConfig; @@ -14,12 +14,6 @@ export type ResolvedAuth = { const REFRESH_LEEWAY_MS = 5 * 60 * 1000; -function flagApiKeyFromArgv(): boolean { - return process.argv.some( - (arg) => arg === "--api-key" || arg.startsWith("--api-key="), - ); -} - export async function resolveAuth( apiKeyOption: string | undefined, ): Promise { @@ -31,7 +25,7 @@ export async function resolveAuth( if (overrideKey) { bearer = overrideKey; - authSource = flagApiKeyFromArgv() ? "flag" : "env"; + authSource = "override"; } else if (config.apiKey) { bearer = config.apiKey; authSource = "config"; From 1047ef8e9428b37728deedb2fcfa9d73ceb9e1bd Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 12 May 2026 17:12:05 -0700 Subject: [PATCH 3/4] fix(cli): address PR review on auth login - Defer the apiKey write until pickOrganization() validates the requested org, so `--api-key X --organization bad-slug` no longer clobbers the user's OAuth session before throwing. - Reuse the user fetched for validation so we don't call user.me twice on each login. - Fail fast on `--api-key=` (empty/whitespace) instead of silently falling back to OAuth. - Restore SUPERSET_HOME_DIR after resolve-auth tests so the env doesn't leak between test files. - Update CLI_SPEC_TARGET.md side-effects to describe both write paths. --- packages/cli/CLI_SPEC_TARGET.md | 6 +- .../cli/src/commands/auth/login/command.ts | 112 +++++++++++------- packages/cli/src/lib/resolve-auth.test.ts | 6 + 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/packages/cli/CLI_SPEC_TARGET.md b/packages/cli/CLI_SPEC_TARGET.md index 62f3555e4b1..1d8fc030aec 100644 --- a/packages/cli/CLI_SPEC_TARGET.md +++ b/packages/cli/CLI_SPEC_TARGET.md @@ -253,8 +253,10 @@ Output: } ``` -Side effects: writes `~/.superset/config.json` with `auth` and -`organizationId`. Spinner is guarded by `process.stdout.isTTY`. +Side effects: writes `~/.superset/config.json`. The OAuth flow writes +`auth` (and clears any stored `apiKey`); the `--api-key` flow writes +`apiKey` (and clears any stored `auth`). Both write `organizationId` +when an org is selected. Spinner is guarded by `process.stdout.isTTY`. ### `superset auth logout` diff --git a/packages/cli/src/commands/auth/login/command.ts b/packages/cli/src/commands/auth/login/command.ts index 7bd5d2e0834..1446f5532f8 100644 --- a/packages/cli/src/commands/auth/login/command.ts +++ b/packages/cli/src/commands/auth/login/command.ts @@ -5,11 +5,7 @@ import { createElement } from "react"; import { type ApiClient, createApiClient } from "../../../lib/api-client"; import { login } from "../../../lib/auth"; import { command } from "../../../lib/command"; -import { - readConfig, - type SupersetConfig, - writeConfig, -} from "../../../lib/config"; +import { readConfig, writeConfig } from "../../../lib/config"; import { copyToClipboard } from "./copyToClipboard"; import { LoginUI, type LoginUIProps } from "./LoginUI"; @@ -22,24 +18,27 @@ type LoginOutput = organizationName: string; }; +type ChosenOrganization = { id: string; name: string }; + function apiKeyFlagInArgv(): boolean { return process.argv.some( (arg) => arg === "--api-key" || arg.startsWith("--api-key="), ); } -async function selectOrganization({ +/** + * Decides which organization to activate without writing to disk. Throws + * for unrecoverable validation failures (bad `--organization` slug, + * multi-org without a TTY) so the caller can bail before persisting + * partial state. + */ +async function pickOrganization({ api, - config, requested, }: { api: ApiClient; - config: SupersetConfig; requested: string | undefined; -}): Promise<{ - output: LoginOutput; - cancelled: boolean; -}> { +}): Promise<{ chosen: ChosenOrganization | null; cancelled: boolean }> { const organizations = await api.user.myOrganizations.query(); const sessionActive = await api.user.myOrganization.query(); @@ -78,28 +77,16 @@ async function selectOrganization({ }); if (p.isCancel(selection)) { p.cancel("Login cancelled"); - return { output: { loggedIn: true }, cancelled: true }; + return { chosen: null, cancelled: true }; } chosen = organizations.find((o) => o.id === selection) ?? null; } } - if (chosen) { - config.organizationId = chosen.id; - writeConfig(config); - p.log.info(`Organization: ${chosen.name}`); - const me = await api.user.me.query(); - return { - output: { - userId: me.id, - organizationId: chosen.id, - organizationName: chosen.name, - }, - cancelled: false, - }; - } - - return { output: { loggedIn: true }, cancelled: false }; + return { + chosen: chosen ? { id: chosen.id, name: chosen.name } : null, + cancelled: false, + }; } async function runApiKeyLogin({ @@ -122,26 +109,37 @@ async function runApiKeyLogin({ error instanceof Error ? error.message : String(error), ); } + p.log.info(`${user.name} (${user.email})`); + + const { chosen, cancelled } = await pickOrganization({ + api, + requested: requestedOrganization, + }); const config = readConfig(); config.apiKey = apiKey; delete config.auth; + if (chosen) { + config.organizationId = chosen.id; + } writeConfig(config); p.log.success("API key stored."); - p.log.info(`${user.name} (${user.email})`); - - const { output, cancelled } = await selectOrganization({ - api, - config, - requested: requestedOrganization, - }); if (cancelled) { - return output; + return { loggedIn: true }; + } + if (chosen) { + p.log.info(`Organization: ${chosen.name}`); + p.outro("Logged in successfully."); + return { + userId: user.id, + organizationId: chosen.id, + organizationName: chosen.name, + }; } p.outro("Logged in successfully."); - return output; + return { loggedIn: true }; } export default command({ @@ -157,10 +155,15 @@ export default command({ }, run: async (opts) => { const requestedOrganization = opts.options.organization; - const apiKeyFromCli = apiKeyFlagInArgv() + const apiKeyExplicit = apiKeyFlagInArgv(); + const apiKeyFromCli = apiKeyExplicit ? opts.options.apiKey?.trim() : undefined; + if (apiKeyExplicit && !apiKeyFromCli) { + throw new CLIError("Option --api-key requires a non-empty value"); + } + if (apiKeyFromCli) { const data = await runApiKeyLogin({ apiKey: apiKeyFromCli, @@ -169,7 +172,6 @@ export default command({ return { data }; } - const config = readConfig(); const useInk = process.stdout.isTTY && process.stdin.isTTY && !process.env.CI; @@ -261,6 +263,10 @@ export default command({ return { data: { loggedIn: false } }; } + // Persist the OAuth tokens immediately so a later org-selection + // failure (bad --organization slug, non-TTY multi-org) doesn't + // force the user to redo the browser dance to retry. + const config = readConfig(); config.auth = { accessToken: result.accessToken, refreshToken: result.refreshToken, @@ -273,8 +279,9 @@ export default command({ const api = createApiClient({ bearer: result.accessToken }); + let user: Awaited> | null = null; try { - const user = await api.user.me.query(); + user = await api.user.me.query(); p.log.info(`${user.name} (${user.email})`); } catch (error) { p.log.warn( @@ -282,16 +289,31 @@ export default command({ ); } - const { output, cancelled: orgCancelled } = await selectOrganization({ + const { chosen, cancelled: orgCancelled } = await pickOrganization({ api, - config, requested: requestedOrganization, }); + if (chosen) { + config.organizationId = chosen.id; + writeConfig(config); + p.log.info(`Organization: ${chosen.name}`); + } + if (orgCancelled) { - return { data: output }; + return { data: { loggedIn: true } }; } p.outro("Logged in successfully."); - return { data: output }; + + if (chosen && user) { + return { + data: { + userId: user.id, + organizationId: chosen.id, + organizationName: chosen.name, + }, + }; + } + return { data: { loggedIn: true } }; }, }); diff --git a/packages/cli/src/lib/resolve-auth.test.ts b/packages/cli/src/lib/resolve-auth.test.ts index ead54159193..8825d60d555 100644 --- a/packages/cli/src/lib/resolve-auth.test.ts +++ b/packages/cli/src/lib/resolve-auth.test.ts @@ -3,6 +3,7 @@ import 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-resolve-auth-"), ); @@ -21,6 +22,11 @@ afterEach(() => { 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("resolveAuth", () => { From db654954151c120d4616f1c5318347bc08f197ea Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 12 May 2026 17:24:05 -0700 Subject: [PATCH 4/4] fix(cli): trim stored apiKey defensively in resolveAuth If config.apiKey was hand-edited and ends up with surrounding whitespace, we'd send a junk key and the request would fail without falling back to OAuth. Treat whitespace-only as empty so we skip to the OAuth branch. --- packages/cli/src/lib/resolve-auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/resolve-auth.ts b/packages/cli/src/lib/resolve-auth.ts index e4b66dd62dd..d979b888bc1 100644 --- a/packages/cli/src/lib/resolve-auth.ts +++ b/packages/cli/src/lib/resolve-auth.ts @@ -26,8 +26,8 @@ export async function resolveAuth( if (overrideKey) { bearer = overrideKey; authSource = "override"; - } else if (config.apiKey) { - bearer = config.apiKey; + } else if (config.apiKey?.trim()) { + bearer = config.apiKey.trim(); authSource = "config"; } else if (config.auth) { const auth = config.auth;