diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index fe6c87495..af1ac8d7f 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -860,6 +860,63 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ +## hyperframes auth + +Sign in to HeyGen and manage credentials. Credentials are stored in +`~/.heygen/credentials` (mode `0600`) and are **shared with the +`heygen` CLI** — sign in with one and the other picks up the session. + +Resolution order (first match wins): + +1. `HEYGEN_API_KEY` environment variable +2. `HYPERFRAMES_API_KEY` environment variable (hyperframes alias) +3. `~/.heygen/credentials` + +### Subcommands + +#### `auth login --api-key` + +Save a HeyGen API key. The key is verified against `GET /v3/users/me` +before the command reports success; a rejected key is not left on disk. + +```bash +# Interactive hidden-input prompt +hyperframes auth login --api-key + +# Pipe a key from stdin (CI-friendly) +echo "$HEYGEN_API_KEY" | hyperframes auth login --api-key +``` + +#### `auth status` + +Show the active credential's source, type, and verified identity +(account + billing snapshot). Exits non-zero when nothing is configured +or the API rejects the credential, so scripts can check sign-in state. + +```bash +hyperframes auth status +hyperframes auth status --json # machine-readable +``` + +#### `auth logout` + +Remove the stored credential. Prompts for confirmation on a TTY. + +```bash +hyperframes auth logout +hyperframes auth logout --keep-api-key # clear only an OAuth session +hyperframes auth logout --yes # skip the confirmation prompt +``` + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `HEYGEN_API_KEY` | Override the stored credential. | +| `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. | +| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). | +| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). | + ## hyperframes lambda Deploy HyperFrames distributed rendering to AWS Lambda and drive renders from your laptop or CI. diff --git a/packages/cli/src/auth/client.test.ts b/packages/cli/src/auth/client.test.ts new file mode 100644 index 000000000..b0ad67b24 --- /dev/null +++ b/packages/cli/src/auth/client.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthClient, apiBaseUrl, buildAuthHeaders } from "./client.js"; +import { isAuthError } from "./errors.js"; +import type { ResolvedCredential } from "./resolver.js"; + +function jsonFetch(body: unknown, status = 200): typeof fetch { + return (async () => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; +} + +function textFetch(body: string, status: number): typeof fetch { + return (async () => new Response(body, { status })) as unknown as typeof fetch; +} + +function apiKeyCred(): ResolvedCredential { + return { type: "api_key", key: "hg_x", source: "env" }; +} + +function makeClient(fetchImpl: typeof fetch): AuthClient { + return new AuthClient({ baseUrl: "https://api.test.example", fetchImpl }); +} + +describe("auth/client", () => { + const original = process.env["HEYGEN_API_URL"]; + + beforeEach(() => { + delete process.env["HEYGEN_API_URL"]; + }); + + afterEach(() => { + if (original !== undefined) process.env["HEYGEN_API_URL"] = original; + else delete process.env["HEYGEN_API_URL"]; + }); + + it("apiBaseUrl defaults to https://api.heygen.com", () => { + expect(apiBaseUrl()).toBe("https://api.heygen.com"); + }); + + it("apiBaseUrl honors HEYGEN_API_URL and strips trailing slash", () => { + process.env["HEYGEN_API_URL"] = "https://api.dev.heygen.com/"; + expect(apiBaseUrl()).toBe("https://api.dev.heygen.com"); + }); + + it("buildAuthHeaders uses Bearer for oauth", () => { + const cred: ResolvedCredential = { + type: "oauth", + access_token: "at_123", + source: "file_json", + refreshable: false, + }; + expect(buildAuthHeaders(cred)).toEqual({ authorization: "Bearer at_123" }); + }); + + it("buildAuthHeaders uses x-api-key for api_key", () => { + expect(buildAuthHeaders(apiKeyCred())).toEqual({ "x-api-key": "hg_x" }); + }); + + it("getCurrentUser parses a wrapped {data: {...}} payload", async () => { + const client = makeClient( + jsonFetch({ + code: 100, + message: "ok", + data: { + username: "alice", + email: "alice@example.com", + billing_type: "subscription", + subscription: { plan: "team", credits: { premium_credits: 4200 } }, + }, + }), + ); + const user = await client.getCurrentUser(apiKeyCred()); + expect(user.username).toBe("alice"); + expect(user.email).toBe("alice@example.com"); + expect(user.subscription?.plan).toBe("team"); + expect(user.subscription?.credits?.premium_credits).toBe(4200); + }); + + it("getCurrentUser parses an unwrapped payload", async () => { + const client = makeClient(jsonFetch({ email: "bob@example.com" })); + const user = await client.getCurrentUser(apiKeyCred()); + expect(user.email).toBe("bob@example.com"); + }); + + it("getCurrentUser throws ErrUnauthenticated on 401", async () => { + const client = makeClient(textFetch("invalid token", 401)); + await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => { + return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED"; + }); + }); + + it("getCurrentUser throws ErrApi on 5xx", async () => { + const client = makeClient(textFetch("upstream", 503)); + await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => { + return isAuthError(err) && (err as { code: string }).code === "API_ERROR"; + }); + }); + + it("getCurrentUser throws ErrApi when 2xx body is not valid JSON", async () => { + const fetchImpl = (async () => + new Response("not json", { + status: 200, + headers: { "content-type": "text/html" }, + })) as unknown as typeof fetch; + const client = makeClient(fetchImpl); + await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => { + return isAuthError(err) && (err as { code: string }).code === "API_ERROR"; + }); + }); + + it("getCurrentUser returns empty UserInfo when payload.data is an array", async () => { + const client = makeClient(jsonFetch({ code: 0, data: [{ email: "x@y" }] })); + const user = await client.getCurrentUser(apiKeyCred()); + expect(user).toEqual({}); + }); + + it("getCurrentUser scrubs hg_ keys and JWTs from 401 detail", async () => { + const fetchImpl = textFetch( + 'invalid request — got header "x-api-key: hg_supersecret_abc123"', + 401, + ); + const client = makeClient(fetchImpl); + try { + await client.getCurrentUser(apiKeyCred()); + } catch (err) { + const msg = (err as Error).message; + expect(msg).not.toContain("hg_supersecret_abc123"); + expect(msg).toContain(""); + return; + } + throw new Error("expected rejection"); + }); + + it("getCurrentUser redacts the full Authorization: Bearer value (not just the scheme)", async () => { + const fetchImpl = textFetch( + "rejected — echoed Authorization: Bearer at_opaque_secret_999", + 401, + ); + const client = makeClient(fetchImpl); + try { + await client.getCurrentUser(apiKeyCred()); + } catch (err) { + const msg = (err as Error).message; + expect(msg).not.toContain("at_opaque_secret_999"); + expect(msg).not.toContain("Bearer at_opaque_secret_999"); + expect(msg).toContain(""); + return; + } + throw new Error("expected rejection"); + }); + + it("getCurrentUser sends the right header for oauth credentials", async () => { + let captured: Record = {}; + const fetchImpl = (async (_url: string, init?: RequestInit) => { + captured = (init?.headers as Record) ?? {}; + return new Response(JSON.stringify({ email: "alice@example.com" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const client = makeClient(fetchImpl); + await client.getCurrentUser({ + type: "oauth", + access_token: "at_xyz", + source: "file_json", + refreshable: false, + }); + expect(captured["authorization"]).toBe("Bearer at_xyz"); + }); +}); diff --git a/packages/cli/src/auth/client.ts b/packages/cli/src/auth/client.ts new file mode 100644 index 000000000..0887de272 --- /dev/null +++ b/packages/cli/src/auth/client.ts @@ -0,0 +1,176 @@ +/** + * Minimal typed HTTP client for HeyGen endpoints needed by the auth + * commands. Hand-written rather than codegen'd because the surface is + * one endpoint (`/v3/users/me`) and pulling in an OpenAPI pipeline is + * disproportionate. + * + * Reads `HEYGEN_API_URL` (default `https://api.heygen.com`) so dev + * testing is one env var away. + * + * Auth header selection: + * - OAuth → `Authorization: Bearer ` + * - API key → `x-api-key: ` + * + * The backend `/v3/users/me` accepts both. See + * `movio/api_service/app/controller/user_v3.py`. + */ + +import { ErrApi, ErrUnauthenticated } from "./errors.js"; +import type { ResolvedCredential } from "./resolver.js"; + +const DEFAULT_BASE_URL = "https://api.heygen.com"; + +export function apiBaseUrl(): string { + const override = process.env["HEYGEN_API_URL"]; + return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL; +} + +export type BillingType = "wallet" | "subscription" | "usage_based" | string; + +export interface WalletInfo { + currency?: string; + remaining_balance?: number; + auto_reload?: boolean; +} + +export interface SubscriptionInfo { + plan?: string; + credits?: { + premium_credits?: number; + add_on_credits?: number; + }; +} + +export interface UsageBasedInfo { + spending_current_usd?: number; + spending_cap_usd?: number; +} + +/** Subset of the backend response we surface to users today. */ +export interface UserInfo { + username?: string; + email?: string; + first_name?: string; + last_name?: string; + billing_type?: BillingType; + wallet?: WalletInfo; + subscription?: SubscriptionInfo; + usage_based?: UsageBasedInfo; +} + +export interface AuthClientOptions { + /** Override base URL (otherwise `HEYGEN_API_URL` / default). */ + baseUrl?: string; + /** Inject a custom fetch (used by tests). */ + fetchImpl?: typeof fetch; +} + +export class AuthClient { + private readonly base: string; + private readonly fetchImpl: typeof fetch; + + constructor(opts: AuthClientOptions = {}) { + this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, ""); + this.fetchImpl = opts.fetchImpl ?? fetch; + } + + /** + * `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi` + * on any other non-2xx or non-JSON body. + */ + async getCurrentUser(credential: ResolvedCredential): Promise { + const url = `${this.base}/v3/users/me`; + const headers = buildAuthHeaders(credential); + const res = await this.fetchImpl(url, { method: "GET", headers }); + + if (res.status === 401) { + const detail = await safeText(res); + throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`); + } + if (!res.ok) { + throw ErrApi(res.status, (await safeText(res)) || res.statusText); + } + + let payload: unknown; + try { + payload = await res.json(); + } catch (err) { + throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`); + } + return extractUserInfo(payload); + } +} + +export function buildAuthHeaders(credential: ResolvedCredential): Record { + if (credential.type === "oauth") { + return { authorization: `Bearer ${credential.access_token}` }; + } + return { "x-api-key": credential.key }; +} + +async function safeText(res: Response): Promise { + try { + const body = (await res.text()).slice(0, 500); + return scrubCredentials(body); + } catch { + return ""; + } +} + +/** + * Strip credential-shaped substrings from error bodies before they + * surface in user-facing messages or `--json` output. Some proxies + * echo request headers in their error pages and we never want a + * HeyGen API key, OAuth bearer, or JWT to land in scrollback / CI + * logs because of one of those echoes. + */ +function scrubCredentials(s: string): string { + return ( + s + .replace(/hg_[A-Za-z0-9_-]{4,}/g, "hg_") + // Redact the ENTIRE header value to end-of-line — `Bearer ` + // is two whitespace-separated words, so a `\S+` would leave the + // opaque token exposed after the scheme. + .replace(/(authorization|x-api-key)[ \t]*[:=][ \t]*[^\r\n]+/gi, "$1: ") + .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "") + ); +} + +/** + * The backend wraps responses in `{code, message, data: {...}}` for some + * endpoints and returns raw fields directly for others. Handle both. + */ +function extractUserInfo(payload: unknown): UserInfo { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {}; + const obj = payload as Record; + const wrapped = obj["data"]; + const data = + wrapped && typeof wrapped === "object" && !Array.isArray(wrapped) + ? (wrapped as Record) + : obj; + return { + username: pickString(data, "username"), + email: pickString(data, "email"), + first_name: pickString(data, "first_name"), + last_name: pickString(data, "last_name"), + billing_type: pickString(data, "billing_type"), + wallet: pickObject(data, "wallet") as WalletInfo | undefined, + subscription: pickObject(data, "subscription") as SubscriptionInfo | undefined, + usage_based: pickObject(data, "usage_based") as UsageBasedInfo | undefined, + }; +} + +function pickString(obj: Record, key: string): string | undefined { + const v = obj[key]; + return typeof v === "string" ? v : undefined; +} + +function pickObject( + obj: Record, + key: string, +): Record | undefined { + const v = obj[key]; + return v && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : undefined; +} diff --git a/packages/cli/src/auth/errors.test.ts b/packages/cli/src/auth/errors.test.ts new file mode 100644 index 000000000..b4cdae26d --- /dev/null +++ b/packages/cli/src/auth/errors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + AuthError, + ErrApi, + ErrInvalidStore, + ErrNotConfigured, + ErrUnauthenticated, + isAuthError, +} from "./errors.js"; + +describe("auth/errors", () => { + it("ErrNotConfigured carries the right code + hint", () => { + const err = ErrNotConfigured(); + expect(err).toBeInstanceOf(AuthError); + expect(err.code).toBe("NOT_CONFIGURED"); + expect(err.hint).toContain("hyperframes auth login"); + }); + + it("ErrInvalidStore wraps the detail", () => { + const err = ErrInvalidStore("malformed at line 3"); + expect(err.code).toBe("INVALID_STORE"); + expect(err.message).toContain("malformed at line 3"); + }); + + it("ErrUnauthenticated includes detail when provided", () => { + expect(ErrUnauthenticated().code).toBe("UNAUTHENTICATED"); + expect(ErrUnauthenticated("invalid token").message).toContain("invalid token"); + }); + + it("ErrApi captures status + detail", () => { + const err = ErrApi(503, "upstream timeout"); + expect(err.code).toBe("API_ERROR"); + expect(err.message).toContain("503"); + expect(err.message).toContain("upstream timeout"); + }); + + it("isAuthError narrows properly", () => { + expect(isAuthError(ErrNotConfigured())).toBe(true); + expect(isAuthError(new Error("plain"))).toBe(false); + expect(isAuthError(null)).toBe(false); + expect(isAuthError("string")).toBe(false); + }); +}); diff --git a/packages/cli/src/auth/errors.ts b/packages/cli/src/auth/errors.ts new file mode 100644 index 000000000..8f45b87bb --- /dev/null +++ b/packages/cli/src/auth/errors.ts @@ -0,0 +1,46 @@ +/** + * Typed errors for the auth layer. Callers branch on `code` so commands + * can map specific failures to friendly UX without parsing messages. + */ + +export type AuthErrorCode = "NOT_CONFIGURED" | "INVALID_STORE" | "API_ERROR" | "UNAUTHENTICATED"; + +export class AuthError extends Error { + readonly code: AuthErrorCode; + readonly hint?: string; + + constructor(code: AuthErrorCode, message: string, hint?: string) { + super(message); + this.name = "AuthError"; + this.code = code; + this.hint = hint; + } +} + +export const ErrNotConfigured = () => + new AuthError( + "NOT_CONFIGURED", + "No HeyGen credentials found", + "Run `hyperframes auth login` to sign in.", + ); + +export const ErrInvalidStore = (detail: string) => + new AuthError( + "INVALID_STORE", + `Credential file is unreadable: ${detail}`, + "Delete ~/.heygen/credentials and run `hyperframes auth login` to re-create it.", + ); + +export const ErrUnauthenticated = (detail?: string) => + new AuthError( + "UNAUTHENTICATED", + detail ? `HeyGen rejected the credential: ${detail}` : "HeyGen rejected the credential", + "Run `hyperframes auth login` to re-authenticate.", + ); + +export const ErrApi = (status: number, detail: string) => + new AuthError("API_ERROR", `HeyGen API error (${status}): ${detail}`); + +export function isAuthError(err: unknown): err is AuthError { + return err instanceof AuthError; +} diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts new file mode 100644 index 000000000..1f562e1d7 --- /dev/null +++ b/packages/cli/src/auth/index.ts @@ -0,0 +1,17 @@ +/** + * Public surface of the auth library — only the symbols the auth + * commands consume today. Internal types stay in their source files. + */ + +export { isAuthError } from "./errors.js"; + +export { clearOAuth, deleteStore, isHeaderSafe, readStore, writeStore } from "./store.js"; +export type { Credentials } from "./store.js"; + +export { configDir, credentialPath } from "./paths.js"; + +export { tryResolveCredential } from "./resolver.js"; +export type { ResolvedCredential } from "./resolver.js"; + +export { AuthClient } from "./client.js"; +export type { UserInfo } from "./client.js"; diff --git a/packages/cli/src/auth/paths.test.ts b/packages/cli/src/auth/paths.test.ts new file mode 100644 index 000000000..ec78ef455 --- /dev/null +++ b/packages/cli/src/auth/paths.test.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { CREDENTIAL_FILENAME, configDir, credentialPath } from "./paths.js"; + +describe("auth/paths", () => { + const original = process.env["HEYGEN_CONFIG_DIR"]; + + beforeEach(() => { + delete process.env["HEYGEN_CONFIG_DIR"]; + }); + + afterEach(() => { + if (original !== undefined) process.env["HEYGEN_CONFIG_DIR"] = original; + else delete process.env["HEYGEN_CONFIG_DIR"]; + }); + + it("defaults to ~/.heygen", () => { + expect(configDir()).toBe(join(homedir(), ".heygen")); + }); + + it("honors HEYGEN_CONFIG_DIR override", () => { + process.env["HEYGEN_CONFIG_DIR"] = "/tmp/some-test-dir"; + expect(configDir()).toBe("/tmp/some-test-dir"); + expect(credentialPath()).toBe(join("/tmp/some-test-dir", CREDENTIAL_FILENAME)); + }); + + it("treats empty HEYGEN_CONFIG_DIR as unset", () => { + process.env["HEYGEN_CONFIG_DIR"] = ""; + expect(configDir()).toBe(join(homedir(), ".heygen")); + }); +}); diff --git a/packages/cli/src/auth/paths.ts b/packages/cli/src/auth/paths.ts new file mode 100644 index 000000000..3df82f4fc --- /dev/null +++ b/packages/cli/src/auth/paths.ts @@ -0,0 +1,25 @@ +/** + * Filesystem layout for the shared HeyGen credential store. Mirrors + * `heygen-cli/internal/paths/paths.go` so both CLIs read the same file. + * `HEYGEN_CONFIG_DIR` overrides the directory. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Filename for the credential store. Matches heygen-cli (no `.json` + * suffix) so a `~/.heygen/credentials` written by either CLI is + * readable by the other — see `heygen-cli/internal/auth/file_resolver.go`. + */ +export const CREDENTIAL_FILENAME = "credentials"; + +export function configDir(): string { + const override = process.env["HEYGEN_CONFIG_DIR"]; + if (override && override.length > 0) return override; + return join(homedir(), ".heygen"); +} + +export function credentialPath(): string { + return join(configDir(), CREDENTIAL_FILENAME); +} diff --git a/packages/cli/src/auth/resolver.test.ts b/packages/cli/src/auth/resolver.test.ts new file mode 100644 index 000000000..eb8a062a0 --- /dev/null +++ b/packages/cli/src/auth/resolver.test.ts @@ -0,0 +1,158 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { isAuthError } from "./errors.js"; +import { resolveCredential, tryResolveCredential } from "./resolver.js"; +import { writeStore } from "./store.js"; + +async function makeTmpDir(): Promise { + return fs.mkdtemp(join(tmpdir(), "hf-auth-resolve-")); +} + +const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const; + +describe("auth/resolver", () => { + let dir: string; + const saved: Partial> = {}; + + beforeEach(async () => { + dir = await makeTmpDir(); + for (const k of ENV_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env["HEYGEN_CONFIG_DIR"] = dir; + }); + + afterEach(async () => { + for (const k of ENV_KEYS) { + const v = saved[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("prefers HEYGEN_API_KEY over everything else", async () => { + process.env["HEYGEN_API_KEY"] = "env-key"; + process.env["HYPERFRAMES_API_KEY"] = "alias-key"; + await writeStore({ api_key: "file-key" }); + const r = await resolveCredential(); + expect(r).toEqual({ type: "api_key", key: "env-key", source: "env" }); + }); + + it("falls through to HYPERFRAMES_API_KEY", async () => { + process.env["HYPERFRAMES_API_KEY"] = "alias-key"; + await writeStore({ api_key: "file-key" }); + const r = await resolveCredential(); + expect(r).toEqual({ type: "api_key", key: "alias-key", source: "env_alias" }); + }); + + it("returns file api_key when no env is set", async () => { + await writeStore({ api_key: "file-key" }); + const r = await resolveCredential(); + expect(r.type).toBe("api_key"); + if (r.type === "api_key") { + expect(r.key).toBe("file-key"); + expect(r.source).toBe("file_json"); + } + }); + + it("prefers fresh oauth over api_key", async () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + await writeStore({ + api_key: "file-key", + oauth: { access_token: "fresh-at", refresh_token: "rt", expires_at: future }, + }); + const r = await resolveCredential(); + expect(r.type).toBe("oauth"); + if (r.type === "oauth") { + expect(r.access_token).toBe("fresh-at"); + // Fresh access_token does NOT need refresh, even with refresh_token present. + expect(r.refreshable).toBe(false); + } + }); + + it("oauth without expires_at is treated as fresh (refreshable=false)", async () => { + await writeStore({ + oauth: { access_token: "at", refresh_token: "rt" }, + }); + const r = await resolveCredential(); + expect(r.type).toBe("oauth"); + if (r.type === "oauth") expect(r.refreshable).toBe(false); + }); + + it("marks expired-but-refreshable oauth as refreshable", async () => { + const past = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + await writeStore({ + oauth: { access_token: "stale-at", refresh_token: "rt", expires_at: past }, + }); + const r = await resolveCredential(); + expect(r.type).toBe("oauth"); + if (r.type === "oauth") expect(r.refreshable).toBe(true); + }); + + it("skips expired non-refreshable oauth and falls through to api_key", async () => { + const past = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + await writeStore({ + api_key: "fallback", + oauth: { access_token: "stale-at", expires_at: past }, + }); + const r = await resolveCredential(); + expect(r.type).toBe("api_key"); + if (r.type === "api_key") expect(r.key).toBe("fallback"); + }); + + it("rejects HEYGEN_API_KEY containing CRLF (header-injection guard)", async () => { + process.env["HEYGEN_API_KEY"] = "hg_x\r\nX-Evil: 1"; + await expect(resolveCredential()).rejects.toSatisfy((err) => { + return isAuthError(err) && (err as { code: string }).code === "INVALID_STORE"; + }); + }); + + it("throws ErrNotConfigured when nothing is configured", async () => { + await expect(resolveCredential()).rejects.toSatisfy((err) => { + return isAuthError(err) && (err as { code: string }).code === "NOT_CONFIGURED"; + }); + }); + + it("identifies legacy plaintext file source", async () => { + const path = join(dir, "credentials"); + await fs.writeFile(path, "hg_legacy_key", { mode: 0o600 }); + const r = await resolveCredential(); + expect(r.type).toBe("api_key"); + if (r.type === "api_key") { + expect(r.key).toBe("hg_legacy_key"); + expect(r.source).toBe("file_legacy"); + } + }); + + it("tryResolveCredential returns null when not configured", async () => { + expect(await tryResolveCredential()).toBeNull(); + }); + + it("tryResolveCredential surfaces broken-file errors", async () => { + const path = join(dir, "credentials"); + await fs.writeFile(path, "{not valid", { mode: 0o600 }); + await expect(tryResolveCredential()).rejects.toSatisfy((err) => isAuthError(err)); + }); + + it("uses injected now() for expiry decisions", async () => { + // expires_at is one hour ago in real time. Injecting `now` two + // hours in the past makes the token appear fresh (still valid for + // another hour), so the resolver should NOT mark it refreshable. + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + await writeStore({ + oauth: { access_token: "at", refresh_token: "rt", expires_at: oneHourAgo }, + }); + const r = await resolveCredential({ + now: () => new Date(Date.now() - 2 * 60 * 60 * 1000), + }); + expect(r.type).toBe("oauth"); + if (r.type === "oauth") { + expect(r.access_token).toBe("at"); + expect(r.refreshable).toBe(false); + } + }); +}); diff --git a/packages/cli/src/auth/resolver.ts b/packages/cli/src/auth/resolver.ts new file mode 100644 index 000000000..6b0ca0f11 --- /dev/null +++ b/packages/cli/src/auth/resolver.ts @@ -0,0 +1,123 @@ +/** + * Chain resolver for HeyGen credentials. + * + * Priority — first non-empty wins: + * 1. `HEYGEN_API_KEY` env (matches heygen-cli) + * 2. `HYPERFRAMES_API_KEY` env (alias for parity with other tools) + * 3. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key + * + * Absent sources fall through. A broken file (parse error, bad shape) + * surfaces immediately as `ErrInvalidStore` — silently falling back + * would mask user config bugs. + * + * Expiry policy: an OAuth access_token whose `expires_at` is in the + * past (60s skew) is considered expired. If a `refresh_token` is also + * present, callers can still use it via `refreshable: true`. Otherwise + * the api_key (if any) wins. + */ + +import { isHeaderSafe, readStore } from "./store.js"; +import { ErrInvalidStore, ErrNotConfigured, isAuthError } from "./errors.js"; + +type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy"; + +interface ApiKeyCredential { + type: "api_key"; + key: string; + source: CredentialSource; +} + +interface OAuthCredential { + type: "oauth"; + access_token: string; + refresh_token?: string; + expires_at?: Date; + scope?: string; + source: CredentialSource; + /** True when the access_token is expired but a refresh_token exists. */ + refreshable: boolean; +} + +export type ResolvedCredential = ApiKeyCredential | OAuthCredential; + +const EXPIRY_SKEW_MS = 60 * 1000; + +export interface ResolveOptions { + now?: () => Date; +} + +export async function resolveCredential(opts: ResolveOptions = {}): Promise { + const now = (opts.now ?? (() => new Date()))(); + + const heygenEnv = process.env["HEYGEN_API_KEY"]; + if (heygenEnv && heygenEnv.length > 0) { + if (!isHeaderSafe(heygenEnv)) { + throw ErrInvalidStore("HEYGEN_API_KEY contains control characters"); + } + return { type: "api_key", key: heygenEnv, source: "env" }; + } + + const hfEnv = process.env["HYPERFRAMES_API_KEY"]; + if (hfEnv && hfEnv.length > 0) { + if (!isHeaderSafe(hfEnv)) { + throw ErrInvalidStore("HYPERFRAMES_API_KEY contains control characters"); + } + return { type: "api_key", key: hfEnv, source: "env_alias" }; + } + + const { credentials, source } = await readStore(); + if (source === "absent") throw ErrNotConfigured(); + + const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json"; + + if (credentials.oauth) { + const oauth = pickOAuth(credentials.oauth, now, fileSource); + if (oauth) return oauth; + } + if (credentials.api_key) { + return { type: "api_key", key: credentials.api_key, source: fileSource }; + } + throw ErrNotConfigured(); +} + +/** Like `resolveCredential` but returns `null` instead of throwing `NOT_CONFIGURED`. */ +export async function tryResolveCredential( + opts: ResolveOptions = {}, +): Promise { + try { + return await resolveCredential(opts); + } catch (err) { + if (isAuthError(err) && err.code === "NOT_CONFIGURED") { + return null; + } + throw err; + } +} + +function pickOAuth( + tokens: NonNullable>["credentials"]["oauth"]>, + now: Date, + source: CredentialSource, +): OAuthCredential | null { + const expiresAt = parseDate(tokens.expires_at); + const expired = expiresAt !== undefined && expiresAt.getTime() - EXPIRY_SKEW_MS < now.getTime(); + + if (expired && !tokens.refresh_token) return null; + + const out: OAuthCredential = { + type: "oauth", + access_token: tokens.access_token, + source, + refreshable: expired && tokens.refresh_token !== undefined, + }; + if (tokens.refresh_token) out.refresh_token = tokens.refresh_token; + if (expiresAt) out.expires_at = expiresAt; + if (tokens.scope) out.scope = tokens.scope; + return out; +} + +function parseDate(s: string | undefined): Date | undefined { + if (!s) return undefined; + const d = new Date(s); + return Number.isNaN(d.getTime()) ? undefined : d; +} diff --git a/packages/cli/src/auth/store.test.ts b/packages/cli/src/auth/store.test.ts new file mode 100644 index 000000000..68b51590d --- /dev/null +++ b/packages/cli/src/auth/store.test.ts @@ -0,0 +1,189 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { isAuthError } from "./errors.js"; +import { clearOAuth, deleteStore, readStore, writeStore, type Credentials } from "./store.js"; + +async function makeTmpDir(): Promise { + return fs.mkdtemp(join(tmpdir(), "hf-auth-store-")); +} + +// POSIX file modes don't apply on Windows — `fs.chmod` only toggles the +// read-only bit there, so `stat.mode & 0o777` reports 0o666/0o444 +// regardless of what we requested. Skip the mode assertions on win32; +// the 0600/0700 hardening is a Unix concern. +const IS_POSIX = process.platform !== "win32"; + +describe("auth/store", () => { + let dir: string; + let path: string; + + beforeEach(async () => { + dir = await makeTmpDir(); + path = join(dir, "credentials"); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("returns absent when the file does not exist", async () => { + const result = await readStore(path); + expect(result).toEqual({ credentials: {}, source: "absent" }); + }); + + it("round-trips api_key only", async () => { + const creds: Credentials = { api_key: "hg_test_abc" }; + await writeStore(creds, path); + const result = await readStore(path); + expect(result.source).toBe("file_json"); + expect(result.credentials).toEqual(creds); + }); + + it("round-trips oauth tokens", async () => { + const creds: Credentials = { + oauth: { + access_token: "at_123", + refresh_token: "rt_456", + expires_at: "2026-06-25T12:00:00.000Z", + scope: "openid profile", + token_type: "Bearer", + }, + }; + await writeStore(creds, path); + const result = await readStore(path); + expect(result.credentials).toEqual(creds); + }); + + it("round-trips both api_key and oauth", async () => { + const creds: Credentials = { + api_key: "hg_test_abc", + oauth: { access_token: "at_123" }, + }; + await writeStore(creds, path); + const result = await readStore(path); + expect(result.credentials.api_key).toBe("hg_test_abc"); + expect(result.credentials.oauth?.access_token).toBe("at_123"); + }); + + it("reads legacy one-line plaintext format", async () => { + await fs.writeFile(path, "hg_legacy_key\n", { mode: 0o600 }); + const result = await readStore(path); + expect(result.source).toBe("file_legacy"); + expect(result.credentials.api_key).toBe("hg_legacy_key"); + }); + + it("treats empty file as absent", async () => { + await fs.writeFile(path, "", { mode: 0o600 }); + const result = await readStore(path); + expect(result.source).toBe("absent"); + }); + + it("throws ErrInvalidStore on garbage JSON", async () => { + await fs.writeFile(path, "{not valid json", { mode: 0o600 }); + await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err)); + }); + + it("throws ErrInvalidStore on multi-line non-JSON content", async () => { + await fs.writeFile(path, "not\na\nkey", { mode: 0o600 }); + await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err)); + }); + + it.skipIf(!IS_POSIX)("writes file 0600 and dir 0700", async () => { + const nested = join(dir, "sub", "deeper"); + const p = join(nested, "credentials"); + await writeStore({ api_key: "hg_x" }, p); + expect((await fs.stat(p)).mode & 0o777).toBe(0o600); + expect((await fs.stat(nested)).mode & 0o777).toBe(0o700); + }); + + it("preserves content across overwrites", async () => { + await writeStore({ api_key: "first" }, path); + await writeStore({ api_key: "second" }, path); + if (IS_POSIX) { + expect((await fs.stat(path)).mode & 0o777).toBe(0o600); + } + const result = await readStore(path); + expect(result.credentials.api_key).toBe("second"); + }); + + it("rejects empty-string api_key", async () => { + await fs.writeFile(path, JSON.stringify({ api_key: "" }), { mode: 0o600 }); + await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err)); + }); + + it("rejects api_key with CR/LF (header-injection guard)", async () => { + await fs.writeFile(path, JSON.stringify({ api_key: "hg_x\r\nX-Evil: foo" }), { mode: 0o600 }); + await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err)); + }); + + it("strips oauth fields containing CR/LF rather than crashing later", async () => { + await fs.writeFile( + path, + JSON.stringify({ + oauth: { + access_token: "good_at", + refresh_token: "bad_rt\r\nX-Smuggle: 1", + }, + }), + { mode: 0o600 }, + ); + const result = await readStore(path); + expect(result.credentials.oauth?.access_token).toBe("good_at"); + expect(result.credentials.oauth?.refresh_token).toBeUndefined(); + }); + + it("rejects access_token containing CR/LF (header-injection guard)", async () => { + await fs.writeFile(path, JSON.stringify({ oauth: { access_token: "at\r\nX-Evil: 1" } }), { + mode: 0o600, + }); + await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err)); + }); + + it("rejects legacy plaintext that doesn't match the HeyGen key shape", async () => { + await fs.writeFile(path, "not-a-hg-key-just-garbage", { mode: 0o600 }); + await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err)); + }); + + it("rejects oauth without access_token", async () => { + await fs.writeFile(path, JSON.stringify({ oauth: { refresh_token: "rt" } }), { + mode: 0o600, + }); + await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err)); + }); + + it("drops unknown top-level keys", async () => { + await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: { stuff: 1 } }), { + mode: 0o600, + }); + const result = await readStore(path); + expect(result.credentials).toEqual({ api_key: "hg_x" }); + }); + + it("deleteStore is idempotent", async () => { + await writeStore({ api_key: "hg_x" }, path); + await deleteStore(path); + await deleteStore(path); + await expect(fs.access(path)).rejects.toThrow(); + }); + + it("clearOAuth removes only the oauth field", async () => { + await writeStore({ api_key: "hg_keep", oauth: { access_token: "drop_me" } }, path); + await clearOAuth(path); + const result = await readStore(path); + expect(result.credentials.oauth).toBeUndefined(); + expect(result.credentials.api_key).toBe("hg_keep"); + }); + + it("clearOAuth removes the whole file when no api_key remains", async () => { + await writeStore({ oauth: { access_token: "only" } }, path); + await clearOAuth(path); + await expect(fs.access(path)).rejects.toThrow(); + }); + + it("clearOAuth is a no-op when file is absent", async () => { + await clearOAuth(path); + await expect(fs.access(path)).rejects.toThrow(); + }); +}); diff --git a/packages/cli/src/auth/store.ts b/packages/cli/src/auth/store.ts new file mode 100644 index 000000000..5a41bd38a --- /dev/null +++ b/packages/cli/src/auth/store.ts @@ -0,0 +1,249 @@ +/** + * Read/write the shared `~/.heygen/credentials` file (JSON contents, + * no `.json` extension — the path matches heygen-cli). + * + * Current format: + * { + * "api_key": "hg_...", + * "oauth": { + * "access_token": "...", + * "refresh_token": "...", + * "expires_at": "2026-06-25T12:00:00Z", + * "scope": "openid profile", + * "token_type": "Bearer" + * } + * } + * + * Legacy: a single-line plaintext API key (the format heygen-cli has + * written historically). If `JSON.parse` rejects the file, we treat the + * trimmed contents as an API key; the next write upgrades to JSON. + * + * Writes go to a temp file + rename, 0600 mode, parent dir 0700. + */ + +import { promises as fs } from "node:fs"; +import { dirname } from "node:path"; +import { credentialPath } from "./paths.js"; +import { ErrInvalidStore } from "./errors.js"; + +const FILE_MODE = 0o600; +const DIR_MODE = 0o700; + +export interface OAuthTokens { + access_token: string; + refresh_token?: string; + /** ISO-8601 UTC. */ + expires_at?: string; + scope?: string; + token_type?: string; +} + +export interface Credentials { + api_key?: string; + oauth?: OAuthTokens; +} + +export type StoreSource = "file_json" | "file_legacy" | "absent"; + +export interface ReadResult { + credentials: Credentials; + source: StoreSource; +} + +export async function readStore(path = credentialPath()): Promise { + let raw: string; + try { + raw = await fs.readFile(path, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return { credentials: {}, source: "absent" }; + } + throw ErrInvalidStore(`unable to read ${path}: ${(err as Error).message}`); + } + + const trimmed = raw.trim(); + if (trimmed.length === 0) return { credentials: {}, source: "absent" }; + + if (trimmed.startsWith("{")) { + return { credentials: parseJsonStore(trimmed), source: "file_json" }; + } + + if (looksLikeApiKey(trimmed)) { + return { credentials: { api_key: trimmed }, source: "file_legacy" }; + } + + throw ErrInvalidStore("file is not JSON and does not look like a plain API key"); +} + +export async function writeStore(credentials: Credentials, path = credentialPath()): Promise { + await ensureDir(dirname(path)); + const body = JSON.stringify(serializeCredentials(credentials), null, 2); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tmp, `${body}\n`, { mode: FILE_MODE, encoding: "utf8" }); + // `mode` on `writeFile` is masked by umask and only applies on file + // creation — explicit chmod is the only reliable way to land on 0600. + // `rename` moves the (already-0600) tmp inode over the destination, + // so the final file carries the tmp's mode; no post-rename chmod + // needed even when overwriting a looser-permissioned file. + await fs.chmod(tmp, FILE_MODE); + await fs.rename(tmp, path); +} + +export async function deleteStore(path = credentialPath()): Promise { + try { + await fs.unlink(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return; + throw err; + } +} + +/** Remove only the `oauth` block. Used by `auth logout --keep-api-key`. */ +export async function clearOAuth(path = credentialPath()): Promise { + const { credentials, source } = await readStore(path); + if (source === "absent" || !credentials.oauth) return; + if (!credentials.api_key) { + await deleteStore(path); + return; + } + await writeStore({ api_key: credentials.api_key }, path); +} + +async function ensureDir(dir: string): Promise { + try { + const stat = await fs.stat(dir); + if (!stat.isDirectory()) { + throw ErrInvalidStore(`${dir} exists and is not a directory`); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + await fs.mkdir(dir, { recursive: true, mode: DIR_MODE }); + } + try { + await fs.chmod(dir, DIR_MODE); + } catch { + /* perm-less filesystems are fine */ + } +} + +function parseJsonStore(text: string): Credentials { + const obj = parseJsonObject(text, "credential file root"); + const out: Credentials = {}; + const apiKey = pickRequiredStringOrAbsent(obj, "api_key", "api_key"); + if (apiKey !== undefined) { + if (!isHeaderSafe(apiKey)) { + throw ErrInvalidStore("api_key must not contain control characters"); + } + out.api_key = apiKey; + } + if (obj["oauth"] !== undefined && obj["oauth"] !== null) { + out.oauth = parseOAuth(obj["oauth"]); + } + return out; +} + +function parseOAuth(raw: unknown): OAuthTokens { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw ErrInvalidStore("oauth must be a JSON object"); + } + const obj = raw as Record; + const accessToken = pickHeaderSafeString(obj, "access_token"); + if (!accessToken) { + throw ErrInvalidStore("oauth.access_token must be a non-empty string with no control chars"); + } + const out: OAuthTokens = { access_token: accessToken }; + const refresh = pickHeaderSafeString(obj, "refresh_token"); + if (refresh) out.refresh_token = refresh; + const exp = pickNonEmptyString(obj, "expires_at"); + if (exp) out.expires_at = exp; + const scope = pickNonEmptyString(obj, "scope"); + if (scope) out.scope = scope; + const tokenType = pickNonEmptyString(obj, "token_type"); + if (tokenType) out.token_type = tokenType; + return out; +} + +function parseJsonObject(text: string, label: string): Record { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (err) { + throw ErrInvalidStore(`invalid JSON: ${(err as Error).message}`); + } + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw ErrInvalidStore(`${label} must be a JSON object`); + } + return raw as Record; +} + +function pickNonEmptyString(obj: Record, key: string): string | undefined { + const v = obj[key]; + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +/** Like `pickNonEmptyString` but rejects values containing control chars. */ +function pickHeaderSafeString(obj: Record, key: string): string | undefined { + const v = pickNonEmptyString(obj, key); + return v !== undefined && isHeaderSafe(v) ? v : undefined; +} + +/** + * Header-safety check for credential strings: reject any string with + * CR, LF, NUL, or other C0 control characters. Without this, a + * malicious credentials.json could smuggle extra request headers via + * `Authorization` / `x-api-key` (RFC 7230 header injection). + */ +export function isHeaderSafe(s: string): boolean { + // Reject U+0000-U+001F (C0 controls) and U+007F (DEL) — bytes that + // aren't allowed in HTTP header values. Using charCodeAt avoids + // embedding control characters in regex source (lint requirement). + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c < 0x20 || c === 0x7f) return false; + } + return true; +} + +/** + * Strict variant: returns the string when present and non-empty, + * `undefined` when the key is absent or null, and throws when the + * field is present-but-invalid (wrong type or empty string). + */ +function pickRequiredStringOrAbsent( + obj: Record, + key: string, + errorLabel: string, +): string | undefined { + const v = obj[key]; + if (v === undefined || v === null) return undefined; + if (typeof v !== "string" || v.length === 0) { + throw ErrInvalidStore(`${errorLabel} must be a non-empty string`); + } + return v; +} + +function serializeCredentials(c: Credentials): Record { + const out: Record = {}; + if (c.api_key) out["api_key"] = c.api_key; + if (c.oauth) { + const oauth: Record = { access_token: c.oauth.access_token }; + if (c.oauth.refresh_token) oauth["refresh_token"] = c.oauth.refresh_token; + if (c.oauth.expires_at) oauth["expires_at"] = c.oauth.expires_at; + if (c.oauth.scope) oauth["scope"] = c.oauth.scope; + if (c.oauth.token_type) oauth["token_type"] = c.oauth.token_type; + out["oauth"] = oauth; + } + return out; +} + +/** + * Tight legacy-plaintext heuristic. HeyGen API keys start with `hg_` + * (e.g. `hg_...`) and only contain URL-safe characters. We deliberately + * accept ONLY those — previously we accepted any printable ASCII 8+ + * chars, which silently lifted JSON fragments that had lost their + * leading `{`, other products' API keys, or pasted Stripe / GitHub + * tokens into the credential slot. + */ +function looksLikeApiKey(s: string): boolean { + return /^hg_[A-Za-z0-9_-]{5,}$/.test(s); +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2dd147f14..4c6bc35f5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -124,6 +124,7 @@ const subCommands = { snapshot: () => import("./commands/snapshot.js").then((m) => m.default), capture: () => import("./commands/capture.js").then((m) => m.default), lambda: () => import("./commands/lambda.js").then((m) => m.default), + auth: () => import("./commands/auth.js").then((m) => m.default), }; const main = defineCommand({ diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts new file mode 100644 index 000000000..549f31bf9 --- /dev/null +++ b/packages/cli/src/commands/auth.ts @@ -0,0 +1,53 @@ +/** + * `hyperframes auth` — credential management for HeyGen. + * + * Subverbs: + * - `login` sign in via API key (OAuth coming next) + * - `status` show the active credential + identity + * - `logout` remove the stored credential + * + * Each subverb lives in `./auth/.ts` and is dynamic-imported on + * demand. Keeps cold-start fast and lets the auth library load only + * when the user is doing auth work. + */ + +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { c } from "../ui/colors.js"; + +export const examples: Example[] = [ + ["Save an API key (interactive)", "hyperframes auth login --api-key"], + ["Save an API key from stdin", "echo $HEYGEN_API_KEY | hyperframes auth login --api-key"], + ["Check who you're signed in as", "hyperframes auth status"], + ["Sign out", "hyperframes auth logout"], +]; + +const HELP = ` +${c.bold("hyperframes auth")} ${c.dim(" [args]")} + +Manage HeyGen credentials. Credentials live in +${c.accent("~/.heygen/credentials")} and are shared with heygen-cli. + +${c.bold("SUBCOMMANDS:")} + ${c.accent("login")} ${c.dim("Save a HeyGen API key (--api-key). OAuth login lands in a follow-up.")} + ${c.accent("status")} ${c.dim("Show the active credential's source, type, and identity.")} + ${c.accent("logout")} ${c.dim("Remove the stored credential (--keep-api-key for OAuth-only).")} + +${c.bold("ENV VARS:")} + ${c.accent("HEYGEN_API_KEY")} Override the stored credential. + ${c.accent("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY. + ${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com). + ${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen). +`; + +export default defineCommand({ + meta: { name: "auth", description: "Sign in to HeyGen and manage credentials" }, + subCommands: { + login: () => import("./auth/login.js").then((m) => m.default), + status: () => import("./auth/status.js").then((m) => m.default), + logout: () => import("./auth/logout.js").then((m) => m.default), + }, + async run({ args }) { + if (!args._?.[0]) console.log(HELP); + }, +}); diff --git a/packages/cli/src/commands/auth/login.test.ts b/packages/cli/src/commands/auth/login.test.ts new file mode 100644 index 000000000..deaf2ac6c --- /dev/null +++ b/packages/cli/src/commands/auth/login.test.ts @@ -0,0 +1,91 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { readStore, writeStore } from "../../auth/store.js"; + +// Mock only AuthClient — keep the real store/resolver so the test +// exercises the actual on-disk rollback behavior. `verifyResult` +// controls what `getCurrentUser` does per test. +const verifyState = vi.hoisted(() => ({ reject: false })); + +vi.mock("../../auth/index.js", async (orig) => { + const actual = await orig(); + class MockAuthClient { + async getCurrentUser(): Promise<{ email: string }> { + if (verifyState.reject) { + const { ErrUnauthenticated: rej } = await import("../../auth/errors.js"); + throw rej("invalid key"); + } + return { email: "alice@example.com" }; + } + } + return { ...actual, AuthClient: MockAuthClient }; +}); + +const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const; + +describe("auth login --api-key rollback", () => { + let dir: string; + const saved: Partial> = {}; + + beforeEach(async () => { + dir = await fs.mkdtemp(join(tmpdir(), "hf-login-")); + for (const k of ENV_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env["HEYGEN_CONFIG_DIR"] = dir; + verifyState.reject = false; + // process.exit throws so we can assert the post-rollback state. + vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + for (const k of ENV_KEYS) { + const v = saved[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + await fs.rm(dir, { recursive: true, force: true }); + }); + + async function runLogin(apiKey: string): Promise { + const cmd = (await import("./login.js")).default; + // citty command run only reads `args` here. + await (cmd.run as (ctx: { args: Record }) => Promise)({ + args: { "api-key": apiKey }, + }); + } + + it("removes the rejected key on a failed FIRST login (no prior credential)", async () => { + verifyState.reject = true; + await expect(runLogin("hg_badkey123")).rejects.toThrow(/process\.exit:1/); + + // The store must NOT retain the rejected key — otherwise the next + // command would silently resolve a known-bad credential. + const { source } = await readStore(); + expect(source).toBe("absent"); + }); + + it("restores the previous credential on a failed re-login", async () => { + await writeStore({ api_key: "hg_previous_good" }); + verifyState.reject = true; + await expect(runLogin("hg_newbadkey99")).rejects.toThrow(/process\.exit:1/); + + const { credentials } = await readStore(); + expect(credentials.api_key).toBe("hg_previous_good"); + }); + + it("persists the key on a successful login", async () => { + verifyState.reject = false; + await runLogin("hg_goodkey456"); + const { credentials } = await readStore(); + expect(credentials.api_key).toBe("hg_goodkey456"); + }); +}); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts new file mode 100644 index 000000000..2f8f82bc6 --- /dev/null +++ b/packages/cli/src/commands/auth/login.ts @@ -0,0 +1,203 @@ +/** + * `hyperframes auth login` — write a HeyGen credential to + * `~/.heygen/credentials`. + * + * This first cut ships the `--api-key` path only. Running `auth login` + * without `--api-key` prints a pointer at the OAuth PKCE work that + * lands in a follow-up. + * + * Inputs: + * - `--api-key=` — take the value inline (note: may leak into + * shell history). + * - `--api-key` with stdin piped — read one line from stdin. + * - `--api-key` interactive — `@clack/prompts` password input. + * + * Write semantics: + * - Read the existing credential file first; preserve any `oauth` + * block so saving a new API key doesn't wipe an OAuth session. + * - Validate the new key looks like a HeyGen API key (`hg_…`) before + * touching disk. Garbage never lands in the store. + * - Verify via `GET /v3/users/me`. On 401, roll back to the previous + * state — leaving a confirmed-invalid key on disk would silently + * break subsequent commands. On other errors (network blip, 5xx) + * keep the new key so retries don't require re-typing. + */ + +import { defineCommand } from "citty"; +import { stdin as input } from "node:process"; +import { + AuthClient, + deleteStore, + isAuthError, + isHeaderSafe, + readStore, + writeStore, + type Credentials, +} from "../../auth/index.js"; +import { c } from "../../ui/colors.js"; + +const API_KEY_SHAPE = /^hg_[A-Za-z0-9_-]{5,}$/; +const STDIN_TIMEOUT_MS = 30_000; + +export default defineCommand({ + meta: { + name: "login", + description: "Sign in to HeyGen by saving an API key (OAuth coming soon)", + }, + args: { + "api-key": { + type: "string", + description: + "API key value. Pass `--api-key` with no value to read from stdin or interactively.", + }, + }, + // fallow-ignore-next-line complexity + async run({ args }) { + const inlineKey = args["api-key"]; + if (inlineKey === undefined) { + printOAuthPlaceholder(); + process.exit(1); + } + + const key = await collectApiKey(inlineKey); + if (!key) { + console.error(c.error("No API key provided.")); + process.exit(1); + } + if (!API_KEY_SHAPE.test(key) || !isHeaderSafe(key)) { + console.error( + c.error("That doesn't look like a HeyGen API key — expected `hg_…` (URL-safe chars only)."), + ); + process.exit(1); + } + + const previous = await snapshotStore(); + const next: Credentials = { ...previous, api_key: key }; + await writeStore(next); + + const verifyOk = await verifyAndReport(key); + if (!verifyOk) { + await rollback(previous); + process.exit(1); + } + }, +}); + +function printOAuthPlaceholder(): void { + console.error( + `${c.warn("Browser-based login isn't ready yet.")} ` + + `Re-run with ${c.accent("--api-key")} to save an API key, ` + + `or pipe one in:\n` + + ` ${c.accent("echo $HEYGEN_API_KEY | hyperframes auth login --api-key")}`, + ); +} + +async function snapshotStore(): Promise { + try { + const { credentials } = await readStore(); + return { ...credentials }; + } catch { + // Existing file is unreadable; treat as empty so the new key still + // lands cleanly. The previous bytes are lost either way. + return {}; + } +} + +async function rollback(previous: Credentials): Promise { + try { + if (previous.api_key || previous.oauth) { + await writeStore(previous); + console.error(c.dim("Rolled back to the previous credential.")); + } else { + // No prior credential — restore true absence. Leaving the + // rejected key on disk would make the next `auth status` / + // command silently resolve a known-bad key. + await deleteStore(); + console.error(c.dim("Removed the rejected credential.")); + } + } catch (err) { + console.error(c.error(`Failed to roll back: ${(err as Error).message}`)); + } +} + +/** + * Returns `true` on successful verify, `false` on a 401. Other errors + * (network blip, 5xx) bubble out — the caller leaves the new key in + * place since the issue is transient. + */ +// fallow-ignore-next-line complexity +async function verifyAndReport(key: string): Promise { + const client = new AuthClient(); + try { + const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" }); + const identity = user.email ?? user.username ?? "(unknown user)"; + console.log(c.success(`✓ API key saved. Authenticated as ${identity}.`)); + return true; + } catch (err) { + if (isAuthError(err) && err.code === "UNAUTHENTICATED") { + console.error( + `${c.warn("HeyGen rejected the API key.")}\n` + + ` ${c.dim(err.message)}\n` + + `Run ${c.accent("hyperframes auth login --api-key")} again with a valid key.`, + ); + return false; + } + throw err; + } +} + +/** + * Citty's arg type for `--api-key` is `string`, so: + * - `--api-key=hg_x` → `"hg_x"` + * - `--api-key ""` / `--api-key` with no value → `""` → fall through + * to stdin/prompt. + */ +async function collectApiKey(inline: string): Promise { + if (inline.length > 0) return inline.trim(); + if (!input.isTTY) { + return (await readAllWithTimeout(input, STDIN_TIMEOUT_MS)).trim(); + } + return await promptForKey(); +} + +/** + * Read all of stdin, or bail with an empty string after `timeoutMs`. + * Hanging forever when stdin is non-TTY but unattached (Docker `-d`, + * some CI shells) is worse than a clear timeout. + */ +async function readAllWithTimeout( + stream: NodeJS.ReadableStream, + timeoutMs: number, +): Promise { + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const timer = setTimeout(() => { + reject(new Error(`Timed out waiting for stdin (${timeoutMs}ms). Pipe the key explicitly.`)); + }, timeoutMs); + stream.on("data", (chunk: Buffer | string) => { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + stream.on("end", () => { + clearTimeout(timer); + resolve(Buffer.concat(chunks).toString("utf8")); + }); + stream.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +async function promptForKey(): Promise { + const clack = await import("@clack/prompts"); + const value = await clack.password({ + message: "Enter HeyGen API key", + validate: (v) => + v && API_KEY_SHAPE.test(v) ? undefined : "Expected `hg_…` (URL-safe chars only)", + }); + if (clack.isCancel(value)) { + console.error("Aborted."); + process.exit(1); + } + return value.trim(); +} diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts new file mode 100644 index 000000000..4fcf6afa6 --- /dev/null +++ b/packages/cli/src/commands/auth/logout.ts @@ -0,0 +1,74 @@ +/** + * `hyperframes auth logout` — remove the credential file. With + * `--keep-api-key`, only the OAuth block is cleared (no-op for + * API-key-only stores). + * + * Env-only credentials (`HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`) can't + * be cleared by this command — we tell the user to unset them. + */ + +import { defineCommand } from "citty"; +import { clearOAuth, configDir, credentialPath, deleteStore } from "../../auth/index.js"; +import { c } from "../../ui/colors.js"; + +export default defineCommand({ + meta: { name: "logout", description: "Remove the stored HeyGen credential" }, + args: { + "keep-api-key": { + type: "boolean", + description: "Only clear the OAuth session; preserve the API key.", + default: false, + }, + yes: { + type: "boolean", + description: "Skip the confirmation prompt.", + default: false, + }, + }, + async run({ args }) { + warnIfEnvCredentialActive(); + const keepApiKey = Boolean(args["keep-api-key"]); + + if (!(await ensureConfirmed(Boolean(args.yes), keepApiKey))) { + console.log("Aborted."); + process.exit(1); + } + + if (keepApiKey) { + await clearOAuth(); + console.log(c.success("✓ OAuth session removed. API key retained.")); + return; + } + await deleteStore(); + console.log(c.success(`✓ Signed out. Removed ${credentialPath()}.`)); + }, +}); + +function warnIfEnvCredentialActive(): void { + if (process.env["HEYGEN_API_KEY"] || process.env["HYPERFRAMES_API_KEY"]) { + console.log( + c.warn( + "An env-var credential is active. Unset HEYGEN_API_KEY / HYPERFRAMES_API_KEY to remove it.", + ), + ); + } +} + +async function ensureConfirmed(yes: boolean, keepApiKey: boolean): Promise { + if (yes) return true; + const prompt = keepApiKey + ? `This will sign out of any active OAuth session on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] ` + : `This will sign out of HeyGen on this machine (~/.heygen lives at ${configDir()}). Continue? [y/N] `; + return confirmInteractive(prompt); +} + +async function confirmInteractive(prompt: string): Promise { + if (!process.stdin.isTTY) return false; + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question(prompt, (line) => resolve(line)); + }); + rl.close(); + return /^y(es)?$/i.test(answer.trim()); +} diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts new file mode 100644 index 000000000..bb13bde61 --- /dev/null +++ b/packages/cli/src/commands/auth/status.ts @@ -0,0 +1,189 @@ +/** + * `hyperframes auth status` — print the active credential's source, + * type, and identity (verified against `GET /v3/users/me`). + * + * Exits non-zero when nothing is configured or the API rejects the + * credential, so scripts can check "am I logged in?" with `$?`. + */ + +import { defineCommand } from "citty"; +import { + AuthClient, + isAuthError, + tryResolveCredential, + type ResolvedCredential, + type UserInfo, +} from "../../auth/index.js"; +import { c } from "../../ui/colors.js"; + +interface VerifiedStatus { + credential: ResolvedCredential; + user: UserInfo | null; + apiError: string | null; +} + +export default defineCommand({ + meta: { name: "status", description: "Show the active HeyGen credential" }, + args: { + json: { + type: "boolean", + description: "Emit machine-readable JSON", + default: false, + }, + }, + // fallow-ignore-next-line complexity + async run({ args }) { + const asJson = Boolean(args.json); + let credential; + try { + credential = await tryResolveCredential(); + } catch (err) { + handleResolveError(err, asJson); + return; + } + if (!credential) { + handleUnconfigured(asJson); + return; + } + + const status = await verify(credential); + if (asJson) printJsonStatus(status); + else printHumanStatus(status); + process.exit(status.apiError ? 1 : 0); + }, +}); + +function handleUnconfigured(asJson: boolean): never { + if (asJson) { + console.log(JSON.stringify({ configured: false })); + } else { + console.log(c.warn("Not signed in to HeyGen.")); + console.log(`Run ${c.accent("hyperframes auth login --api-key")} to sign in.`); + } + process.exit(1); +} + +// fallow-ignore-next-line complexity +function handleResolveError(err: unknown, asJson: boolean): never { + if (!isAuthError(err)) throw err; + if (asJson) { + console.log(JSON.stringify({ configured: false, error: err.message, hint: err.hint ?? null })); + } else { + console.error(c.error(err.message)); + if (err.hint) console.error(c.dim(err.hint)); + } + process.exit(1); +} + +async function verify(credential: ResolvedCredential): Promise { + const client = new AuthClient(); + try { + const user = await client.getCurrentUser(credential); + return { credential, user, apiError: null }; + } catch (err) { + if (!isAuthError(err)) throw err; + return { + credential, + user: null, + apiError: err instanceof Error ? err.message : String(err), + }; + } +} + +function printJsonStatus(s: VerifiedStatus): void { + const payload: Record = { + configured: true, + source: s.credential.source, + type: s.credential.type, + user: s.user, + api_error: s.apiError, + }; + if (s.credential.type === "oauth") { + payload["expires_at"] = s.credential.expires_at?.toISOString() ?? null; + payload["refreshable"] = s.credential.refreshable; + payload["scope"] = s.credential.scope ?? null; + } + console.log(JSON.stringify(payload, null, 2)); +} + +function printHumanStatus(s: VerifiedStatus): void { + const rows = collectStatusRows(s); + for (const [label, value] of rows) console.log(`${c.bold(label)} ${value}`); +} + +// fallow-ignore-next-line complexity +function collectStatusRows(s: VerifiedStatus): [string, string][] { + const rows: [string, string][] = [ + ["Source:", describeSource(s.credential.source)], + ["Type: ", s.credential.type === "oauth" ? "oauth" : "api_key"], + ]; + if (s.credential.type === "oauth") rows.push(...oauthRows(s.credential)); + if (s.apiError) { + rows.push([c.error("API check failed:"), s.apiError]); + return rows; + } + if (s.user) rows.push(...identityRows(s.user)); + return rows; +} + +// fallow-ignore-next-line complexity +function oauthRows(credential: Extract): [string, string][] { + const rows: [string, string][] = []; + if (credential.expires_at) { + const fresh = credential.expires_at.getTime() > Date.now(); + const tag = fresh ? c.success("(valid)") : c.warn("(expired)"); + const refresh = credential.refreshable ? c.dim(" · refreshable") : ""; + rows.push(["Expires:", `${credential.expires_at.toISOString()} ${tag}${refresh}`]); + } + if (credential.scope) rows.push(["Scope: ", credential.scope]); + return rows; +} + +function identityRows(user: UserInfo): [string, string][] { + const identity = user.email ?? user.username ?? "(unknown user)"; + return [["Account:", identity], ...billingRows(user)]; +} + +const SOURCE_LABELS: Record = { + env: "env (HEYGEN_API_KEY)", + env_alias: "env (HYPERFRAMES_API_KEY)", + file_legacy: "file (~/.heygen/credentials — legacy plaintext)", + file_json: "file (~/.heygen/credentials)", +}; + +function describeSource(source: ResolvedCredential["source"]): string { + return SOURCE_LABELS[source]; +} + +function billingRows(user: UserInfo): [string, string][] { + const rows: [string, string][] = []; + if (user.billing_type) rows.push(["Billing:", user.billing_type]); + pushWalletRow(rows, user); + pushSubscriptionRows(rows, user); + pushUsageRow(rows, user); + return rows; +} + +// fallow-ignore-next-line complexity +function pushWalletRow(rows: [string, string][], user: UserInfo): void { + const balance = user.wallet?.remaining_balance; + if (balance === undefined) return; + const currency = user.wallet?.currency ? ` ${user.wallet.currency}` : ""; + rows.push(["Wallet: ", `${balance}${currency}`]); +} + +// fallow-ignore-next-line complexity +function pushSubscriptionRows(rows: [string, string][], user: UserInfo): void { + if (user.subscription?.plan) rows.push(["Plan: ", user.subscription.plan]); + const premium = user.subscription?.credits?.premium_credits; + if (premium !== undefined) rows.push(["Premium credits:", String(premium)]); +} + +// fallow-ignore-next-line complexity +function pushUsageRow(rows: [string, string][], user: UserInfo): void { + const current = user.usage_based?.spending_current_usd; + if (current === undefined) return; + const cap = user.usage_based?.spending_cap_usd; + const capPart = cap !== undefined ? ` / $${cap}` : ""; + rows.push(["Usage: ", `$${current}${capPart}`]); +} diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index d9d0f7606..e4c156528 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -67,6 +67,10 @@ const GROUPS: Group[] = [ ["remove-background", "Remove background from a video or image to produce transparent media"], ], }, + { + title: "Account", + commands: [["auth", "Sign in to HeyGen and manage credentials"]], + }, { title: "Settings", commands: [["telemetry", "Manage anonymous usage telemetry"]],