diff --git a/README.md b/README.md index 93a3305..d6a394c 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,15 @@ upstash start-redis --user-agent claude-code # or cursor, codex, opencode, ... ## Authentication -Grab a Developer API key from the [Upstash Console](https://console.upstash.com/account/api), then save it once per machine: +Sign in through the browser once per machine: ```bash -upstash login +upstash login --oauth ``` -Or set `UPSTASH_EMAIL` and `UPSTASH_API_KEY` in your shell or a `.env` file. See the [auth docs](https://upstash.com/docs/agent-resources/cli#authentication) for env files, per-command flags, and precedence rules. +The consent page lets you pick a personal or team scope and whether the login is read-only. To switch teams, run it again. Team management commands (`team create`, `team delete`, `team add-member`, `team remove-member`) need an API key login. + +Or grab a Developer API key from the [Upstash Console](https://console.upstash.com/account/api) and save it with `upstash login`, or set `UPSTASH_EMAIL` and `UPSTASH_API_KEY` in your shell or a `.env` file (recommended for CI and agents). `upstash whoami` shows which credentials are in use. See the [auth docs](https://upstash.com/docs/agent-resources/cli#authentication) for env files, per-command flags, and precedence rules. ## Quick examples diff --git a/src/auth.ts b/src/auth.ts index f680b88..2f8c747 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,19 +1,25 @@ -export interface Auth { - email: string; - apiKey: string; -} +export type Auth = + | { kind: "api-key"; email: string; apiKey: string } + | { kind: "oauth" }; + +export type AuthSource = "flag" | "env" | "config"; import type { Command } from "commander"; import { readConfig } from "./config.js"; -export function resolveAuth(cmdOrFlags: Command | { email?: string; apiKey?: string }): Auth { +type AuthFlags = { email?: string; apiKey?: string }; + +export function envApiKeyAuth(): { email?: string; apiKey?: string } { + return { email: process.env.UPSTASH_EMAIL, apiKey: process.env.UPSTASH_API_KEY }; +} + +export function resolveAuthWithSource(cmdOrFlags: Command | AuthFlags): { auth: Auth; source: AuthSource } { const opts = typeof (cmdOrFlags as Command).optsWithGlobals === "function" ? (cmdOrFlags as Command).optsWithGlobals() : cmdOrFlags; - const flagEmail = (opts as { email?: string }).email; - const flagKey = (opts as { apiKey?: string }).apiKey; - const envEmail = process.env.UPSTASH_EMAIL; - const envKey = process.env.UPSTASH_API_KEY; + const flagEmail = (opts as AuthFlags).email; + const flagKey = (opts as AuthFlags).apiKey; + const { email: envEmail, apiKey: envKey } = envApiKeyAuth(); // If any flag/env auth signal is present, resolve from that tier only — // don't mix a partial session with the saved config, since that silently @@ -26,13 +32,17 @@ export function resolveAuth(cmdOrFlags: Command | { email?: string; apiKey?: str "Authentication is incomplete: provide both --email and --api-key, or set both UPSTASH_EMAIL and UPSTASH_API_KEY. Or unset them and run `upstash login` to use saved credentials." ); } - return { email, apiKey }; + return { auth: { kind: "api-key", email, apiKey }, source: flagEmail || flagKey ? "flag" : "env" }; } const stored = readConfig(); - if (stored) return stored; + if (stored) return { auth: stored, source: "config" }; throw new Error( - "Authentication required. Run `upstash login` to save credentials, or provide --email and --api-key flags, or set UPSTASH_EMAIL and UPSTASH_API_KEY environment variables (also honored from a .env file in the current directory)." + "Authentication required. Run `upstash login --oauth` to sign in through the browser or `upstash login` to save an API key, or provide --email and --api-key flags, or set UPSTASH_EMAIL and UPSTASH_API_KEY environment variables (also honored from a .env file in the current directory)." ); } + +export function resolveAuth(cmdOrFlags: Command | AuthFlags): Auth { + return resolveAuthWithSource(cmdOrFlags).auth; +} diff --git a/src/cli.ts b/src/cli.ts index 885a83a..ae474dd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { registerQStash } from "./commands/qstash/index.js"; import { registerBlob } from "./commands/blob/index.js"; import { registerLogin } from "./commands/login.js"; import { registerLogout } from "./commands/logout.js"; +import { registerWhoami } from "./commands/whoami.js"; import { registerStartRedis } from "./commands/start-redis.js"; import { registerTelemetry } from "./commands/telemetry.js"; import { handleError } from "./output.js"; @@ -42,6 +43,7 @@ program registerLogin(program); registerLogout(program); +registerWhoami(program); registerStartRedis(program); registerTelemetry(program); registerRedis(program); diff --git a/src/client.ts b/src/client.ts index 5f10354..1dc0bca 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,8 @@ import type { Auth } from "./auth.js"; import { telemetryHeaders } from "./telemetry.js"; +import { getAccessToken } from "./oauth/refresh.js"; -const BASE_URL = "https://api.upstash.com"; +const BASE_URL = (process.env.UPSTASH_API_URL ?? "https://api.upstash.com").replace(/\/$/, ""); export class HttpError extends Error { readonly status: number; @@ -11,34 +12,96 @@ export class HttpError extends Error { } } +export interface TokenInfo { + email: string; + team_id?: string; + team_role?: string; + read_only?: boolean; + blocked?: boolean; +} + +// The backend keeps team lifecycle and membership closed to OAuth tokens on purpose. +const TEAM_MANAGEMENT = [ + { method: "POST", path: /^\/v2\/team$/ }, + { method: "DELETE", path: /^\/v2\/team\/[^/]+$/ }, + { method: "POST", path: /^\/v2\/teams\/member$/ }, + { method: "DELETE", path: /^\/v2\/teams\/member$/ }, +]; + +export const TEAM_MANAGEMENT_NEEDS_API_KEY = + "Team management needs an API key login: run `upstash login` with an API key from https://console.upstash.com/account/api, or pass --email and --api-key."; + +export const READ_ONLY_LOGIN = + "This login is read-only, so write commands are refused. Run `upstash login --oauth` again and turn read-only off on the consent page."; + +function parseErrorMessage(text: string, status: number): string { + let message = text || `HTTP ${status}`; + try { + const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }; + const msg = parsed.error ?? parsed.message; + if (typeof msg === "string" && msg.length > 0) message = msg; + } catch { + // fall through with the raw text + } + return message; +} + +async function authorizationHeader(auth: Auth, force = false): Promise { + if (auth.kind === "api-key") { + return `Basic ${Buffer.from(`${auth.email}:${auth.apiKey}`).toString("base64")}`; + } + return `Bearer ${await getAccessToken({ force })}`; +} + +export async function fetchTokenInfo(accessToken: string): Promise { + const response = await fetch(`${BASE_URL}/v2/account/oauth/token-info`, { + headers: { Authorization: `Bearer ${accessToken}`, ...telemetryHeaders() }, + }); + const text = await response.text(); + if (!response.ok) throw new HttpError(parseErrorMessage(text, response.status), response.status); + return JSON.parse(text) as TokenInfo; +} + +async function explainForbidden(auth: Auth, method: string, path: string, fallback: string): Promise { + if (auth.kind !== "oauth") return fallback; + if (TEAM_MANAGEMENT.some((r) => r.method === method && r.path.test(path))) return TEAM_MANAGEMENT_NEEDS_API_KEY; + if (method === "GET" || method === "HEAD") return fallback; + try { + const info = await fetchTokenInfo(await getAccessToken()); + if (info.read_only) return READ_ONLY_LOGIN; + } catch { + // The original 403 is still the best explanation. + } + return fallback; +} + export async function request( auth: Auth, method: string, path: string, body?: unknown, ): Promise { - const credentials = Buffer.from(`${auth.email}:${auth.apiKey}`).toString("base64"); - const response = await fetch(`${BASE_URL}${path}`, { - method, - headers: { - Authorization: `Basic ${credentials}`, - "Content-Type": "application/json", - ...telemetryHeaders(), - }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); + const send = async (authorization: string) => + fetch(`${BASE_URL}${path}`, { + method, + headers: { + Authorization: authorization, + "Content-Type": "application/json", + ...telemetryHeaders(), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + let response = await send(await authorizationHeader(auth)); + if (response.status === 401 && auth.kind === "oauth") { + response = await send(await authorizationHeader(auth, true)); + } const text = await response.text(); if (!response.ok) { - let message = text || `HTTP ${response.status}`; - try { - const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }; - const msg = parsed.error ?? parsed.message; - if (typeof msg === "string" && msg.length > 0) message = msg; - } catch { - // fall through with the raw text - } + let message = parseErrorMessage(text, response.status); + if (response.status === 403) message = await explainForbidden(auth, method, path, message); throw new HttpError(message, response.status); } diff --git a/src/commands/login.ts b/src/commands/login.ts index a7d458b..58addb7 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,15 +1,32 @@ import { Command } from "commander"; import { createInterface } from "node:readline"; -import { writeConfig } from "../config.js"; -import { HttpError, request } from "../client.js"; +import { envApiKeyAuth } from "../auth.js"; +import { clearOAuthClient, getConfigPath, writeConfig, writeOAuth } from "../config.js"; +import { fetchTokenInfo, HttpError, request } from "../client.js"; import { plainError } from "../output.js"; +import { openBrowser } from "../oauth/browser.js"; +import { issuerUrl, SCOPE } from "../oauth/issuer.js"; +import { startCallbackServer } from "../oauth/loopback.js"; +import { generatePkce } from "../oauth/pkce.js"; +import { ensureClient } from "../oauth/register.js"; +import { exchangeCode, OAuthError } from "../oauth/token.js"; + +const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; export function registerLogin(program: Command): void { program .command("login") - .description("Save Upstash credentials to the user config file. Uses --email/--api-key if provided, otherwise prompts interactively.") - .action(async (_flags: unknown, command: Command) => { + .description( + "Save Upstash credentials to the user config file. Uses --email/--api-key if provided, otherwise prompts for an API key. With --oauth, signs in through the browser instead.", + ) + .option("--oauth", "Sign in through the browser with your Upstash account instead of an API key") + .option("--no-browser", "With --oauth: print the login URL instead of opening a browser") + .action(async (flags: { oauth?: boolean; browser: boolean }, command: Command) => { const globals = command.optsWithGlobals() as { email?: string; apiKey?: string }; + if (flags.oauth) { + await oauthLogin(flags.browser); + return; + } const email = globals.email ?? await promptLine("Upstash email: "); if (!globals.apiKey) { process.stderr.write("Create an API key at https://console.upstash.com/account/api\n"); @@ -20,7 +37,7 @@ export function registerLogin(program: Command): void { if (!apiKey) throw plainError("API key is required."); try { - await request({ email, apiKey }, "GET", "/v2/redis/databases"); + await request({ kind: "api-key", email, apiKey }, "GET", "/v2/redis/databases"); } catch (err) { if (err instanceof HttpError && (err.status === 401 || err.status === 403)) { throw plainError("Authentication failed: the email and API key combination is not valid."); @@ -34,6 +51,64 @@ export function registerLogin(program: Command): void { }); } +async function oauthLogin(useBrowser: boolean): Promise { + const env = envApiKeyAuth(); + if (env.email || env.apiKey) { + process.stderr.write("Warning: UPSTASH_EMAIL / UPSTASH_API_KEY are set and will override this login until unset.\n"); + } + if (process.env.CI && !process.stdin.isTTY) { + throw plainError("Browser login needs an interactive terminal. In CI, set UPSTASH_EMAIL and UPSTASH_API_KEY instead."); + } + + const issuer = issuerUrl(); + const client = await ensureClient(issuer); + const pkce = generatePkce(); + const server = await startCallbackServer({ state: pkce.state, issuer }, LOGIN_TIMEOUT_MS); + + try { + const url = new URL(`${issuer}/oauth/authorize`); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", client.client_id); + url.searchParams.set("redirect_uri", server.redirectUri); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("state", pkce.state); + url.searchParams.set("code_challenge", pkce.challenge); + url.searchParams.set("code_challenge_method", "S256"); + + const opened = useBrowser && (await openBrowser(url.toString())); + process.stderr.write( + opened + ? `Opened your browser to sign in. If it did not open, visit:\n${url}\n` + : `Open this URL in your browser to sign in:\n${url}\n`, + ); + + const code = await server.code; + let tokens; + try { + tokens = await exchangeCode({ + issuer, + clientId: client.client_id, + code, + redirectUri: server.redirectUri, + verifier: pkce.verifier, + }); + } catch (err) { + if (err instanceof OAuthError && err.code === "invalid_client") { + clearOAuthClient(); + throw plainError("The saved login client was rejected by the server; run `upstash login --oauth` again to register a new one."); + } + throw err; + } + writeOAuth(tokens); + + const info = await fetchTokenInfo(tokens.access_token); + const scope = info.team_id ? `team ${info.team_id}${info.team_role ? ` as ${info.team_role}` : ""}` : "personal account"; + console.log(`Logged in as ${info.email} (${scope}${info.read_only ? ", read-only" : ""}); saved to ${getConfigPath()}`); + } finally { + server.close(); + } +} + function promptLine(question: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stderr }); return new Promise((resolve) => { diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 67cd4b6..3e6cf19 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,17 +1,34 @@ import { Command } from "commander"; -import { deleteConfig, getConfigPath } from "../config.js"; +import { deleteConfig, getConfigPath, readOAuth, readOAuthClient } from "../config.js"; +import { revokeRefreshToken } from "../oauth/token.js"; export function registerLogout(program: Command): void { program .command("logout") - .description("Delete saved credentials from the user config file.") - .action(() => { + .description("Delete saved credentials from the user config file. A browser login is also revoked on the server.") + .action(async () => { const path = getConfigPath(); + const oauth = readOAuth(); + const client = readOAuthClient(); + let revoked = false; + if (oauth && client) { + try { + await revokeRefreshToken(oauth.issuer, client.client_id, oauth.refresh_token); + revoked = true; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + process.stderr.write(`Warning: could not revoke the login on the server (${reason}). Removing it locally anyway.\n`); + } + } const removed = deleteConfig(); - if (removed) { - console.log(`Removed credentials at ${path}`); - } else { + if (!removed) { console.log(`No saved credentials at ${path}`); + return; + } + console.log(`Removed credentials at ${path}`); + if (oauth) { + if (revoked) console.log("The Upstash API stops accepting this login within about 10 minutes."); + console.log("The CLI stays listed under https://console.upstash.com/account/oauth-clients until you remove it there."); } }); } diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts new file mode 100644 index 0000000..5d0793b --- /dev/null +++ b/src/commands/whoami.ts @@ -0,0 +1,27 @@ +import { Command } from "commander"; +import { resolveAuthWithSource } from "../auth.js"; +import { fetchTokenInfo } from "../client.js"; +import { getAccessToken } from "../oauth/refresh.js"; +import { printJSON } from "../output.js"; + +export function registerWhoami(program: Command): void { + program + .command("whoami") + .description("Show which credentials the CLI is using, and for a browser login, the team and whether it is read-only") + .action(async (_flags: unknown, command: Command) => { + const { auth, source } = resolveAuthWithSource(command); + if (auth.kind === "api-key") { + printJSON({ auth: "api-key", source, email: auth.email }); + return; + } + const info = await fetchTokenInfo(await getAccessToken()); + printJSON({ + auth: "oauth", + source, + email: info.email, + team_id: info.team_id ?? null, + team_role: info.team_role ?? null, + read_only: info.read_only === true, + }); + }); +} diff --git a/src/config.ts b/src/config.ts index efe3001..402ede2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,11 +1,28 @@ -import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { readFileSync, writeFileSync, mkdirSync, rmSync, renameSync, existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { randomBytes } from "node:crypto"; import type { Auth } from "./auth.js"; +export interface OAuthTokens { + issuer: string; + access_token: string; + refresh_token: string; + expires_at: number; +} + +export interface OAuthClient { + issuer: string; + client_id: string; + redirect_uri: string; + registered_at: number; +} + interface StoredConfig { email?: string; api_key?: string; + oauth?: OAuthTokens; + oauth_client?: OAuthClient; telemetry_disabled?: boolean; } @@ -47,35 +64,80 @@ function readRawConfig(path: string): RawConfig | null { } } -function readConfigFile(path: string): Auth | null { +function isOAuthTokens(value: unknown): value is OAuthTokens { + const v = value as Partial | undefined; + return ( + typeof v?.issuer === "string" && + typeof v.access_token === "string" && + typeof v.refresh_token === "string" && + typeof v.expires_at === "number" + ); +} + +function readApiKeyAuth(path: string): Auth | null { const parsed = readRawConfig(path); if (!parsed) return null; // Accept the new snake_case `api_key` or the legacy camelCase `apiKey`. const apiKey = parsed.api_key ?? parsed.apiKey; if (!parsed.email || !apiKey) return null; - return { email: parsed.email, apiKey }; + return { kind: "api-key", email: parsed.email, apiKey }; } export function readConfig(): Auth | null { - return readConfigFile(getConfigPath()) ?? readConfigFile(getLegacyConfigPath()); + const current = readRawConfig(getConfigPath()); + if (current && isOAuthTokens(current.oauth)) return { kind: "oauth" }; + return readApiKeyAuth(getConfigPath()) ?? readApiKeyAuth(getLegacyConfigPath()); +} + +export function readOAuth(): OAuthTokens | null { + const parsed = readRawConfig(getConfigPath()); + return parsed && isOAuthTokens(parsed.oauth) ? parsed.oauth : null; +} + +export function readOAuthClient(): OAuthClient | null { + const client = readRawConfig(getConfigPath())?.oauth_client; + return client && typeof client.client_id === "string" && typeof client.issuer === "string" ? client : null; } +// A crash between truncate and write must not leave an empty file that reads as logged out. function writeStoredConfig(body: StoredConfig): string { const path = getConfigPath(); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - writeFileSync(path, JSON.stringify(body, null, 2) + "\n", { mode: 0o600 }); + const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, path); return path; } -export function writeConfig(auth: Auth): string { - const existing = readRawConfig(getConfigPath()); - return writeStoredConfig({ +function updateConfig(mutate: (current: StoredConfig) => StoredConfig): string { + const existing = readRawConfig(getConfigPath()) ?? {}; + const { apiKey, ...current } = existing; + if (apiKey && current.api_key === undefined) current.api_key = apiKey; + return writeStoredConfig(mutate(current)); +} + +export function writeConfig(auth: { email: string; apiKey: string }): string { + return updateConfig(({ email: _e, api_key: _k, oauth: _o, ...rest }) => ({ email: auth.email, api_key: auth.apiKey, - ...(existing?.telemetry_disabled === undefined - ? {} - : { telemetry_disabled: existing.telemetry_disabled }), - }); + ...rest, + })); +} + +export function writeOAuth(tokens: OAuthTokens): string { + return updateConfig(({ email: _e, api_key: _k, ...rest }) => ({ ...rest, oauth: tokens })); +} + +export function clearOAuth(): string { + return updateConfig(({ oauth: _o, ...rest }) => rest); +} + +export function writeOAuthClient(client: OAuthClient): string { + return updateConfig((current) => ({ ...current, oauth_client: client })); +} + +export function clearOAuthClient(): string { + return updateConfig(({ oauth_client: _c, ...rest }) => rest); } export function readTelemetryDisabled(): boolean { @@ -83,29 +145,26 @@ export function readTelemetryDisabled(): boolean { } export function writeTelemetryDisabled(disabled: boolean): string { - const existing = readRawConfig(getConfigPath()); - return writeStoredConfig({ - ...(existing?.email === undefined ? {} : { email: existing.email }), - ...(existing?.api_key ?? existing?.apiKey - ? { api_key: existing.api_key ?? existing.apiKey } - : {}), - telemetry_disabled: disabled, - }); + return updateConfig((current) => ({ ...current, telemetry_disabled: disabled })); } /** - * Drops the credentials, keeping any telemetry preference: logging out must not - * silently turn telemetry back on. Returns whether credentials were there. + * Drops the credentials, keeping the telemetry preference and the registered + * OAuth client: logging out must not silently turn telemetry back on, and a + * later login should replace the same grant. Returns whether credentials were there. */ export function deleteConfig(): boolean { const path = getConfigPath(); const existing = readRawConfig(path); if (!existing) return false; - const hadCredentials = Boolean(existing.email && (existing.api_key ?? existing.apiKey)); - if (existing.telemetry_disabled === undefined) { + const hadCredentials = Boolean( + (existing.email && (existing.api_key ?? existing.apiKey)) || isOAuthTokens(existing.oauth), + ); + const { email: _e, api_key: _k, apiKey: _a, oauth: _o, ...rest } = existing; + if (Object.keys(rest).length === 0) { rmSync(path); } else { - writeStoredConfig({ telemetry_disabled: existing.telemetry_disabled }); + writeStoredConfig(rest); } return hadCredentials; } diff --git a/src/oauth/browser.ts b/src/oauth/browser.ts new file mode 100644 index 0000000..968c56f --- /dev/null +++ b/src/oauth/browser.ts @@ -0,0 +1,22 @@ +import { spawn } from "node:child_process"; + +export function openBrowser(url: string): Promise { + const [command, args] = + process.platform === "darwin" + ? ["open", [url]] + : process.platform === "win32" + ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]] + : ["xdg-open", [url]]; + return new Promise((resolve) => { + try { + const child = spawn(command, args, { stdio: "ignore", detached: true }); + child.once("error", () => resolve(false)); + child.once("spawn", () => { + child.unref(); + resolve(true); + }); + } catch { + resolve(false); + } + }); +} diff --git a/src/oauth/issuer.ts b/src/oauth/issuer.ts new file mode 100644 index 0000000..0744b72 --- /dev/null +++ b/src/oauth/issuer.ts @@ -0,0 +1,7 @@ +export const DEFAULT_ISSUER = "https://clerk.upstash.com"; +export const SCOPE = "openid email offline_access"; +export const REDIRECT_PATH = "/callback"; + +export function issuerUrl(): string { + return (process.env.UPSTASH_OAUTH_ISSUER ?? DEFAULT_ISSUER).replace(/\/$/, ""); +} diff --git a/src/oauth/lock.ts b/src/oauth/lock.ts new file mode 100644 index 0000000..772fdba --- /dev/null +++ b/src/oauth/lock.ts @@ -0,0 +1,59 @@ +import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs"; + +const RETRY_MS = 100; +// A waiter must outlive the stale timeout, or a crashed holder's lock is never reclaimed. +const STALE_MS = 30_000; +const WAIT_MS = 45_000; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function tryAcquire(lockPath: string): boolean { + try { + const fd = openSync(lockPath, "wx", 0o600); + writeSync(fd, String(process.pid)); + closeSync(fd); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + return false; + } +} + +function holderIsDead(lockPath: string): boolean { + const pid = Number(readFileSync(lockPath, "utf8")); + if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return false; + try { + process.kill(pid, 0); + return false; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "ESRCH"; + } +} + +function removeIfStale(lockPath: string): void { + try { + if (Date.now() - statSync(lockPath).mtimeMs > STALE_MS || holderIsDead(lockPath)) unlinkSync(lockPath); + } catch { + // Already gone: another process released it. + } +} + +export async function withLock(lockPath: string, fn: () => Promise): Promise { + const deadline = Date.now() + WAIT_MS; + while (!tryAcquire(lockPath)) { + removeIfStale(lockPath); + if (Date.now() > deadline) { + throw new Error(`Another upstash process has held ${lockPath} for over ${WAIT_MS / 1000}s. Delete it if no upstash command is running.`); + } + await sleep(RETRY_MS); + } + try { + return await fn(); + } finally { + try { + unlinkSync(lockPath); + } catch { + // Removed as stale by a waiter; nothing to release. + } + } +} diff --git a/src/oauth/loopback.ts b/src/oauth/loopback.ts new file mode 100644 index 0000000..391d3cf --- /dev/null +++ b/src/oauth/loopback.ts @@ -0,0 +1,81 @@ +import { createServer } from "node:http"; +import { REDIRECT_PATH } from "./issuer.js"; + +export interface CallbackServer { + redirectUri: string; + code: Promise; + close(): void; +} + +const escapeHtml = (text: string) => + text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string); + +const page = (title: string, body: string) => + `Upstash CLI

${title}

${escapeHtml(body)}

`; + +function problemWith(q: URLSearchParams, expected: { state: string; issuer: string }): string | undefined { + if (q.get("state") !== expected.state) return "the response did not match this login attempt (state mismatch)"; + const iss = q.get("iss"); + if (iss && iss !== expected.issuer) return `the response came from ${iss}, not ${expected.issuer}`; + const error = q.get("error"); + if (error) return q.get("error_description") ?? error; + if (!q.get("code")) return "the response had no authorization code"; + return undefined; +} + +export function startCallbackServer( + expected: { state: string; issuer: string }, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let resolveCode!: (code: string) => void; + let rejectCode!: (err: Error) => void; + const code = new Promise((res, rej) => { + resolveCode = res; + rejectCode = rej; + }); + // The caller awaits later; a rejection before then must not be reported as unhandled. + code.catch(() => {}); + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + // Browser preconnects and favicon requests must not consume the callback. + if (url.pathname !== REDIRECT_PATH || settled) { + res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found"); + return; + } + const problem = problemWith(url.searchParams, expected); + if (problem) { + res.writeHead(400, { "Content-Type": "text/html" }).end(page("Login failed", `${problem}. Return to the terminal.`)); + if (url.searchParams.get("state") !== expected.state) return; + settled = true; + rejectCode(new Error(`Login failed: ${problem}.`)); + return; + } + settled = true; + res.writeHead(200, { "Content-Type": "text/html" }).end(page("You're logged in", "You can close this tab and return to the terminal.")); + resolveCode(url.searchParams.get("code") as string); + }); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + rejectCode(new Error("Timed out waiting for the browser login. Run the command again.")); + }, timeoutMs); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Could not open a local port for the login callback.")); + return; + } + resolve({ + redirectUri: `http://127.0.0.1:${address.port}${REDIRECT_PATH}`, + code, + close: () => { + clearTimeout(timer); + server.close(); + }, + }); + }); + }); +} diff --git a/src/oauth/pkce.ts b/src/oauth/pkce.ts new file mode 100644 index 0000000..9843531 --- /dev/null +++ b/src/oauth/pkce.ts @@ -0,0 +1,14 @@ +import { createHash, randomBytes } from "node:crypto"; + +export interface Pkce { + verifier: string; + challenge: string; + state: string; +} + +export function generatePkce(): Pkce { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const state = randomBytes(16).toString("base64url"); + return { verifier, challenge, state }; +} diff --git a/src/oauth/refresh.ts b/src/oauth/refresh.ts new file mode 100644 index 0000000..3dd85f2 --- /dev/null +++ b/src/oauth/refresh.ts @@ -0,0 +1,63 @@ +import { clearOAuth, clearOAuthClient, getConfigPath, readOAuth, readOAuthClient, writeOAuth, type OAuthTokens } from "../config.js"; +import { plainError } from "../output.js"; +import { withLock } from "./lock.js"; +import { OAuthError, refreshTokens } from "./token.js"; + +const EXPIRY_MARGIN_S = 5 * 60; +const TRANSIENT_RETRIES = 2; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export const LOGIN_EXPIRED = "Your Upstash login has expired. Run `upstash login --oauth` to sign in again."; + +function isFresh(tokens: OAuthTokens): boolean { + return tokens.expires_at - Math.floor(Date.now() / 1000) > EXPIRY_MARGIN_S; +} + +function isTransient(err: unknown): boolean { + if (err instanceof OAuthError) return err.status >= 500; + return !(err instanceof OAuthError); +} + +async function refreshWithRetry(tokens: OAuthTokens, clientId: string): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await refreshTokens(tokens.issuer, clientId, tokens.refresh_token); + } catch (err) { + if (err instanceof OAuthError && err.code === "invalid_grant") { + clearOAuth(); + throw plainError(LOGIN_EXPIRED); + } + if (err instanceof OAuthError && err.code === "invalid_client") { + clearOAuth(); + clearOAuthClient(); + throw plainError(LOGIN_EXPIRED); + } + if (!isTransient(err) || attempt >= TRANSIENT_RETRIES) throw err; + await sleep(500 * (attempt + 1)); + } + } +} + +/** + * Returns a usable access token, refreshing under a cross-process lock. The + * issuer rotates refresh tokens and revokes the whole login when one is reused, + * so two commands refreshing at once must never both reach the token endpoint. + */ +export async function getAccessToken(options: { force?: boolean } = {}): Promise { + const seen = readOAuth(); + if (!seen) throw plainError(LOGIN_EXPIRED); + if (!options.force && isFresh(seen)) return seen.access_token; + + return withLock(`${getConfigPath()}.lock`, async () => { + const current = readOAuth(); + if (!current) throw plainError(LOGIN_EXPIRED); + const refreshedByOther = current.access_token !== seen.access_token; + if (refreshedByOther && (isFresh(current) || options.force)) return current.access_token; + const client = readOAuthClient(); + if (!client) throw plainError(LOGIN_EXPIRED); + const next = await refreshWithRetry(current, client.client_id); + writeOAuth(next); + return next.access_token; + }); +} diff --git a/src/oauth/register.ts b/src/oauth/register.ts new file mode 100644 index 0000000..87a2360 --- /dev/null +++ b/src/oauth/register.ts @@ -0,0 +1,40 @@ +import { hostname } from "node:os"; +import { readOAuthClient, writeOAuthClient, type OAuthClient } from "../config.js"; +import { REDIRECT_PATH, SCOPE } from "./issuer.js"; + +export async function registerClient(issuer: string): Promise { + const redirectUri = `http://127.0.0.1${REDIRECT_PATH}`; + const response = await fetch(`${issuer}/oauth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: `Upstash CLI (${hostname()})`, + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: SCOPE, + }), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Could not register the CLI with ${issuer} (HTTP ${response.status}): ${text.slice(0, 200)}`); + } + const body = JSON.parse(text) as { client_id?: string }; + if (!body.client_id) throw new Error(`Registration with ${issuer} returned no client_id.`); + const client: OAuthClient = { + issuer, + client_id: body.client_id, + redirect_uri: redirectUri, + registered_at: Math.floor(Date.now() / 1000), + }; + writeOAuthClient(client); + return client; +} + +// One client per install: re-login then replaces the same grant instead of adding one. +export async function ensureClient(issuer: string): Promise { + const stored = readOAuthClient(); + if (stored && stored.issuer === issuer) return stored; + return registerClient(issuer); +} diff --git a/src/oauth/token.ts b/src/oauth/token.ts new file mode 100644 index 0000000..42e0bcb --- /dev/null +++ b/src/oauth/token.ts @@ -0,0 +1,87 @@ +import type { OAuthTokens } from "../config.js"; + +export class OAuthError extends Error { + readonly code: string; + readonly status: number; + constructor(code: string, description: string | undefined, status: number) { + super(description ? `${code}: ${description}` : code); + this.code = code; + this.status = status; + } +} + +interface TokenResponse { + access_token?: string; + refresh_token?: string; + expires_in?: number; + error?: string; + error_description?: string; +} + +async function tokenRequest(issuer: string, path: string, params: Record): Promise { + const response = await fetch(`${issuer}${path}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params), + }); + const text = await response.text(); + let parsed: TokenResponse = {}; + try { + parsed = text ? (JSON.parse(text) as TokenResponse) : {}; + } catch { + parsed = {}; + } + if (!response.ok) { + const code = parsed.error ?? (response.status >= 500 ? "server_error" : `http_${response.status}`); + throw new OAuthError(code, parsed.error_description ?? (parsed.error ? undefined : text.slice(0, 200)), response.status); + } + return parsed; +} + +function toTokens(issuer: string, body: TokenResponse, previousRefreshToken?: string): OAuthTokens { + const refresh = body.refresh_token ?? previousRefreshToken; + if (!body.access_token || !refresh) { + throw new OAuthError("invalid_response", "the token response had no access or refresh token", 200); + } + const expiresIn = typeof body.expires_in === "number" ? body.expires_in : 3600; + return { + issuer, + access_token: body.access_token, + refresh_token: refresh, + expires_at: Math.floor(Date.now() / 1000) + expiresIn, + }; +} + +export async function exchangeCode(input: { + issuer: string; + clientId: string; + code: string; + redirectUri: string; + verifier: string; +}): Promise { + const body = await tokenRequest(input.issuer, "/oauth/token", { + grant_type: "authorization_code", + code: input.code, + redirect_uri: input.redirectUri, + client_id: input.clientId, + code_verifier: input.verifier, + }); + return toTokens(input.issuer, body); +} + +export async function refreshTokens(issuer: string, clientId: string, refreshToken: string): Promise { + const body = await tokenRequest(issuer, "/oauth/token", { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }); + return toTokens(issuer, body, refreshToken); +} + +export async function revokeRefreshToken(issuer: string, clientId: string, refreshToken: string): Promise { + await tokenRequest(issuer, "/oauth/token/revoke", { + token: refreshToken, + token_type_hint: "refresh_token", + client_id: clientId, + }); +} diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index bdfd009..dcc4a55 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -33,7 +33,7 @@ describe("config file round-trip", () => { it("writes with 0600 perms and reads back", () => { const path = writeConfig({ email: "a@b.com", apiKey: "key-1" }); expect(path).toBe(getConfigPath()); - expect(readConfig()).toEqual({ email: "a@b.com", apiKey: "key-1" }); + expect(readConfig()).toEqual({ kind: "api-key", email: "a@b.com", apiKey: "key-1" }); if (process.platform !== "win32") { const mode = statSync(path).mode & 0o777; expect(mode).toBe(0o600); @@ -57,13 +57,13 @@ describe("legacy ~/.upstash.json fallback", () => { it("reads the 0.x file (camelCase apiKey) when no new config exists", () => { writeLegacy({ email: "legacy@b.com", apiKey: "legacy-key" }); - expect(readConfig()).toEqual({ email: "legacy@b.com", apiKey: "legacy-key" }); + expect(readConfig()).toEqual({ kind: "api-key", email: "legacy@b.com", apiKey: "legacy-key" }); }); it("prefers the new config over the legacy file", () => { writeLegacy({ email: "legacy@b.com", apiKey: "legacy-key" }); writeConfig({ email: "new@b.com", apiKey: "new-key" }); - expect(readConfig()).toEqual({ email: "new@b.com", apiKey: "new-key" }); + expect(readConfig()).toEqual({ kind: "api-key", email: "new@b.com", apiKey: "new-key" }); }); it("ignores a legacy file that is missing a field", () => { @@ -79,14 +79,14 @@ describe("resolveAuth precedence", () => { it("falls back to the saved config file", () => { writeConfig({ email: "file@b.com", apiKey: "file-key" }); - expect(resolveAuth({})).toEqual({ email: "file@b.com", apiKey: "file-key" }); + expect(resolveAuth({})).toEqual({ kind: "api-key", email: "file@b.com", apiKey: "file-key" }); }); it("env vars beat the saved config file", () => { writeConfig({ email: "file@b.com", apiKey: "file-key" }); process.env.UPSTASH_EMAIL = "env@b.com"; process.env.UPSTASH_API_KEY = "env-key"; - expect(resolveAuth({})).toEqual({ email: "env@b.com", apiKey: "env-key" }); + expect(resolveAuth({})).toEqual({ kind: "api-key", email: "env@b.com", apiKey: "env-key" }); }); it("refuses to mix a partial session tier with the saved config", () => { @@ -101,6 +101,7 @@ describe("resolveAuth precedence", () => { process.env.UPSTASH_EMAIL = "env@b.com"; process.env.UPSTASH_API_KEY = "env-key"; expect(resolveAuth({ email: "flag@b.com", apiKey: "flag-key" })).toEqual({ + kind: "api-key", email: "flag@b.com", apiKey: "flag-key", }); @@ -138,7 +139,7 @@ describe("login / logout commands (flag form)", () => { const output = await captureStdout(p, ["login", "--email", "cli@b.com", "--api-key", "cli-key"]); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(output).toBe(`Credentials verified and saved to ${getConfigPath()}`); - expect(readConfig()).toEqual({ email: "cli@b.com", apiKey: "cli-key" }); + expect(readConfig()).toEqual({ kind: "api-key", email: "cli@b.com", apiKey: "cli-key" }); } finally { fetchSpy.mockRestore(); } diff --git a/tests/unit/blob.test.ts b/tests/unit/blob.test.ts index ca5edcf..9a7c1ac 100644 --- a/tests/unit/blob.test.ts +++ b/tests/unit/blob.test.ts @@ -448,7 +448,7 @@ describe("blob provisioning helpers", () => { }); describe("blob delete retries", () => { - const auth = { email: "user@example.com", apiKey: "api-key" }; + const auth = { kind: "api-key" as const, email: "user@example.com", apiKey: "api-key" }; it("retries 5xx while the bucket is provisioning, then succeeds", async () => { const delays: number[] = []; diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index c99367d..eb7fd84 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { request } from "../../src/client.js"; -const auth = { email: "user@example.com", apiKey: "key" }; +const auth = { kind: "api-key" as const, email: "user@example.com", apiKey: "key" }; afterEach(() => { vi.restoreAllMocks(); diff --git a/tests/unit/oauth.test.ts b/tests/unit/oauth.test.ts new file mode 100644 index 0000000..7955954 --- /dev/null +++ b/tests/unit/oauth.test.ts @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "commander"; +import { + clearOAuth, + deleteConfig, + getConfigPath, + readConfig, + readOAuth, + readOAuthClient, + readTelemetryDisabled, + writeConfig, + writeOAuth, + writeOAuthClient, + writeTelemetryDisabled, +} from "../../src/config.js"; +import { getAccessToken, LOGIN_EXPIRED } from "../../src/oauth/refresh.js"; +import { startCallbackServer } from "../../src/oauth/loopback.js"; +import { withLock } from "../../src/oauth/lock.js"; +import { request, READ_ONLY_LOGIN, TEAM_MANAGEMENT_NEEDS_API_KEY } from "../../src/client.js"; +import { registerLogout } from "../../src/commands/logout.js"; +import { registerWhoami } from "../../src/commands/whoami.js"; + +const ISSUER = "https://issuer.test"; +const now = () => Math.floor(Date.now() / 1000); + +let dir: string; +const originalEnv = { ...process.env }; + +function tokens(overrides: Partial[0]> = {}) { + return { issuer: ISSUER, access_token: "at-0", refresh_token: "rt-0", expires_at: now() + 86400, ...overrides }; +} + +function client() { + return { issuer: ISSUER, client_id: "cid", redirect_uri: "http://127.0.0.1/callback", registered_at: now() }; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +async function captureStdout(program: Command, argv: string[]): Promise { + const lines: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => lines.push(args.join(" ")); + try { + await program.parseAsync(["node", "upstash", ...argv]); + } finally { + console.log = origLog; + } + return lines.join("\n"); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "upstash-cli-oauth-")); + process.env.UPSTASH_CONFIG_HOME = dir; + process.env.UPSTASH_LEGACY_CONFIG_HOME = dir; + process.env.UPSTASH_OAUTH_ISSUER = ISSUER; + delete process.env.UPSTASH_EMAIL; + delete process.env.UPSTASH_API_KEY; +}); + +afterEach(() => { + vi.restoreAllMocks(); + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe("config keeps the fields a write does not touch", () => { + it("telemetry and client survive credential writes, and files stay 0600", () => { + writeTelemetryDisabled(true); + writeOAuthClient(client()); + writeConfig({ email: "a@b.com", apiKey: "k" }); + writeOAuth(tokens()); + expect(readTelemetryDisabled()).toBe(true); + expect(readOAuthClient()?.client_id).toBe("cid"); + if (process.platform !== "win32") expect(statSync(getConfigPath()).mode & 0o777).toBe(0o600); + expect(existsSync(`${getConfigPath()}.tmp`)).toBe(false); + }); + + it("an OAuth login replaces an API key login and vice versa", () => { + writeConfig({ email: "a@b.com", apiKey: "k" }); + writeOAuth(tokens()); + expect(readConfig()).toEqual({ kind: "oauth" }); + const raw = JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record; + expect(raw.email).toBeUndefined(); + writeConfig({ email: "c@d.com", apiKey: "k2" }); + expect(readOAuth()).toBeNull(); + expect(readConfig()).toEqual({ kind: "api-key", email: "c@d.com", apiKey: "k2" }); + }); + + it("deleteConfig keeps the registered client and telemetry preference", () => { + writeOAuthClient(client()); + writeTelemetryDisabled(true); + writeOAuth(tokens()); + expect(deleteConfig()).toBe(true); + expect(readOAuth()).toBeNull(); + expect(readOAuthClient()?.client_id).toBe("cid"); + expect(readTelemetryDisabled()).toBe(true); + expect(deleteConfig()).toBe(false); + }); +}); + +describe("callback server", () => { + async function hit(redirectUri: string, params: Record): Promise { + const url = new URL(redirectUri); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + return (await fetch(url)).status; + } + + it("accepts a matching state and iss and yields the code", async () => { + const server = await startCallbackServer({ state: "s1", issuer: ISSUER }, 5000); + expect(server.redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/); + expect(await hit(server.redirectUri.replace("/callback", "/favicon.ico"), {})).toBe(404); + expect(await hit(server.redirectUri, { state: "s1", iss: ISSUER, code: "abc" })).toBe(200); + await expect(server.code).resolves.toBe("abc"); + server.close(); + }); + + it("rejects a wrong state without settling, then a wrong iss with a clear error", async () => { + const server = await startCallbackServer({ state: "s1", issuer: ISSUER }, 5000); + expect(await hit(server.redirectUri, { state: "other", code: "x" })).toBe(400); + expect(await hit(server.redirectUri, { state: "s1", iss: "https://evil.test", code: "x" })).toBe(400); + await expect(server.code).rejects.toThrow(/evil\.test/); + server.close(); + }); + + it("surfaces an error parameter, escaped in the page it shows", async () => { + const server = await startCallbackServer({ state: "s1", issuer: ISSUER }, 5000); + const url = new URL(server.redirectUri); + url.searchParams.set("state", "s1"); + url.searchParams.set("error", "access_denied"); + url.searchParams.set("error_description", " denied"); + const html = await (await fetch(url)).text(); + expect(html).not.toContain("