Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 22 additions & 12 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
}
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -42,6 +43,7 @@ program

registerLogin(program);
registerLogout(program);
registerWhoami(program);
registerStartRedis(program);
registerTelemetry(program);
registerRedis(program);
Expand Down
101 changes: 82 additions & 19 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string> {
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<TokenInfo> {
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<string> {
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<T>(
auth: Auth,
method: string,
path: string,
body?: unknown,
): Promise<T> {
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);
}

Expand Down
85 changes: 80 additions & 5 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -20,7 +37,7 @@ export function registerLogin(program: Command): void {
if (!apiKey) throw plainError("API key is required.");

try {
await request<unknown>({ email, apiKey }, "GET", "/v2/redis/databases");
await request<unknown>({ 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.");
Expand All @@ -34,6 +51,64 @@ export function registerLogin(program: Command): void {
});
}

async function oauthLogin(useBrowser: boolean): Promise<void> {
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);
Comment on lines +102 to +104
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<string> {
const rl = createInterface({ input: process.stdin, output: process.stderr });
return new Promise((resolve) => {
Expand Down
29 changes: 23 additions & 6 deletions src/commands/logout.ts
Original file line number Diff line number Diff line change
@@ -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 {
Comment on lines +11 to +15
await revokeRefreshToken(oauth.issuer, client.client_id, oauth.refresh_token);
Comment on lines +14 to +16
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.");
}
});
}
27 changes: 27 additions & 0 deletions src/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
}
Loading
Loading