Skip to content
212 changes: 212 additions & 0 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { Command } from "commander";
import * as config from "../lib/config.js";
import { AUTH_PORT, getLoginUrl, performBrowserLogin, revokeToken } from "../lib/auth.js";
import { AdminClient } from "../lib/admin-client.js";
import type { App } from "../lib/admin-client.js";
import { CLIError, ErrorCode, exitWithError } from "../lib/errors.js";
import { printHuman, isJSONMode, debug } from "../lib/output.js";
import { promptSelect } from "../lib/terminal-ui.js";
import { isInteractiveAllowed } from "../lib/interaction.js";
import { resolveAuthToken } from "../lib/resolve.js";
import { green, dim, bold, brand, maskIf, withSpinner } from "../lib/ui.js";

export function registerAuth(program: Command) {
const cmd = program
.command("auth")
.description("Authenticate with your Alchemy account");

cmd
.command("login", { isDefault: true })
.description("Log in via browser")
.option("--force", "Force re-authentication even if a valid token exists")
.action(async (opts: { force?: boolean }) => {
try {
// Skip browser flow if we already have a valid token
if (!opts.force) {
const existing = resolveAuthToken();
if (existing) {
printHuman(
` ${green("✓")} Already authenticated\n` +
` ${dim("Token:")} ${maskIf(existing)}\n` +
` ${dim("Run")} alchemy auth login --force ${dim("to re-authenticate.")}\n`,
{ status: "already_authenticated" },
);
return;
}
}

if (!isJSONMode()) {
console.log("");
console.log(` ${brand("◆")} ${bold("Alchemy Authentication")}`);
console.log(` ${dim("────────────────────────────────────")}`);
console.log("");
console.log(` Opening browser to log in...`);
console.log(` ${dim(getLoginUrl(AUTH_PORT))}`);
console.log("");
console.log(` ${dim("Waiting for authentication...")}`);
}

const result = await performBrowserLogin();

// Save token to config
const cfg = config.load();
config.save({
...cfg,
auth_token: result.token,
auth_token_expires_at: result.expiresAt,
});
const expiresAt = result.expiresAt;

printHuman(
` ${green("✓")} Logged in successfully\n` +
` ${dim("Token saved to")} ${config.configPath()}\n` +
` ${dim("Expires:")} ${expiresAt}\n`,
{
status: "authenticated",
expiresAt,
configPath: config.configPath(),
},
);

// After auth, try to fetch apps and let user select one
if (isInteractiveAllowed(program)) {
await selectAppAfterAuth(result.token);
}

process.exit(0);
} catch (err) {
exitWithError(
err instanceof CLIError
? err
: new CLIError(ErrorCode.AUTH_REQUIRED, String((err as Error).message)),
);
}
});

cmd
.command("status")
.description("Show current authentication status")
.action(() => {
try {
const cfg = config.load();
const validToken = resolveAuthToken(cfg);

if (!cfg.auth_token) {
printHuman(
` ${dim("Not authenticated. Run")} alchemy auth ${dim("to log in.")}\n`,
{ authenticated: false },
);
return;
}

if (!validToken) {
printHuman(
` ${dim("Session expired. Run")} alchemy auth ${dim("to log in again.")}\n`,
{ authenticated: false, expired: true },
);
return;
}

printHuman(
` ${green("✓")} Authenticated\n` +
` ${dim("Token:")} ${maskIf(validToken)}\n` +
` ${dim("Expires:")} ${cfg.auth_token_expires_at || "unknown"}\n`,
{
authenticated: true,
expired: false,
expiresAt: cfg.auth_token_expires_at,
},
);
} catch (err) {
exitWithError(err);
}
});

cmd
.command("logout")
.description("Revoke and clear saved authentication token")
.action(async () => {
try {
const cfg = config.load();
const token = cfg.auth_token;

// Revoke server-side first (best-effort)
if (token) {
try {
await revokeToken(token);
} catch {
// Token may already be expired/invalid — continue with local cleanup
}
}

const { auth_token: _, auth_token_expires_at: __, ...rest } = cfg as Record<string, unknown>;
config.save(rest as config.Config);
printHuman(
` ${green("✓")} Logged out\n`,
{ status: "logged_out" },
);
} catch (err) {
exitWithError(err);
}
});
}

async function selectAppAfterAuth(authToken: string): Promise<void> {
let apps: App[];
try {
const admin = new AdminClient({ type: "auth_token", token: authToken });
const result = await withSpinner("Fetching apps…", "Apps fetched", () =>
admin.listAllApps(),
);
apps = result.apps;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
debug(`Failed to fetch apps: ${msg}`);
if (!isJSONMode()) {
console.error(` ${dim(`Could not fetch apps: ${msg}`)}`);
}
return;
}

if (apps.length === 0) {
console.log(` ${dim("No apps found. Create one at dashboard.alchemy.com")}`);
return;
}

let selectedApp: App;

if (apps.length === 1) {
selectedApp = apps[0];
console.log(` ${green("✓")} Auto-selected app: ${bold(selectedApp.name)}`);
} else {
console.log("");
const appId = await promptSelect({
message: "Select an app",
options: apps.map((app) => ({
value: app.id,
label: app.name,
hint: `${app.chainNetworks.length} networks`,
})),
cancelMessage: "Skipped app selection.",
});

if (!appId) return;
selectedApp = apps.find((a) => a.id === appId)!;
}

// Save selected app to config
const cfg = config.load();
config.save({
...cfg,
app: {
id: selectedApp.id,
name: selectedApp.name,
apiKey: selectedApp.apiKey,
webhookApiKey: selectedApp.webhookApiKey,
},
});

console.log(
` ${dim("App")} ${selectedApp.name} ${dim("saved to config")}`,
);
}
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { registerChains } from "./commands/chains.js";
import { registerApps } from "./commands/apps.js";
import { registerWallet } from "./commands/wallet.js";
import { registerSetup } from "./commands/setup.js";
import { registerAuth } from "./commands/auth.js";
import { registerTrace } from "./commands/trace.js";
import { registerDebug } from "./commands/debug.js";
import { registerTransfers } from "./commands/transfers.js";
Expand Down Expand Up @@ -74,7 +75,7 @@ const ROOT_COMMAND_PILLARS = [
},
{
label: "Admin",
commands: ["apps", "config", "setup", "agent-prompt", "update-check", "version", "help"],
commands: ["apps", "auth", "config", "setup", "agent-prompt", "update-check", "version", "help"],
},
] as const;

Expand Down Expand Up @@ -410,6 +411,7 @@ registerChains(program);

// Ops / Admin
registerApps(program);
registerAuth(program);
registerSetup(program);
registerConfig(program);
registerSolana(program);
Expand Down
26 changes: 20 additions & 6 deletions src/lib/admin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,29 @@ interface ListAppsResponse {

// ── Client ───────────────────────────────────────────────────────────

export type AdminCredential =
| { type: "access_key"; key: string }
| { type: "auth_token"; token: string };

export class AdminClient {
private static readonly ADMIN_API_HOST = "admin-api.alchemy.com";
// Test/debug only: used by mock E2E to route admin requests locally.
private static readonly ADMIN_API_BASE_URL_ENV = "ALCHEMY_ADMIN_API_BASE_URL";
private accessKey: string;

constructor(accessKey: string) {
this.validateAccessKey(accessKey);
this.accessKey = accessKey;
private credential: AdminCredential;

constructor(credential: string | AdminCredential) {
if (typeof credential === "string") {
// Legacy: treat as access key
this.validateAccessKey(credential);
this.credential = { type: "access_key", key: credential };
} else {
if (credential.type === "access_key") {
this.validateAccessKey(credential.key);
} else if (!credential.token.trim()) {
throw errInvalidAccessKey();
}
this.credential = credential;
}
}

protected baseURL(): string {
Expand Down Expand Up @@ -122,7 +136,7 @@ export class AdminClient {
method,
redirect: "error",
headers: {
Authorization: `Bearer ${this.accessKey}`,
Authorization: `Bearer ${this.credential.type === "access_key" ? this.credential.key : this.credential.token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
Expand Down
53 changes: 53 additions & 0 deletions src/lib/auth-retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { load, save } from "./config.js";
import { performBrowserLogin } from "./auth.js";
import { CLIError, ErrorCode } from "./errors.js";
import { isInteractiveAllowed as checkInteractive } from "./interaction.js";
import { dim } from "./ui.js";
import { isJSONMode } from "./output.js";
import type { Command } from "commander";

export async function withAuthRetry<T>(
program: Command,
fn: (authToken: string) => Promise<T>,
): Promise<T> {
const cfg = load();
if (!cfg.auth_token) {
throw new Error("Not authenticated. Run 'alchemy auth' to log in.");
}

try {
return await fn(cfg.auth_token);
} catch (err: unknown) {
if (!is401(err)) throw err;
if (!checkInteractive(program)) throw err;

if (!isJSONMode()) {
console.log(`\n ${dim("Session expired. Re-authenticating...")}`);
}

// Clear expired token and re-authenticate
save({ ...cfg, auth_token: undefined, auth_token_expires_at: undefined });
const result = await performBrowserLogin();
const freshCfg = load();
save({
...freshCfg,
auth_token: result.token,
auth_token_expires_at: result.expiresAt,
});

return fn(result.token);
}
}

function is401(err: unknown): boolean {
if (err instanceof CLIError) {
return (
err.code === ErrorCode.AUTH_REQUIRED ||
err.code === ErrorCode.INVALID_ACCESS_KEY
);
}
if (err && typeof err === "object" && "status" in err) {
return (err as { status: number }).status === 401;
}
return false;
}
Loading
Loading