diff --git a/packages/cli/src/auth/_test-utils.ts b/packages/cli/src/auth/_test-utils.ts new file mode 100644 index 0000000000..7fa9743188 --- /dev/null +++ b/packages/cli/src/auth/_test-utils.ts @@ -0,0 +1,54 @@ +/** + * Shared fixtures for auth-module tests. Centralises the env-snapshot + * + tmp-config-dir pattern so resolver.test.ts and oauth.test.ts don't + * each maintain a copy of the same beforeEach/afterEach plumbing. + * + * Only loaded by `*.test.ts` — runtime code doesn't depend on it. + */ + +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ENV_KEYS = [ + "HEYGEN_API_KEY", + "HYPERFRAMES_API_KEY", + "HEYGEN_CONFIG_DIR", + "HEYGEN_API_URL", + "HYPERFRAMES_OAUTH_CLIENT_ID", +] as const; + +type EnvKey = (typeof ENV_KEYS)[number]; + +export interface EnvFixture { + /** Tmp config dir; deleted on `restore()`. */ + dir: string; + /** Restore env + delete tmp dir. Idempotent. */ + restore: () => Promise; +} + +/** + * Take a snapshot of the auth-related env, clear them, make a tmp + * `HEYGEN_CONFIG_DIR`, and return a `restore()` that undoes all of + * the above. + */ +export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise { + const dir = await fs.mkdtemp(join(tmpdir(), prefix)); + const saved: Partial> = {}; + for (const k of ENV_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + process.env["HEYGEN_CONFIG_DIR"] = dir; + + const restore = async (): Promise => { + 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 }); + }; + + return { dir, restore }; +} diff --git a/packages/cli/src/auth/browser.ts b/packages/cli/src/auth/browser.ts new file mode 100644 index 0000000000..fab036307e --- /dev/null +++ b/packages/cli/src/auth/browser.ts @@ -0,0 +1,36 @@ +/** + * Open a URL in the user's default browser. Falls back to printing the + * URL when no browser is openable (SSH session, CI, `BROWSER=none`, + * or `open` rejects). + */ + +import { c } from "../ui/colors.js"; + +export interface OpenBrowserResult { + /** True when we successfully invoked the platform "open" command. */ + opened: boolean; +} + +export async function openBrowser(url: string): Promise { + if (process.env["BROWSER"] === "none" || process.env["HF_NO_BROWSER"] === "1") { + printManualInstructions(url); + return { opened: false }; + } + try { + const open = (await import("open")).default; + await open(url); + return { opened: true }; + } catch (err) { + printManualInstructions(url, err instanceof Error ? err.message : String(err)); + return { opened: false }; + } +} + +function printManualInstructions(url: string, detail?: string): void { + if (detail) { + console.error(c.warn(`Could not open browser automatically (${detail}).`)); + } else { + console.error(c.warn("Browser auto-open is disabled.")); + } + console.error(`Open this URL manually to continue:\n ${c.accent(url)}`); +} diff --git a/packages/cli/src/auth/client.test.ts b/packages/cli/src/auth/client.test.ts index 02956ee667..8746d78cf0 100644 --- a/packages/cli/src/auth/client.test.ts +++ b/packages/cli/src/auth/client.test.ts @@ -159,6 +159,115 @@ describe("auth/client", () => { throw new Error("expected rejection"); }); + it("getCurrentUser retries once on 401 when refresh hook is configured for OAuth", async () => { + let callCount = 0; + const observed: string[] = []; + const fetchImpl = (async (_url: string, init?: RequestInit) => { + callCount++; + const headers = (init?.headers as Record) ?? {}; + observed.push(headers["authorization"] ?? ""); + if (callCount === 1) return new Response("expired", { status: 401 }); + return new Response(JSON.stringify({ email: "a@b" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const client = new AuthClient({ + baseUrl: "https://api.test.example", + fetchImpl, + onUnauthenticatedRefresh: async () => ({ + access_token: "fresh_at", + refresh_token: "fresh_rt", + }), + }); + const user = await client.getCurrentUser({ + type: "oauth", + access_token: "stale_at", + refresh_token: "rt", + source: "file_json", + refreshable: true, + }); + expect(user.email).toBe("a@b"); + expect(observed[0]).toBe("Bearer stale_at"); + expect(observed[1]).toBe("Bearer fresh_at"); + expect(callCount).toBe(2); + }); + + it("getCurrentUser hook returns full token set so the retry credential carries any rotated refresh_token", async () => { + // The hook contract now returns `OAuthTokens` (access_token plus an + // optional rotated refresh_token), not just an access_token string. + // For IdPs that rotate refresh_tokens on every exchange, any future + // retry on the in-memory credential needs the FRESH rt — the in- + // memory credential must be rebuilt from the hook's return, not + // re-use the stale rt from the original credential. + let receivedRt = ""; + const fetchImpl = (async () => + new Response("expired", { status: 401 })) as unknown as typeof fetch; + const client = new AuthClient({ + baseUrl: "https://api.test.example", + fetchImpl, + onUnauthenticatedRefresh: async (rt) => { + receivedRt = rt; + // Hook MUST be able to return a rotated refresh_token — this + // would have been impossible with the old `Promise` + // contract. The type checker fails the build if the shape drifts. + return { access_token: "fresh_at", refresh_token: "rotated_rt" }; + }, + }); + await expect( + client.getCurrentUser({ + type: "oauth", + access_token: "stale_at", + refresh_token: "ORIGINAL_rt", + source: "file_json", + refreshable: true, + }), + ).rejects.toSatisfy((err) => isAuthError(err)); + expect(receivedRt).toBe("ORIGINAL_rt"); + }); + + it("getCurrentUser does NOT retry on 401 for api_key credentials", async () => { + let callCount = 0; + const fetchImpl = (async () => { + callCount++; + return new Response("invalid", { status: 401 }); + }) as unknown as typeof fetch; + const client = new AuthClient({ + baseUrl: "https://api.test.example", + fetchImpl, + onUnauthenticatedRefresh: async () => ({ access_token: "fresh" }), + }); + await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => { + return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED"; + }); + expect(callCount).toBe(1); + }); + + it("getCurrentUser surfaces 401 when refresh hook returns null (refresh failed)", async () => { + const fetchImpl = (async () => + new Response("nope", { status: 401 })) as unknown as typeof fetch; + const { ErrRefreshFailed } = await import("./errors.js"); + const client = new AuthClient({ + baseUrl: "https://api.test.example", + fetchImpl, + onUnauthenticatedRefresh: async () => { + throw ErrRefreshFailed("invalid_grant"); + }, + }); + await expect( + client.getCurrentUser({ + type: "oauth", + access_token: "stale", + refresh_token: "rt", + source: "file_json", + refreshable: true, + }), + ).rejects.toSatisfy((err) => { + return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED"; + }); + }); + it("getCurrentUser sends the right header for oauth credentials", async () => { let captured: Record = {}; const fetchImpl = (async (_url: string, init?: RequestInit) => { diff --git a/packages/cli/src/auth/client.ts b/packages/cli/src/auth/client.ts index dba11878fb..d3682f10e5 100644 --- a/packages/cli/src/auth/client.ts +++ b/packages/cli/src/auth/client.ts @@ -15,8 +15,10 @@ * `movio/api_service/app/controller/user_v3.py`. */ -import { ErrApi, ErrUnauthenticated } from "./errors.js"; +import { ErrApi, ErrUnauthenticated, isAuthError } from "./errors.js"; import type { ResolvedCredential } from "./resolver.js"; +import { scrubCredentials } from "./scrub.js"; +import type { OAuthTokens } from "./store.js"; const DEFAULT_BASE_URL = "https://api.heygen.com"; @@ -74,27 +76,75 @@ export interface AuthClientOptions { baseUrl?: string; /** Inject a custom fetch (used by tests). */ fetchImpl?: typeof fetch; + /** + * Hook for refreshing an OAuth credential on 401. The hook should + * exchange the supplied refresh_token for new tokens, persist them, + * and return the FULL new token set — at minimum a fresh + * `access_token`, plus a fresh `refresh_token` if the IdP rotated it. + * Returning the full set lets the retry's credential carry the new + * refresh_token, so any subsequent refresh on the same in-memory + * credential doesn't re-use a now-invalidated rotated RT. + * Wired in by the auth commands; injectable for tests. + */ + onUnauthenticatedRefresh?: (refresh_token: string) => Promise; } export class AuthClient { private readonly base: string; private readonly fetchImpl: typeof fetch; + private readonly onRefresh?: (refresh_token: string) => Promise; constructor(opts: AuthClientOptions = {}) { this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, ""); this.fetchImpl = opts.fetchImpl ?? fetch; + this.onRefresh = opts.onUnauthenticatedRefresh; } /** * `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi` * on any other non-2xx or non-JSON body. + * + * On OAuth 401 with a refresh hook configured, the request is + * retried once after refreshing the access token. The retry's + * outcome is what the caller sees — if the refresh itself fails + * (REFRESH_FAILED) or the retry still 401s, the user lands on a + * "please log in again" path upstream. */ async getCurrentUser(credential: ResolvedCredential): Promise { const url = `${this.base}/v3/users/me`; + return await this.fetchUser(url, credential, true); + } + + // fallow-ignore-next-line complexity + private async fetchUser( + url: string, + credential: ResolvedCredential, + allowRefresh: boolean, + ): Promise { const headers = buildAuthHeaders(credential); const res = await this.fetchImpl(url, { method: "GET", headers }); if (res.status === 401) { + if ( + allowRefresh && + credential.type === "oauth" && + credential.refresh_token && + this.onRefresh + ) { + const refreshed = await this.tryRefresh(credential.refresh_token); + if (refreshed) { + // Carry the new refresh_token forward too — for IdPs that + // rotate RTs on every refresh, a future retry on this + // in-memory credential would otherwise re-send the old + // (now-invalidated) one. + const next: ResolvedCredential = { + ...credential, + access_token: refreshed.access_token, + ...(refreshed.refresh_token ? { refresh_token: refreshed.refresh_token } : {}), + }; + return await this.fetchUser(url, next, false); + } + } const detail = await safeText(res); throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`); } @@ -110,6 +160,19 @@ export class AuthClient { } return extractUserInfo(payload); } + + private async tryRefresh(refresh_token: string): Promise { + if (!this.onRefresh) return null; + try { + return await this.onRefresh(refresh_token); + } catch (err) { + // Refresh failure should be surfaced upstream by the caller via + // the retry's 401, not by throwing here — so callers consistently + // see "please log in again" rather than mixed error types. + if (isAuthError(err) && err.code === "REFRESH_FAILED") return null; + throw err; + } + } } export function buildAuthHeaders(credential: ResolvedCredential): Record { @@ -128,25 +191,6 @@ async function safeText(res: Response): Promise { } } -/** - * 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. diff --git a/packages/cli/src/auth/errors.ts b/packages/cli/src/auth/errors.ts index 8f45b87bb9..1c0af12b9d 100644 --- a/packages/cli/src/auth/errors.ts +++ b/packages/cli/src/auth/errors.ts @@ -3,7 +3,13 @@ * can map specific failures to friendly UX without parsing messages. */ -export type AuthErrorCode = "NOT_CONFIGURED" | "INVALID_STORE" | "API_ERROR" | "UNAUTHENTICATED"; +export type AuthErrorCode = + | "NOT_CONFIGURED" + | "INVALID_STORE" + | "API_ERROR" + | "UNAUTHENTICATED" + | "OAUTH_NOT_CONFIGURED" + | "REFRESH_FAILED"; export class AuthError extends Error { readonly code: AuthErrorCode; @@ -41,6 +47,20 @@ export const ErrUnauthenticated = (detail?: string) => export const ErrApi = (status: number, detail: string) => new AuthError("API_ERROR", `HeyGen API error (${status}): ${detail}`); +export const ErrOAuthNotConfigured = () => + new AuthError( + "OAUTH_NOT_CONFIGURED", + "OAuth client is not configured", + "Set HYPERFRAMES_OAUTH_CLIENT_ID, or run `hyperframes auth login --api-key`.", + ); + +export const ErrRefreshFailed = (detail?: string) => + new AuthError( + "REFRESH_FAILED", + detail ? `Failed to refresh OAuth tokens: ${detail}` : "Failed to refresh OAuth tokens", + "Run `hyperframes auth login` to re-authenticate.", + ); + 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 index 1f562e1d7c..eb2c99fa4b 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -15,3 +15,10 @@ export type { ResolvedCredential } from "./resolver.js"; export { AuthClient } from "./client.js"; export type { UserInfo } from "./client.js"; + +export { + assertOAuthConfiguredOrExit, + refreshTokens, + revokeTokens, + startAuthorizationCodeFlow, +} from "./oauth.js"; diff --git a/packages/cli/src/auth/loopback.test.ts b/packages/cli/src/auth/loopback.test.ts new file mode 100644 index 0000000000..269ccc8b52 --- /dev/null +++ b/packages/cli/src/auth/loopback.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startLoopback, type LoopbackHandle } from "./loopback.js"; + +describe("auth/loopback", () => { + let active: LoopbackHandle | null = null; + + afterEach(async () => { + if (active) { + await active.close().catch(() => {}); + active = null; + } + }); + + it("captures `code` when state matches", async () => { + const handle = await startLoopback({ state: "expected_state", timeoutMs: 5_000 }); + active = handle; + const redirect = new URL(handle.redirectUri); + const callback = new URL(`${handle.redirectUri}?code=abc123&state=expected_state`); + + const fetchPromise = fetch(callback.toString()); + const result = await handle.result; + const res = await fetchPromise; + + expect(result.code).toBe("abc123"); + expect(result.redirectUri).toContain(redirect.host); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain("Signed in"); + }); + + async function expectRejection(args: { + expectedState: string; + query: string; + pattern: RegExp; + }): Promise { + const handle = await startLoopback({ state: args.expectedState, timeoutMs: 5_000 }); + active = handle; + await fetch(`${handle.redirectUri}?${args.query}`).catch(() => {}); + await expect(handle.result).rejects.toThrow(args.pattern); + } + + it("rejects when state does not match", async () => { + await expectRejection({ + expectedState: "expected", + query: "code=abc&state=wrong", + pattern: /state mismatch/i, + }); + }); + + it("rejects when the IdP returns an error", async () => { + await expectRejection({ + expectedState: "s", + query: "error=access_denied&error_description=user+denied&state=s", + pattern: /access_denied/, + }); + }); + + it("rejects when code is missing from the callback", async () => { + await expectRejection({ expectedState: "s", query: "state=s", pattern: /code/ }); + }); + + it("times out when no callback arrives", async () => { + const handle = await startLoopback({ state: "s", timeoutMs: 200 }); + active = handle; + await expect(handle.result).rejects.toThrow(/timed out/i); + }); + + it("404s non-callback paths and does not resolve the flow", async () => { + const handle = await startLoopback({ state: "s", timeoutMs: 1_000 }); + active = handle; + const res = await fetch(`${handle.redirectUri.replace("/oauth/callback", "/other")}`); + expect(res.status).toBe(404); + // Flow is still waiting — kill it via timeout. + await expect(handle.result).rejects.toThrow(/timed out/i); + }); +}); diff --git a/packages/cli/src/auth/loopback.ts b/packages/cli/src/auth/loopback.ts new file mode 100644 index 0000000000..5b14cb56cd --- /dev/null +++ b/packages/cli/src/auth/loopback.ts @@ -0,0 +1,214 @@ +/** + * Loopback HTTP server for the OAuth authorization-code callback. + * + * The CLI binds a server to `127.0.0.1:0` (ephemeral port), sends the + * user's browser to `/v1/oauth/authorize?redirect_uri=…` pointing at + * this server, and waits for the redirect carrying `?code=…&state=…`. + * + * The backend wildcards localhost ports for public clients + * (`movio/model/oauth2.py:check_redirect_uri`), so the registered + * redirect URI's port is just a placeholder — the actual port the + * server lands on is what matters at runtime. + * + * Times out after 120s. Validates `state` matches the value we + * generated. Renders a small "you can close this window" page back + * to the browser before shutting down. + */ + +import { timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; + +export interface LoopbackOptions { + /** Expected `state` value — flow fails if it doesn't match. */ + state: string; + /** Timeout in ms (default 120s). */ + timeoutMs?: number; + /** Override port for tests (default 0 = ephemeral). */ + port?: number; +} + +export interface LoopbackResult { + /** Authorization code from the IdP. */ + code: string; + /** The full redirect_uri (with port) we listened on. */ + redirectUri: string; +} + +export interface LoopbackHandle { + /** Promise that resolves with the captured code, or rejects on timeout/error. */ + result: Promise; + /** Redirect URI to pass to /v1/oauth/authorize. */ + redirectUri: string; + /** Stop the server early (e.g. user cancels). */ + close: () => Promise; +} + +const CALLBACK_PATH = "/oauth/callback"; +const DEFAULT_TIMEOUT_MS = 120_000; + +export async function startLoopback(opts: LoopbackOptions): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + let resolveResult!: (value: LoopbackResult) => void; + let rejectResult!: (err: Error) => void; + const result = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + // redirectUri is the value the IdP sees on /authorize. RFC 6749 §4.1.3 + // requires the token exchange's redirect_uri to be byte-identical to + // it, so we capture this string once and reuse it on both hops — never + // reconstructing from req.socket.localAddress later (which can drift + // on dual-stack hosts). + let redirectUri = ""; + + const server = createServer((req, res) => + handleRequest(req, res, opts.state, redirectUri, resolveResult, rejectResult), + ); + + await listen(server, opts.port ?? 0); + const address = server.address() as AddressInfo; + redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`; + + let closed = false; + const close = async (): Promise => { + if (closed) return; + closed = true; + clearTimeout(timer); + // `server.close()` only refuses NEW connections — it does NOT + // terminate existing keep-alive sockets, which browsers default to + // and idle for minutes (Chrome ~5min). Without `closeAllConnections` + // the CLI process hangs after "Signed in" until the browser closes + // its idle socket. `respond()` also emits `Connection: close` so the + // browser doesn't try to keep-alive in the first place. + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + }; + + const timer = setTimeout(() => { + rejectResult(new Error(`OAuth callback timed out after ${timeoutMs}ms`)); + void close(); + }, timeoutMs); + + // When result settles, drain the timer + shutdown. + result.finally(close).catch(() => {}); + + return { result, redirectUri, close }; +} + +async function listen(server: Server, port: number): Promise { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +// fallow-ignore-next-line complexity +function handleRequest( + req: IncomingMessage, + res: ServerResponse, + expectedState: string, + redirectUri: string, + resolveResult: (value: LoopbackResult) => void, + rejectResult: (err: Error) => void, +): void { + // Only GET is part of the OAuth redirect contract. Anything else is + // probe-traffic on the ephemeral port; reject without leaking that a + // CLI is listening there. + if (req.method !== "GET") { + res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed"); + return; + } + + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404, { "content-type": "text/plain" }).end("Not Found"); + return; + } + + const params = url.searchParams; + const error = params.get("error"); + if (error) { + const desc = params.get("error_description") ?? ""; + respond(res, 400, errorPage(error, desc)); + rejectResult(new Error(`OAuth authorize returned error: ${error}${desc ? ` — ${desc}` : ""}`)); + return; + } + + const state = params.get("state"); + if (!state || !stateMatches(state, expectedState)) { + respond(res, 400, errorPage("invalid_state", "State parameter did not match.")); + rejectResult(new Error("OAuth state mismatch — possible CSRF, aborting.")); + return; + } + + const code = params.get("code"); + if (!code) { + respond( + res, + 400, + errorPage("missing_code", "Authorization code is missing from the redirect."), + ); + rejectResult(new Error("OAuth redirect did not include `code`.")); + return; + } + + respond(res, 200, successPage()); + resolveResult({ code, redirectUri }); +} + +/** + * Constant-time comparison for the OAuth `state` parameter. Real + * exploitability is very low (loopback, 256-bit entropy, narrow flow + * window), but the rest of the auth path uses crypto-grade primitives + * and a `!==` here would be a gratuitous deviation in security review. + */ +function stateMatches(actual: string, expected: string): boolean { + const a = Buffer.from(actual, "utf8"); + const b = Buffer.from(expected, "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +function respond(res: ServerResponse, status: number, body: string): void { + res + .writeHead(status, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + // Tell the browser not to keep the TCP socket alive — otherwise + // `server.close()` blocks on the idle keep-alive timeout + // (Chrome ~5min). Combined with `server.closeAllConnections()` + // in `close()` this guarantees the CLI exits promptly after the + // user sees the success page. + connection: "close", + }) + .end(body); +} + +function successPage(): string { + return `Signed in to HeyGen + +

You're signed in.

You can close this tab and return to your terminal.

`; +} + +function errorPage(code: string, description: string): string { + const safeCode = escapeHtml(code); + const safeDesc = escapeHtml(description); + return `Sign-in failed + +

Sign-in failed

${safeCode}

${safeDesc}

`; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/packages/cli/src/auth/oauth.test.ts b/packages/cli/src/auth/oauth.test.ts new file mode 100644 index 0000000000..2b88441ff2 --- /dev/null +++ b/packages/cli/src/auth/oauth.test.ts @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setupTempAuthEnv } from "./_test-utils.js"; +import { isAuthError } from "./errors.js"; +import { + parseTokenResponse, + refreshTokens, + resolveClientId, + revokeTokens, + startAuthorizationCodeFlow, +} from "./oauth.js"; +import { readStore, writeStore } from "./store.js"; + +// Mock the interactive bits so startAuthorizationCodeFlow runs headless. +vi.mock("./loopback.js", () => ({ + startLoopback: vi.fn(async () => ({ + result: Promise.resolve({ + code: "auth_code_123", + redirectUri: "http://127.0.0.1:12345/oauth/callback", + }), + redirectUri: "http://127.0.0.1:12345/oauth/callback", + close: vi.fn(async () => {}), + })), +})); +vi.mock("./browser.js", () => ({ + openBrowser: vi.fn(async () => ({ opened: true })), +})); + +describe("auth/oauth", () => { + let fixture: Awaited>; + + beforeEach(async () => { + fixture = await setupTempAuthEnv("hf-oauth-"); + }); + + afterEach(async () => { + await fixture.restore(); + }); + + describe("resolveClientId", () => { + it("returns the env override when set", () => { + process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "test_client_id"; + expect(resolveClientId()).toBe("test_client_id"); + }); + + it("returns the build-time default when env is unset", () => { + expect(resolveClientId()).toMatch(/.+/); + }); + }); + + describe("parseTokenResponse", () => { + it("parses a full token response", () => { + const tokens = parseTokenResponse({ + access_token: "at_123", + refresh_token: "rt_456", + token_type: "Bearer", + expires_in: 3600, + scope: "openid profile", + }); + expect(tokens.access_token).toBe("at_123"); + expect(tokens.refresh_token).toBe("rt_456"); + expect(tokens.token_type).toBe("Bearer"); + expect(tokens.scope).toBe("openid profile"); + expect(tokens.expires_at).toBeDefined(); + const expiresAt = new Date(tokens.expires_at!); + // Should be approximately 1 hour in the future + const diff = expiresAt.getTime() - Date.now(); + expect(diff).toBeGreaterThan(3500 * 1000); + expect(diff).toBeLessThan(3700 * 1000); + }); + + it("accepts expires_in as a string (some servers serialize as string)", () => { + const tokens = parseTokenResponse({ access_token: "at", expires_in: "1800" }); + expect(tokens.expires_at).toBeDefined(); + }); + + it("rejects responses missing access_token", () => { + expect(() => parseTokenResponse({ token_type: "Bearer" })).toThrow(); + }); + + it("rejects array payloads", () => { + expect(() => parseTokenResponse([])).toThrow(); + }); + + it("rejects null payloads", () => { + expect(() => parseTokenResponse(null)).toThrow(); + }); + + it("rejects access_token containing CR/LF", () => { + expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toSatisfy( + () => true, // assertion done below via toThrow + ); + expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toThrow( + /control characters/, + ); + }); + + it("rejects refresh_token containing CR/LF", () => { + expect(() => parseTokenResponse({ access_token: "at", refresh_token: "rt\nbad" })).toThrow( + /control characters/, + ); + }); + + it("clamps non-positive expires_in to avoid an immediate-refresh loop", () => { + const zero = parseTokenResponse({ access_token: "at", expires_in: 0 }); + const negative = parseTokenResponse({ access_token: "at", expires_in: -100 }); + // both should resolve to a future time + expect(new Date(zero.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000); + expect(new Date(negative.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000); + }); + + it("uses REFRESH_FAILED error code on shape failures (not API_ERROR)", () => { + try { + parseTokenResponse(null); + } catch (err) { + expect(isAuthError(err)).toBe(true); + if (isAuthError(err)) expect(err.code).toBe("REFRESH_FAILED"); + return; + } + throw new Error("expected throw"); + }); + }); + + describe("refreshTokens", () => { + it("posts grant_type=refresh_token and persists the response", async () => { + process.env["HEYGEN_API_URL"] = "https://api.test.example"; + let capturedBody: string | undefined; + const fetchImpl = (async (_url: string, init?: RequestInit) => { + capturedBody = init?.body as string; + return new Response( + JSON.stringify({ + access_token: "new_at", + refresh_token: "new_rt", + expires_in: 3600, + token_type: "Bearer", + scope: "openid profile", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + const tokens = await refreshTokens("old_rt", { fetchImpl }); + expect(tokens.access_token).toBe("new_at"); + expect(tokens.refresh_token).toBe("new_rt"); + expect(capturedBody).toContain("grant_type=refresh_token"); + expect(capturedBody).toContain("refresh_token=old_rt"); + + // Should have persisted. + const { credentials } = await readStore(); + expect(credentials.oauth?.access_token).toBe("new_at"); + }); + + it("preserves the prior refresh_token when the server omits it (no rotation)", async () => { + await writeStore({ + oauth: { + access_token: "old_at", + refresh_token: "keep_me_rt", + expires_at: "2026-01-01T00:00:00Z", + }, + }); + const fetchImpl = (async () => + new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + await refreshTokens("keep_me_rt", { fetchImpl }); + const { credentials } = await readStore(); + expect(credentials.oauth?.access_token).toBe("new_at"); + // Critical: refresh_token MUST survive a no-rotation refresh. + expect(credentials.oauth?.refresh_token).toBe("keep_me_rt"); + }); + + it("preserves an existing api_key when persisting refreshed oauth", async () => { + await writeStore({ api_key: "hg_keep" }); + const fetchImpl = (async () => + new Response(JSON.stringify({ access_token: "new_at", expires_in: 60 }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + await refreshTokens("old_rt", { fetchImpl }); + const { credentials } = await readStore(); + expect(credentials.api_key).toBe("hg_keep"); + expect(credentials.oauth?.access_token).toBe("new_at"); + }); + + it("throws REFRESH_FAILED on 400/401", async () => { + const fetchImpl = (async () => + new Response("invalid_grant", { status: 400 })) as unknown as typeof fetch; + await expect(refreshTokens("bad_rt", { fetchImpl })).rejects.toSatisfy((err) => { + return isAuthError(err) && (err as { code: string }).code === "REFRESH_FAILED"; + }); + }); + + it("throws API_ERROR on 5xx", async () => { + const fetchImpl = (async () => + new Response("upstream", { status: 503 })) as unknown as typeof fetch; + await expect(refreshTokens("rt", { fetchImpl })).rejects.toSatisfy((err) => { + return isAuthError(err) && (err as { code: string }).code === "API_ERROR"; + }); + }); + }); + + describe("revokeTokens", () => { + it("never throws on network failure (best-effort)", async () => { + const fetchImpl = (async () => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + await expect(revokeTokens("any_token", { fetchImpl })).resolves.toBeUndefined(); + }); + + it("respects the timeout", async () => { + let aborted = false; + const fetchImpl = (async (_url: string, init?: RequestInit) => { + const signal = init?.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + aborted = true; + reject(new Error("aborted")); + }); + }); + }) as unknown as typeof fetch; + await revokeTokens("token", { fetchImpl, timeoutMs: 50 }); + expect(aborted).toBe(true); + }); + + it("sends token_type_hint when provided", async () => { + let capturedBody = ""; + const fetchImpl = (async (_url: string, init?: RequestInit) => { + capturedBody = init?.body as string; + return new Response("", { status: 200 }); + }) as unknown as typeof fetch; + await revokeTokens("tok", { fetchImpl, token_type_hint: "refresh_token" }); + expect(capturedBody).toContain("token_type_hint=refresh_token"); + }); + + it("returns silently when client_id is unconfigured (no throw)", async () => { + process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = ""; + // With the baked-in default cleared from env, revokeTokens still has + // the build-time default. Force it to fail by setting the override to + // a value AND nulling the default isn't possible from a test — instead + // verify that the function never throws via the standard path. + const fetchImpl = (async () => new Response("", { status: 200 })) as unknown as typeof fetch; + await expect(revokeTokens("tok", { fetchImpl })).resolves.toBeUndefined(); + }); + }); + + describe("error scrubbing", () => { + it("refreshTokens does not leak token-shaped secrets from the error body", async () => { + const fetchImpl = (async () => + new Response( + '{"error":"invalid_grant","echoed":"refresh_token=rt_leak_secret&code_verifier=cv_leak"}', + { status: 400 }, + )) as unknown as typeof fetch; + try { + await refreshTokens("rt_leak_secret", { fetchImpl }); + throw new Error("expected rejection"); + } catch (err) { + const msg = (err as Error).message; + expect(msg).not.toContain("rt_leak_secret"); + expect(msg).not.toContain("cv_leak"); + expect(msg).toContain(""); + } + }); + }); + + describe("startAuthorizationCodeFlow persistence", () => { + function tokenFetch(body: Record): typeof fetch { + return (async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + } + + it("overwrites the OAuth block on fresh login (no inherited refresh_token)", async () => { + // Pre-seed a prior session whose refresh_token must NOT leak into + // the new login when the new response omits one. + await writeStore({ + oauth: { access_token: "old_at", refresh_token: "OLD_rt_should_not_survive" }, + }); + const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); + await startAuthorizationCodeFlow({ fetchImpl }); + + const { credentials } = await readStore(); + expect(credentials.oauth?.access_token).toBe("new_at"); + // Fresh login is a clean session — the old refresh_token is gone. + expect(credentials.oauth?.refresh_token).toBeUndefined(); + }); + + it("preserves a co-located api_key across fresh login", async () => { + await writeStore({ api_key: "hg_keep_me" }); + const fetchImpl = tokenFetch({ access_token: "new_at", refresh_token: "new_rt" }); + await startAuthorizationCodeFlow({ fetchImpl }); + + const { credentials } = await readStore(); + expect(credentials.api_key).toBe("hg_keep_me"); + expect(credentials.oauth?.access_token).toBe("new_at"); + expect(credentials.oauth?.refresh_token).toBe("new_rt"); + }); + }); +}); diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts new file mode 100644 index 0000000000..ddfc31ddaa --- /dev/null +++ b/packages/cli/src/auth/oauth.ts @@ -0,0 +1,438 @@ +/** + * OAuth 2.0 + PKCE driver for the HeyGen public OAuth flow. + * + * Entry points: + * - `startAuthorizationCodeFlow()` — full interactive login (browser + * opens, loopback waits, code → tokens, persist). + * - `refreshTokens()` — POST with grant_type=refresh_token. + * - `revokeTokens()` — best-effort POST . + * + * Endpoints split across two hosts (verified live): + * - **Authorize** — GET https://app.heygen.com/oauth/authorize + * Renders the consent screen, has to live on the same origin as the + * user's web session (cookies). The Next.js SPA shell serves this. + * - **Token** — POST https://api2.heygen.com/v1/oauth/token + * Server-to-server JSON API. `app.heygen.com/oauth/token` returns + * the SPA HTML for direct POSTs — confirmed by curl; only the api2 + * route returns JSON OAuth responses. + * - **Revoke** — POST https://api2.heygen.com/v1/oauth/revoke + * Same host/prefix as token. + * + * The `heygen-oauth-urls.ts` default in `hyperframes-internal/demo-next` + * lists `app.heygen.com/oauth/token` as the default — that's either + * proxied via a Next.js rewrite or set via `HEYGEN_OAUTH_TOKEN_URL` in + * their prod env. A direct fetch to it returns the SPA shell, so we + * use the api2 endpoint here. + * + * Overrides: + * - `HYPERFRAMES_OAUTH_AUTHORIZE_URL` + * - `HYPERFRAMES_OAUTH_TOKEN_URL` + * - `HYPERFRAMES_OAUTH_REVOKE_URL` + * - `HYPERFRAMES_OAUTH_CLIENT_ID` + * + * Public client — no `client_secret`. + */ + +import { ErrApi, ErrOAuthNotConfigured, ErrRefreshFailed, isAuthError } from "./errors.js"; +import { generatePkcePair, generateState } from "./pkce.js"; +import { startLoopback } from "./loopback.js"; +import { openBrowser } from "./browser.js"; +import { scrubCredentials } from "./scrub.js"; +import { + isHeaderSafe, + readStore, + writeStore, + type Credentials, + type OAuthTokens, +} from "./store.js"; +import { c } from "../ui/colors.js"; + +const REVOKE_TIMEOUT_MS = 5_000; +const MIN_EXPIRES_IN_SECONDS = 30; + +/** + * Default OAuth client_id baked at build time. Override with the + * `HYPERFRAMES_OAUTH_CLIENT_ID` env var. Empty string means "not + * configured" — `resolveClientId()` errors cleanly with a pointer + * at `--api-key`. + */ +const DEFAULT_CLIENT_ID = "q2A2QRSke2LrFTPJhoDbHtXh"; +const DEFAULT_SCOPES = "openid profile email"; + +// Endpoint defaults — see file-header comment for why these straddle two +// hosts. Each is independently overridable. +const DEFAULT_AUTHORIZE_URL = "https://app.heygen.com/oauth/authorize"; +const DEFAULT_TOKEN_URL = "https://api2.heygen.com/v1/oauth/token"; +const DEFAULT_REVOKE_URL = "https://api2.heygen.com/v1/oauth/revoke"; + +function authorizeEndpoint(): string { + return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL; +} +function tokenEndpoint(): string { + return process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] || DEFAULT_TOKEN_URL; +} +function revokeEndpoint(): string { + return process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL; +} + +export interface AuthorizeFlowOptions { + /** Override scopes (default `openid profile email`). */ + scope?: string; + /** Inject a custom fetch (used by tests). */ + fetchImpl?: typeof fetch; + /** Override timeout in ms (default 120s). */ + timeoutMs?: number; +} + +export interface AuthorizeFlowResult { + tokens: OAuthTokens; + /** Returned identity info for friendly post-login UX. */ + userInfo?: Record; +} + +export interface RefreshOptions { + fetchImpl?: typeof fetch; +} + +/** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */ +export function resolveClientId(): string { + const override = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"]; + const id = override && override.length > 0 ? override : DEFAULT_CLIENT_ID; + if (!id || id.length === 0) throw ErrOAuthNotConfigured(); + return id; +} + +/** + * For command-entry points: throw the standard `ErrOAuthNotConfigured` + * via `resolveClientId()`; on a real misconfig, print a friendly hint + * and exit with status 1 rather than dumping a stack. Throws non-auth + * errors so callers can surface programmer bugs. + */ +export function assertOAuthConfiguredOrExit(): void { + try { + resolveClientId(); + } catch (err) { + if (isAuthError(err) && err.code === "OAUTH_NOT_CONFIGURED") { + console.error(`Error: ${err.message}`); + if (err.hint) console.error(err.hint); + process.exit(1); + } + throw err; + } +} + +export async function startAuthorizationCodeFlow( + opts: AuthorizeFlowOptions = {}, +): Promise { + const clientId = resolveClientId(); + const scope = opts.scope ?? DEFAULT_SCOPES; + const pkce = generatePkcePair(); + const state = generateState(); + + const loopback = await startLoopback({ state, timeoutMs: opts.timeoutMs }); + const authorizeUrl = buildAuthorizeUrl({ + clientId, + redirectUri: loopback.redirectUri, + scope, + state, + challenge: pkce.challenge, + }); + + // Print the host+path only (no state / code_challenge) so live + // values can't leak into scrollback / CI logs during the 120s window. + console.log(`Opening browser to ${c.dim(authorizeHost(authorizeUrl))} ...`); + const { opened } = await openBrowser(authorizeUrl); + if (!opened) { + // openBrowser already printed the manual URL; surface it as the + // last on-screen instruction so it isn't buried above "Waiting…". + console.log(c.dim("(open the URL above to continue)")); + } + console.log(`Waiting for callback on ${c.accent(loopback.redirectUri)} ...`); + + let codeResult; + try { + codeResult = await loopback.result; + } catch (err) { + await loopback.close().catch(() => {}); + throw err; + } + + const tokens = await exchangeCodeForTokens({ + clientId, + code: codeResult.code, + redirectUri: codeResult.redirectUri, + verifier: pkce.verifier, + fetchImpl: opts.fetchImpl, + }); + + // Fresh login → clean OAuth block (no inherited refresh_token). + await persistOAuth(tokens, { preserveMissing: false }); + return { tokens }; +} + +export async function refreshTokens( + refresh_token: string, + opts: RefreshOptions = {}, +): Promise { + const clientId = resolveClientId(); + const fetchImpl = opts.fetchImpl ?? fetch; + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token, + client_id: clientId, + }); + + const res = await fetchImpl(tokenEndpoint(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: body.toString(), + }); + + if (res.status === 400 || res.status === 401) { + throw ErrRefreshFailed(await safeText(res)); + } + if (!res.ok) { + throw ErrApi(res.status, (await safeText(res)) || res.statusText); + } + + const payload = await readJsonOrThrow(res); + const tokens = parseTokenResponse(payload); + // Refresh grant → preserve a refresh_token the server didn't rotate. + await persistOAuth(tokens, { preserveMissing: true }); + return tokens; +} + +export interface RevokeOptions extends RefreshOptions { + /** Hint to the server about which token we're sending (RFC 7009). */ + token_type_hint?: "access_token" | "refresh_token"; + /** Abort the revoke after this many ms (default 5s). */ + timeoutMs?: number; +} + +/** + * RFC 7009 revoke. Best-effort: never throws. A hung IdP or unset + * client_id MUST NOT block local logout — both are caught here so the + * caller can wipe local state immediately afterward regardless. + */ +export async function revokeTokens(token: string, opts: RevokeOptions = {}): Promise { + let clientId: string; + try { + clientId = resolveClientId(); + } catch { + // OAuth not configured — nothing to revoke server-side. + return; + } + + const fetchImpl = opts.fetchImpl ?? fetch; + const params: Record = { token, client_id: clientId }; + if (opts.token_type_hint) params["token_type_hint"] = opts.token_type_hint; + const body = new URLSearchParams(params); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? REVOKE_TIMEOUT_MS); + try { + const res = await fetchImpl(revokeEndpoint(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: controller.signal, + }); + if (!res.ok) { + console.error( + c.dim(`Note: token revoke returned HTTP ${res.status}; local credentials cleared anyway.`), + ); + } + } catch { + /* timeout or network error — silent, this is best-effort */ + } finally { + clearTimeout(timer); + } +} + +function authorizeHost(fullUrl: string): string { + try { + const u = new URL(fullUrl); + return `${u.origin}${u.pathname}`; + } catch { + return fullUrl.split("?")[0] ?? fullUrl; + } +} + +function buildAuthorizeUrl(args: { + clientId: string; + redirectUri: string; + scope: string; + state: string; + challenge: string; +}): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: args.clientId, + redirect_uri: args.redirectUri, + scope: args.scope, + state: args.state, + code_challenge: args.challenge, + code_challenge_method: "S256", + }); + return `${authorizeEndpoint()}?${params.toString()}`; +} + +async function exchangeCodeForTokens(args: { + clientId: string; + code: string; + redirectUri: string; + verifier: string; + fetchImpl?: typeof fetch; +}): Promise { + const fetchImpl = args.fetchImpl ?? fetch; + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: args.code, + redirect_uri: args.redirectUri, + client_id: args.clientId, + code_verifier: args.verifier, + }); + const res = await fetchImpl(tokenEndpoint(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: body.toString(), + }); + if (res.status === 400 || res.status === 401) { + // The authorization code is single-use and short-lived. A 400/401 + // here almost always means it expired during the loopback wait or + // was already redeemed — surface an actionable message instead of + // a bare "HeyGen API error (400)". + const detail = (await safeText(res)) || res.statusText; + throw ErrRefreshFailed( + `authorization code rejected (${detail}); please run \`auth login\` again`, + ); + } + if (!res.ok) { + throw ErrApi(res.status, (await safeText(res)) || res.statusText); + } + return parseTokenResponse(await readJsonOrThrow(res)); +} + +/** + * Parse the RFC 6749 token response. Backend may also include + * `id_token` (OIDC) but we ignore that today. + * + * Throws `ErrInvalidTokenResponse` (an AuthError carrying + * REFRESH_FAILED code, so callers using `tryRefresh` consistently + * route the user to "log in again" instead of a generic API error) + * on shape failures. + */ +// fallow-ignore-next-line complexity +export function parseTokenResponse(payload: unknown): OAuthTokens { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw ErrRefreshFailed("token endpoint returned a non-object payload"); + } + const obj = payload as Record; + const accessToken = stringField(obj, "access_token"); + if (!accessToken) { + throw ErrRefreshFailed("token endpoint did not return an access_token"); + } + if (!isHeaderSafe(accessToken)) { + throw ErrRefreshFailed("access_token contains control characters"); + } + + const out: OAuthTokens = { access_token: accessToken }; + const refreshToken = stringField(obj, "refresh_token"); + if (refreshToken) { + if (!isHeaderSafe(refreshToken)) { + throw ErrRefreshFailed("refresh_token contains control characters"); + } + out.refresh_token = refreshToken; + } + const tokenType = stringField(obj, "token_type"); + if (tokenType) out.token_type = tokenType; + const scope = stringField(obj, "scope"); + if (scope) out.scope = scope; + + const expiresIn = numericField(obj, "expires_in"); + if (expiresIn !== undefined) { + // Clamp to a sensible minimum so a misbehaving / clock-skewed + // server returning 0 or a negative value doesn't put expires_at in + // the past and cause the 401-refresh path to loop. + const clamped = Math.max(expiresIn, MIN_EXPIRES_IN_SECONDS); + out.expires_at = new Date(Date.now() + clamped * 1000).toISOString(); + } + return out; +} + +function stringField(obj: Record, key: string): string | undefined { + const v = obj[key]; + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +function numericField(obj: Record, key: string): number | undefined { + const v = obj[key]; + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string") { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : undefined; + } + return undefined; +} + +/** + * Persist a new OAuth token set, always preserving a co-located + * `api_key`. + * + * `preserveMissing` controls how the new tokens combine with whatever + * OAuth block is already on disk: + * - `false` (fresh authorization-code login): overwrite the OAuth + * block entirely. A new interactive login is a clean session — it + * must NOT inherit the previous session's refresh_token, or a + * response that omits one would pair a new access token with a + * stale refresh token and break/misroute the next refresh. + * - `true` (refresh grant): keep the prior refresh_token / scope / + * token_type when the response omits them. RFC 6749 §6 lets the + * token endpoint skip refresh_token on a no-rotation refresh, and + * dropping it would brick future refreshes. + */ +async function persistOAuth( + tokens: OAuthTokens, + opts: { preserveMissing: boolean }, +): Promise { + let existing: Credentials = {}; + try { + const { credentials } = await readStore(); + existing = credentials; + } catch { + // Treat unreadable existing file as empty — we're about to + // overwrite the OAuth block anyway. + existing = {}; + } + + const oauth: OAuthTokens = opts.preserveMissing + ? { ...existing.oauth, ...tokens } + : { ...tokens }; + // Keep co-located API key in both modes. + await writeStore({ ...(existing.api_key ? { api_key: existing.api_key } : {}), oauth }); +} + +async function readJsonOrThrow(res: Response): Promise { + try { + return await res.json(); + } catch (err) { + throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`); + } +} + +async function safeText(res: Response): Promise { + try { + // Token/revoke requests carry refresh_token, authorization code, and + // code_verifier in the form body; a server/proxy error page can echo + // request data back. Scrub before the text reaches any error message. + return scrubCredentials((await res.text()).slice(0, 500)); + } catch { + return ""; + } +} diff --git a/packages/cli/src/auth/pkce.test.ts b/packages/cli/src/auth/pkce.test.ts new file mode 100644 index 0000000000..a107923084 --- /dev/null +++ b/packages/cli/src/auth/pkce.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { generatePkcePair, generateState } from "./pkce.js"; + +describe("auth/pkce", () => { + it("generates a verifier within RFC 7636 length bounds (43-128 chars)", () => { + const { verifier } = generatePkcePair(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + }); + + it("uses only URL-safe base64 characters", () => { + const { verifier, challenge } = generatePkcePair(); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("challenge is the SHA-256 hash of the verifier, base64url-encoded", () => { + const { verifier, challenge } = generatePkcePair(); + const expected = createHash("sha256") + .update(verifier) + .digest("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + expect(challenge).toBe(expected); + }); + + it("always uses S256 method", () => { + expect(generatePkcePair().method).toBe("S256"); + }); + + it("generates distinct verifiers on each call", () => { + const a = generatePkcePair().verifier; + const b = generatePkcePair().verifier; + expect(a).not.toBe(b); + }); + + it("generates state values with sufficient entropy", () => { + const state = generateState(); + expect(state.length).toBeGreaterThanOrEqual(40); + expect(state).toMatch(/^[A-Za-z0-9_-]+$/); + expect(state).not.toBe(generateState()); + }); +}); diff --git a/packages/cli/src/auth/pkce.ts b/packages/cli/src/auth/pkce.ts new file mode 100644 index 0000000000..36ef8d4e1b --- /dev/null +++ b/packages/cli/src/auth/pkce.ts @@ -0,0 +1,43 @@ +/** + * PKCE (Proof Key for Code Exchange, RFC 7636) helpers. + * + * The flow: + * 1. Generate a high-entropy `code_verifier` (43–128 URL-safe chars). + * 2. Send `code_challenge = base64url(SHA-256(code_verifier))` to + * `/v1/oauth/authorize`. + * 3. After the user consents, exchange the returned `code` + the + * original `code_verifier` at `/v1/oauth/token`. The server hashes + * the verifier and rejects the exchange if it doesn't match the + * challenge that opened the flow. + * + * PKCE removes the need for a client secret — perfect for a CLI that + * can't keep one. + */ + +import { createHash, randomBytes } from "node:crypto"; + +const VERIFIER_BYTES = 64; // 64 random bytes → 86 base64url chars (well within 43-128) + +export interface PkcePair { + /** Sent on the exchange; kept secret by the CLI between the two HTTP hops. */ + verifier: string; + /** Sent on the authorize URL. */ + challenge: string; + /** Always "S256" for HeyGen's backend (`code_challenge_method`). */ + method: "S256"; +} + +export function generatePkcePair(): PkcePair { + const verifier = base64UrlEncode(randomBytes(VERIFIER_BYTES)); + const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest()); + return { verifier, challenge, method: "S256" }; +} + +/** OAuth `state` parameter — a CSRF token bound to this flow. */ +export function generateState(): string { + return base64UrlEncode(randomBytes(32)); +} + +function base64UrlEncode(buf: Buffer): string { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} diff --git a/packages/cli/src/auth/resolver.test.ts b/packages/cli/src/auth/resolver.test.ts index eb8a062a0b..ad10ade1c1 100644 --- a/packages/cli/src/auth/resolver.test.ts +++ b/packages/cli/src/auth/resolver.test.ts @@ -1,37 +1,22 @@ 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 { setupTempAuthEnv } from "./_test-utils.js"; 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 fixture: Awaited>; 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; + fixture = await setupTempAuthEnv("hf-auth-resolve-"); + dir = fixture.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 }); + await fixture.restore(); }); it("prefers HEYGEN_API_KEY over everything else", async () => { diff --git a/packages/cli/src/auth/scrub.test.ts b/packages/cli/src/auth/scrub.test.ts new file mode 100644 index 0000000000..09af5c44d6 --- /dev/null +++ b/packages/cli/src/auth/scrub.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { scrubCredentials } from "./scrub.js"; + +describe("auth/scrub", () => { + it("redacts HeyGen API keys", () => { + const out = scrubCredentials("rejected key hg_supersecret_abc123 from header"); + expect(out).not.toContain("hg_supersecret_abc123"); + expect(out).toContain("hg_"); + }); + + it("redacts sk_V2_ keys echoed inline (not just after Authorization:)", () => { + // Real HeyGen keys are `sk_V2_…`. A token echoed in a stack trace or + // JSON payload — without a header-name anchor — must still be + // redacted, per the threat model the scrubber is written for. + const out = scrubCredentials( + "error: token sk_V2_hgu_supersecret_real999 was rejected upstream", + ); + expect(out).not.toContain("sk_V2_hgu_supersecret_real999"); + expect(out).toContain("sk_"); + }); + + it("redacts authorization / x-api-key header lines", () => { + const out = scrubCredentials("x-api-key: hg_zzz999\nauthorization: Bearer abc"); + expect(out).not.toContain("hg_zzz999"); + expect(out).not.toContain("Bearer abc"); + expect(out).toContain("x-api-key: "); + expect(out).toContain("authorization: "); + }); + + it("redacts the full Authorization: Bearer value, not just the scheme", () => { + const out = scrubCredentials("echoed Authorization: Bearer at_opaque_secret_999\nnext line"); + // The opaque token after `Bearer` must be gone. + expect(out).not.toContain("at_opaque_secret_999"); + expect(out).not.toContain("Bearer at_opaque_secret_999"); + expect(out).toContain("Authorization: "); + // Redaction stops at the line break — unrelated following lines survive. + expect(out).toContain("next line"); + }); + + it("redacts JWT-shaped tokens", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcDEF_123-xyz"; + const out = scrubCredentials(`token was ${jwt}`); + expect(out).not.toContain(jwt); + expect(out).toContain(""); + }); + + it("redacts form-encoded OAuth secrets (refresh_token, code, code_verifier)", () => { + const body = + "error: grant_type=refresh_token&refresh_token=rt_secret_abc&code=auth_code_xyz&code_verifier=verif_123"; + const out = scrubCredentials(body); + expect(out).not.toContain("rt_secret_abc"); + expect(out).not.toContain("auth_code_xyz"); + expect(out).not.toContain("verif_123"); + expect(out).toContain("refresh_token="); + expect(out).toContain("code="); + expect(out).toContain("code_verifier="); + // grant_type is not a secret — keep it for debuggability. + expect(out).toContain("grant_type=refresh_token"); + }); + + it("redacts JSON-encoded OAuth secrets", () => { + const body = '{"error":"invalid","access_token":"at_leak","refresh_token":"rt_leak"}'; + const out = scrubCredentials(body); + expect(out).not.toContain("at_leak"); + expect(out).not.toContain("rt_leak"); + expect(out).toContain('"access_token":""'); + expect(out).toContain('"refresh_token":""'); + }); + + it("does not over-redact unrelated text", () => { + const out = scrubCredentials("error_code=42 the request failed validation"); + expect(out).toBe("error_code=42 the request failed validation"); + }); +}); diff --git a/packages/cli/src/auth/scrub.ts b/packages/cli/src/auth/scrub.ts new file mode 100644 index 0000000000..c52bbc18b3 --- /dev/null +++ b/packages/cli/src/auth/scrub.ts @@ -0,0 +1,45 @@ +/** + * Redact credential-shaped substrings from error bodies before they + * reach user-facing messages, `--json` output, or logs. + * + * Both the `/v3/users/me` client and the OAuth token/revoke endpoints + * surface upstream error bodies. A misbehaving server or proxy can echo + * request data (API keys, OAuth `refresh_token` / `code` / `code_verifier`, + * bearer tokens, JWTs) into those bodies — this scrubber makes sure none + * of it lands in scrollback or CI logs. + */ + +// Both HeyGen key prefixes: legacy `hg_…` and current `sk_V2_…` (plus +// any `sk__…` partner format). A bare key echoed inline in an +// error body — without an Authorization:/x-api-key: header anchor — +// must still be redacted, so we match the prefix directly. +const HEYGEN_KEY = /\b(hg|sk)_[A-Za-z0-9_-]{4,}/g; +// Redact the ENTIRE header value to end-of-line. `Authorization: Bearer +// ` is two whitespace-separated words, so a `\S+` would stop after +// the scheme and leave the opaque token exposed. +const HEADER_LINE = /(authorization|x-api-key)[ \t]*[:=][ \t]*[^\r\n]+/gi; +const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g; + +// Sensitive OAuth/credential field names. Matched both as +// form-encoded (`name=value`) and JSON (`"name":"value"`). +const SECRET_FIELDS = [ + "access_token", + "refresh_token", + "code_verifier", + "client_secret", + "id_token", + "token", + "code", +]; + +const FORM_FIELD = new RegExp(`\\b(${SECRET_FIELDS.join("|")})=[^&\\s"']+`, "gi"); +const JSON_FIELD = new RegExp(`("(?:${SECRET_FIELDS.join("|")})"\\s*:\\s*)"[^"]*"`, "gi"); + +export function scrubCredentials(s: string): string { + return s + .replace(HEYGEN_KEY, "$1_") + .replace(HEADER_LINE, "$1: ") + .replace(JWT, "") + .replace(FORM_FIELD, "$1=") + .replace(JSON_FIELD, '$1""'); +} diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 549f31bf9f..f6beeecd23 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -16,9 +16,11 @@ import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; export const examples: Example[] = [ + ["Sign in via browser (OAuth)", "hyperframes auth login"], ["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"], + ["Force-refresh the OAuth access token", "hyperframes auth refresh"], ["Sign out", "hyperframes auth logout"], ]; @@ -29,15 +31,17 @@ 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("login")} ${c.dim("Sign in via browser (default) or --api-key for a long-lived key.")} ${c.accent("status")} ${c.dim("Show the active credential's source, type, and identity.")} + ${c.accent("refresh")} ${c.dim("Force-refresh the OAuth access token.")} ${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). + ${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). + ${c.accent("HYPERFRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test). `; export default defineCommand({ @@ -46,6 +50,7 @@ export default defineCommand({ 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), + refresh: () => import("./auth/refresh.js").then((m) => m.default), }, async run({ args }) { if (!args._?.[0]) console.log(HELP); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index b0a05a2eb3..c9d95c63fd 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,39 +1,38 @@ /** - * `hyperframes auth login` — write a HeyGen credential to - * `~/.heygen/credentials`. + * `hyperframes auth login` — sign in to HeyGen. * - * 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. + * Default: OAuth 2.0 + PKCE via a loopback callback. The CLI opens + * the user's browser, captures the authorization code on an + * ephemeral 127.0.0.1 port, exchanges it for tokens, and persists + * them to `~/.heygen/credentials`. * - * 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. + * `--api-key`: opts into the legacy long-lived API-key path. * * Write semantics: - * - Read the existing credential file first; preserve any `oauth` - * block so saving a new API key doesn't wipe an OAuth session. + * - Snapshot existing credentials first; merge so a new OAuth session + * preserves an existing API key (and vice versa). * - Sanity-check that the input is non-empty and header-safe (no - * CR/LF) before touching disk. The backend's `/v3/users/me` is - * the source of truth for whether the key is actually valid — - * we do NOT shape-check the prefix (real keys come in multiple - * formats: `sk_V2_…`, `hg_…`, partner keys, etc.). + * CR/LF) before touching disk. The backend's `/v3/users/me` is the + * source of truth for whether the key is actually valid — we do + * NOT shape-check the prefix (real keys come in multiple formats: + * `sk_V2_…`, `hg_…`, partner keys, etc.). * - 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. + * state. Network/5xx errors keep the new credential in place per + * the transient-blip rationale. */ import { defineCommand } from "citty"; import { stdin as input } from "node:process"; import { AuthClient, + assertOAuthConfiguredOrExit, deleteStore, isAuthError, isHeaderSafe, readStore, + refreshTokens, + startAuthorizationCodeFlow, + tryResolveCredential, writeStore, type Credentials, } from "../../auth/index.js"; @@ -49,59 +48,95 @@ const MIN_KEY_LENGTH = 8; export default defineCommand({ meta: { name: "login", - description: "Sign in to HeyGen by saving an API key (OAuth coming soon)", + description: "Sign in to HeyGen (OAuth by default; --api-key for long-lived keys)", }, args: { "api-key": { type: "string", - description: - "API key value. Pass `--api-key` with no value to read from stdin or interactively.", + description: "API key value, or pass `--api-key` with no value to read from stdin / prompt.", }, }, // fallow-ignore-next-line complexity async run({ args }) { const inlineKey = args["api-key"]; - if (inlineKey === undefined) { - printOAuthPlaceholder(); - process.exit(1); + if (inlineKey !== undefined) { + await runApiKeyLogin(inlineKey); + return; } + await runOAuthLogin(); + }, +}); - const key = await collectApiKey(inlineKey); - if (!key) { - console.error(c.error("No API key provided.")); - process.exit(1); - } - if (!isHeaderSafe(key)) { - // CR/LF in the value would smuggle headers when the key is sent - // via `x-api-key`. The backend handles "wrong key" itself, but - // header-injection has to be caught here. - console.error(c.error("API key must not contain newline or control characters.")); - process.exit(1); - } - if (key.length < MIN_KEY_LENGTH) { - console.error(c.error(`API key looks too short (got ${key.length} chars).`)); - process.exit(1); - } +// fallow-ignore-next-line complexity +async function runOAuthLogin(): Promise { + assertOAuthConfiguredOrExit(); - const previous = await snapshotStore(); - const next: Credentials = { ...previous, api_key: key }; - await writeStore(next); + try { + await startAuthorizationCodeFlow(); + } catch (err) { + console.error(c.error(`Sign-in failed: ${(err as Error).message}`)); + process.exit(1); + } - const verifyOk = await verifyAndReport(key); - if (!verifyOk) { - await rollback(previous); - process.exit(1); - } - }, -}); + await reportIdentity(); +} -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")}`, - ); +// fallow-ignore-next-line complexity +async function reportIdentity(): Promise { + const credential = await tryResolveCredential(); + if (!credential) { + console.error(c.warn("Sign-in completed but no credential was persisted.")); + process.exit(1); + } + // Wire the refresh hook here too — a freshly-minted token shouldn't + // need it, but a fast IdP-side rotation (or a misconfigured short + // TTL) shouldn't punish the user with a hard failure when the + // refresh_token would have transparently fixed it. + const client = new AuthClient({ + onUnauthenticatedRefresh: async (rt) => await refreshTokens(rt), + }); + try { + const user = await client.getCurrentUser(credential); + const identity = user.email ?? user.username ?? "(unknown user)"; + console.log(c.success(`✓ Signed in as ${identity}.`)); + } catch (err) { + // Don't roll back — the OAuth tokens are valid on disk; this is a + // transient verify-side issue. Surface as a warning so the user + // can re-check with `auth status` rather than re-running login. + console.error( + c.warn(`Signed in. Identity check failed (transient): ${(err as Error).message}`), + ); + } +} + +// fallow-ignore-next-line complexity +async function runApiKeyLogin(inlineKey: string): Promise { + const key = await collectApiKey(inlineKey); + if (!key) { + console.error(c.error("No API key provided.")); + process.exit(1); + } + if (!isHeaderSafe(key)) { + // CR/LF in the value would smuggle headers when the key is sent + // via `x-api-key`. The backend handles "wrong key" itself, but + // header-injection has to be caught here. + console.error(c.error("API key must not contain newline or control characters.")); + process.exit(1); + } + if (key.length < MIN_KEY_LENGTH) { + console.error(c.error(`API key looks too short (got ${key.length} chars).`)); + 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); + } } async function snapshotStore(): Promise { @@ -109,8 +144,6 @@ async function snapshotStore(): Promise { 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 {}; } } @@ -132,11 +165,6 @@ async function rollback(previous: Credentials): Promise { } } -/** - * 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(); @@ -158,12 +186,6 @@ async function verifyAndReport(key: string): Promise { } } -/** - * 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) { @@ -172,11 +194,6 @@ async function collectApiKey(inline: string): Promise { 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, diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 4fcf6afa64..a836bf956c 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -8,7 +8,14 @@ */ import { defineCommand } from "citty"; -import { clearOAuth, configDir, credentialPath, deleteStore } from "../../auth/index.js"; +import { + clearOAuth, + configDir, + credentialPath, + deleteStore, + readStore, + revokeTokens, +} from "../../auth/index.js"; import { c } from "../../ui/colors.js"; export default defineCommand({ @@ -34,6 +41,10 @@ export default defineCommand({ process.exit(1); } + // Best-effort revoke before we wipe local state. RFC 7009 says + // success is empty 200 — we ignore failure either way. + await bestEffortRevoke(); + if (keepApiKey) { await clearOAuth(); console.log(c.success("✓ OAuth session removed. API key retained.")); @@ -62,6 +73,26 @@ async function ensureConfirmed(yes: boolean, keepApiKey: boolean): Promise { + try { + const { credentials, source } = await readStore(); + if (source === "absent" || !credentials.oauth) return; + const { access_token, refresh_token } = credentials.oauth; + // Revoke the refresh_token first (per RFC 7009, that typically + // invalidates all derived access tokens), but also revoke the + // access_token explicitly to cover servers that don't cascade. + if (refresh_token) { + await revokeTokens(refresh_token, { token_type_hint: "refresh_token" }); + } + if (access_token) { + await revokeTokens(access_token, { token_type_hint: "access_token" }); + } + } catch { + /* Best-effort — never block local wipe on a network/IdP issue. */ + } +} + async function confirmInteractive(prompt: string): Promise { if (!process.stdin.isTTY) return false; const { createInterface } = await import("node:readline"); diff --git a/packages/cli/src/commands/auth/refresh.ts b/packages/cli/src/commands/auth/refresh.ts new file mode 100644 index 0000000000..d8b161db7a --- /dev/null +++ b/packages/cli/src/commands/auth/refresh.ts @@ -0,0 +1,48 @@ +/** + * `hyperframes auth refresh` — force-refresh the OAuth access_token + * using the stored refresh_token. + * + * Mostly useful for testing the refresh path or for users on flaky + * networks who want to pre-emptively refresh before a long render + * job. Status's 401-retry path already does this automatically. + */ + +import { defineCommand } from "citty"; +import { + assertOAuthConfiguredOrExit, + isAuthError, + readStore, + refreshTokens, +} from "../../auth/index.js"; +import { c } from "../../ui/colors.js"; + +export default defineCommand({ + meta: { name: "refresh", description: "Force-refresh the OAuth access token" }, + args: {}, + // fallow-ignore-next-line complexity + async run() { + assertOAuthConfiguredOrExit(); + + const { credentials, source } = await readStore(); + if (source === "absent" || !credentials.oauth?.refresh_token) { + console.error(c.warn("No OAuth refresh token to use. Run `hyperframes auth login` first.")); + process.exit(1); + } + + try { + // refreshTokens persists via oauth.ts:persistOAuth, which merges + // into a freshly-read store (preserving api_key + any + // refresh_token the server didn't rotate). Re-writing here would + // use a stale snapshot and risks clobbering concurrent writes. + await refreshTokens(credentials.oauth.refresh_token); + console.log(c.success("✓ Refreshed OAuth access token.")); + } catch (err) { + if (isAuthError(err) && err.code === "REFRESH_FAILED") { + console.error(c.error(err.message)); + if (err.hint) console.error(c.dim(err.hint)); + process.exit(1); + } + throw err; + } + }, +}); diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index fdca580606..832f6a0d07 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -10,6 +10,7 @@ import { defineCommand } from "citty"; import { AuthClient, isAuthError, + refreshTokens, tryResolveCredential, type ResolvedCredential, type UserInfo, @@ -76,7 +77,12 @@ function handleResolveError(err: unknown, asJson: boolean): never { } async function verify(credential: ResolvedCredential): Promise { - const client = new AuthClient(); + const client = new AuthClient({ + // Return the full new token set so the retry's credential carries + // a rotated refresh_token forward (defends against IdPs that + // invalidate the old RT on every refresh). + onUnauthenticatedRefresh: async (rt) => await refreshTokens(rt), + }); try { const user = await client.getCurrentUser(credential); return { credential, user, apiError: null };