diff --git a/docs/guides/authentication.mdx b/docs/guides/authentication.mdx index 3a7120eb18..9236e6ebc1 100644 --- a/docs/guides/authentication.mdx +++ b/docs/guides/authentication.mdx @@ -37,7 +37,7 @@ Signing in is the same OAuth step as creating an account — new users land on t -The credential lives in `~/.heygen/credentials` (mode `0600`) — no per-repo `.env` to manage. Browser OAuth is a `hyperframes auth login` feature. The separate [`heygen` CLI](https://github.com/heygen-com/heygen-cli) (its own install — there's no `npx heygen`) is API-key-only, so `heygen auth login` just stores a key you paste. Both read the same `~/.heygen/credentials`, so signing in with one carries to the other. +HyperFrames uses its own public OAuth client and stores credentials in `~/.hyperframes/credentials` (mode `0600`). Sign in again after upgrading from the shared HeyGen login. The separate [`heygen` CLI](https://github.com/heygen-com/heygen-cli) keeps its own login in `~/.heygen`; HyperFrames does not move or delete it. An expired HyperFrames OAuth session requires refresh or sign-in and never selects a saved API key instead. An explicit API-key login or environment key selects API-key billing. No account is needed to try HyperFrames locally. With no credential, voice can @@ -54,12 +54,11 @@ sound retrieval. It resolves first-match-wins: 2. `HEYGEN_ACCESS_TOKEN` — an OAuth token a host app injects 3. `HEYGEN_API_KEY` — environment variable 4. `HYPERFRAMES_API_KEY` — alias, for parity with other tools -5. `~/.heygen/credentials` — written by `hyperframes auth login` (or `heygen auth login`) +5. `~/.hyperframes/credentials` — written by `hyperframes auth login` A nearby project `.env` can supply the keys, but never `HEYGEN_API_BASE`. Point at a different config directory -with `HEYGEN_CONFIG_DIR`. The `hyperframes` CLI's own commands check the API keys before `HEYGEN_ACCESS_TOKEN`; see -the [CLI reference](/packages/cli). `media-use resolve` searches through the separate `heygen` CLI, which resolves its -own credential. +with `HYPERFRAMES_CONFIG_DIR`. The `hyperframes` CLI's own commands check the API keys before `HEYGEN_ACCESS_TOKEN`; see +the [CLI reference](/packages/cli). When `media-use` invokes the separate `heygen` executable, it supplies an isolated temporary credential containing the selected access token. That helper cannot refresh a HyperFrames grant or pick up another HeyGen login. ## Providers used by agent workflows @@ -70,7 +69,7 @@ vision key is available. | Capability | Provider order | Key(s) — first match wins | Local dependency | |------------|----------------|---------------------------|------------------| -| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` → `~/.heygen` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` | +| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` → `~/.hyperframes` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` | | **Music (BGM)** | HeyGen library → Lyria → MusicGen | HeyGen credential (above) · then `GEMINI_API_KEY` → `GOOGLE_API_KEY` | MusicGen: `pip install transformers torch soundfile numpy` | | **Sound effects** | HeyGen library → bundled library | HeyGen credential (above) | bundled — no deps | | **Capture descriptions** | OpenRouter → Gemini | `OPENROUTER_API_KEY` → `GEMINI_API_KEY` | None; optional for [website capture](/guides/product-launch-video) | @@ -145,7 +144,9 @@ to a shared space with `--space`. | `HEYGEN_ACCESS_TOKEN` | OAuth token a host app injects; never refreshed or saved. | | `HEYGEN_API_BASE` | A host app's gateway for media workflows; never read from a project `.env`. | | `HEYGEN_API_URL` | API base URL for `hyperframes` CLI commands (default `https://api.heygen.com`). | -| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). | +| `HYPERFRAMES_CONFIG_DIR` | HyperFrames credentials root (default `~/.hyperframes`). | +| `HYPERFRAMES_ENV` | `prod` (default) or `dev`; switches OAuth and API defaults together. Dev credentials live under the root’s `dev/` directory. | +| `HYPERFRAMES_OAUTH_CLIENT_ID` | Override the public client ID for a controlled OAuth environment. | | `ELEVENLABS_API_KEY` | ElevenLabs TTS, used when no HeyGen credential is present. | | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Explicit Gemini TTS selection and Lyria music generation; capture descriptions use `GEMINI_API_KEY`. | | `GOOGLE_APPLICATION_CREDENTIALS` / `GCS_CREDS` | Gemini TTS service-account JSON file path / injected JSON. Used when neither API key is set. | diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index f21de16913..1fef7f09b6 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -682,10 +682,11 @@ and are copied to `~/.media/` so other projects can reuse them. Sound effects under CC BY get their credit written to `.media/audio/sfx/CREDITS.md`. **What needs an account, and what can cost money.** The HeyGen sources run -through the separate `heygen` CLI, signed in with `heygen auth login --oauth`. -Searching the music, image, and icon catalogs is free. Text to speech and avatar -video draw on a free allowance when you are signed in with OAuth, then on your -HeyGen billing, so an agent should ask before generating them. When a +through the separate `heygen` executable using the login selected by +`hyperframes auth login`. It receives a temporary access-token credential and +cannot read the separate HeyGen login or refresh a HyperFrames grant. +The server decides feature eligibility and charges. An explicit API-key credential +uses API-key billing. When a host app sets `HEYGEN_API_BASE` and `HEYGEN_API_KEY`, HeyGen calls go through that host instead and are charged to its key. The `codex` image fallback uses your ChatGPT subscription. Openverse, theSVG, GitHub, favicons, color grades, and the @@ -2152,16 +2153,16 @@ usage event (`--skill`, `--event`, `--outcome`). Nothing calls it by hand. ## hyperframes auth -Sign in to HeyGen and manage credentials. They live in `~/.heygen/credentials` -(mode `0600`) and are **shared with the `heygen` CLI** — sign in with one and -the other picks up the session. +Sign into your HeyGen account through the dedicated HyperFrames OAuth client. +Credentials live in `~/.hyperframes/credentials` (mode `0600`), independently +of the `heygen` CLI. A previous shared login requires a fresh HyperFrames sign-in. First match wins: 1. `HEYGEN_API_KEY` 2. `HYPERFRAMES_API_KEY` (a HyperFrames alias for the same thing) 3. `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved) -4. `~/.heygen/credentials` +4. `~/.hyperframes/credentials` ### `auth login` @@ -2221,7 +2222,9 @@ hyperframes auth logout --yes # no prompt | `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. | | `HEYGEN_ACCESS_TOKEN` | OAuth token from a host app; never refreshed. | | `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). | -| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). | +| `HYPERFRAMES_CONFIG_DIR` | Credentials root (default `~/.hyperframes`). | +| `HYPERFRAMES_ENV` | `prod` (default) or `dev`; selects matching OAuth/API hosts and a separate dev credential directory. | +| `HYPERFRAMES_OAUTH_CLIENT_ID` | Override the selected public OAuth client. | For the keys other capabilities use — ElevenLabs and Gemini for voice and music fallback, OpenRouter and Gemini for capture — and how the skills prioritize diff --git a/packages/cli/scripts/test-home.mjs b/packages/cli/scripts/test-home.mjs index 708c7ac013..07feafbfc6 100644 --- a/packages/cli/scripts/test-home.mjs +++ b/packages/cli/scripts/test-home.mjs @@ -18,6 +18,7 @@ for (const name of [ "CODEX_HOME", "CLAUDE_CONFIG_DIR", "HEYGEN_CONFIG_DIR", + "HYPERFRAMES_CONFIG_DIR", "HYPERFRAMES_CATALOG_ARTIFACT_DIR", "HYPERFRAMES_MEDIA_HOME", "HF_HOME", diff --git a/packages/cli/src/auth/_test-utils.ts b/packages/cli/src/auth/_test-utils.ts index 4afd6cd224..bbaceb14e2 100644 --- a/packages/cli/src/auth/_test-utils.ts +++ b/packages/cli/src/auth/_test-utils.ts @@ -14,9 +14,16 @@ const ENV_KEYS = [ "HEYGEN_API_KEY", "HEYGEN_ACCESS_TOKEN", "HYPERFRAMES_API_KEY", + "HYPERFRAMES_CONFIG_DIR", "HEYGEN_CONFIG_DIR", + "HEYGEN_API_BASE", "HEYGEN_API_URL", "HYPERFRAMES_OAUTH_CLIENT_ID", + "HYPERFRAMES_ENV", + "HYPERFRAMES_OAUTH_TOKEN_URL", + "HYPERFRAMES_OAUTH_AUTHORIZE_URL", + "HYPERFRAMES_OAUTH_REVOKE_URL", + "HYPERFRAMES_OAUTH_DEVICE_URL", ] as const; type EnvKey = (typeof ENV_KEYS)[number]; @@ -30,7 +37,7 @@ export interface EnvFixture { /** * Take a snapshot of the auth-related env, clear them, make a tmp - * `HEYGEN_CONFIG_DIR`, and return a `restore()` that undoes all of + * `HYPERFRAMES_CONFIG_DIR`, and return a `restore()` that undoes all of * the above. */ export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise { @@ -40,7 +47,7 @@ export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise => { for (const k of ENV_KEYS) { diff --git a/packages/cli/src/auth/client.ts b/packages/cli/src/auth/client.ts index 0bb3f92bb4..a485f1b278 100644 --- a/packages/cli/src/auth/client.ts +++ b/packages/cli/src/auth/client.ts @@ -19,8 +19,8 @@ import { ErrApi, ErrUnauthenticated, isAuthError } from "./errors.js"; import type { ResolvedCredential } from "./resolver.js"; import { scrubCredentials } from "./scrub.js"; import type { OAuthTokens } from "./store.js"; +import { oauthEnvironment } from "./config.js"; -const DEFAULT_BASE_URL = "https://api.heygen.com"; export const HEYGEN_CLI_SOURCE_HEADER = "X-HeyGen-Source"; export const HEYGEN_CLI_SOURCE = "cli"; // Tool-attribution header identifying this CLI as the request origin. Sent on @@ -32,7 +32,9 @@ export const HEYGEN_CLIENT_SOURCE = "hyperframes"; export function apiBaseUrl(): string { const override = process.env["HEYGEN_API_URL"]; - return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL; + return override && override.length > 0 + ? override.replace(/\/+$/, "") + : `https://api.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com`; } export type BillingType = "wallet" | "subscription" | "usage_based" | string; diff --git a/packages/cli/src/auth/config.test.ts b/packages/cli/src/auth/config.test.ts new file mode 100644 index 0000000000..9e50b53fa3 --- /dev/null +++ b/packages/cli/src/auth/config.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setupTempAuthEnv } from "./_test-utils.js"; +import { + assertOAuthIdentity, + authorizeEndpoint, + deviceAuthorizationEndpoint, + oauthIdentity, + resolveClientId, + revokeEndpoint, + tokenEndpoint, +} from "./config.js"; +import { apiBaseUrl } from "./client.js"; + +describe("HyperFrames OAuth configuration", () => { + let fixture: Awaited>; + beforeEach(async () => { + fixture = await setupTempAuthEnv(); + }); + afterEach(async () => { + await fixture.restore(); + }); + + it("uses the dedicated public production client", () => { + expect(resolveClientId()).toBe("Zg3LF4FCLoeJoGs2YqMtZ17g"); + expect(authorizeEndpoint()).toBe("https://app.heygen.com/oauth/authorize"); + expect(tokenEndpoint()).toBe("https://api2.heygen.com/v1/oauth/token"); + expect(apiBaseUrl()).toBe("https://api.heygen.com"); + }); + + it("switches every default host together for development", () => { + process.env["HYPERFRAMES_ENV"] = "dev"; + expect(resolveClientId()).toBe("9QoaiwCZov9O0cAkC4OZbR4p"); + expect(authorizeEndpoint()).toBe("https://api2.dev.heygen.com/v1/oauth/authorize"); + expect(tokenEndpoint()).toBe("https://api2.dev.heygen.com/v1/oauth/token"); + expect(revokeEndpoint()).toBe("https://api2.dev.heygen.com/v1/oauth/revoke"); + expect(deviceAuthorizationEndpoint()).toBe( + "https://api2.dev.heygen.com/v1/oauth/device_authorization", + ); + expect(apiBaseUrl()).toBe("https://api.dev.heygen.com"); + }); + + it("rejects a session after either its client or token server changes", () => { + const identity = oauthIdentity(); + expect(() => assertOAuthIdentity(identity)).not.toThrow(); + process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "another-client"; + expect(() => assertOAuthIdentity(identity)).toThrow("different OAuth client or environment"); + delete process.env["HYPERFRAMES_OAUTH_CLIENT_ID"]; + process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] = "https://other.example/token"; + expect(() => assertOAuthIdentity(identity)).toThrow("different OAuth client or environment"); + }); +}); diff --git a/packages/cli/src/auth/config.ts b/packages/cli/src/auth/config.ts new file mode 100644 index 0000000000..b76ca60f17 --- /dev/null +++ b/packages/cli/src/auth/config.ts @@ -0,0 +1,65 @@ +import { AuthError, ErrOAuthNotConfigured } from "./errors.js"; + +const PROD_CLIENT_ID = "Zg3LF4FCLoeJoGs2YqMtZ17g"; + +export function oauthEnvironment(): "prod" | "dev" { + const value = process.env["HYPERFRAMES_ENV"] || "prod"; + if (value === "prod" || value === "dev") return value; + throw new AuthError("OAUTH_NOT_CONFIGURED", "HYPERFRAMES_ENV must be prod or dev"); +} + +export function resolveClientId(): string { + const id = + process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] || + (oauthEnvironment() === "prod" ? PROD_CLIENT_ID : "9QoaiwCZov9O0cAkC4OZbR4p"); + if (!id) throw ErrOAuthNotConfigured(); + return id; +} + +export function tokenEndpoint(): string { + return ( + process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] || + `https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com/v1/oauth/token` + ); +} + +export function authorizeEndpoint(): string { + return ( + process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || + (oauthEnvironment() === "dev" + ? "https://api2.dev.heygen.com/v1/oauth/authorize" + : "https://app.heygen.com/oauth/authorize") + ); +} + +export function revokeEndpoint(): string { + return ( + process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || + `https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com/v1/oauth/revoke` + ); +} + +export function deviceAuthorizationEndpoint(): string { + return ( + process.env["HYPERFRAMES_OAUTH_DEVICE_URL"] || + `https://api2.${oauthEnvironment() === "dev" ? "dev." : ""}heygen.com/v1/oauth/device_authorization` + ); +} + +export function oauthIdentity(): { client_id: string; token_endpoint: string } { + return { client_id: resolveClientId(), token_endpoint: tokenEndpoint() }; +} + +export function assertOAuthIdentity(tokens: { client_id?: string; token_endpoint?: string }): void { + const identity = oauthIdentity(); + if ( + tokens.client_id !== identity.client_id || + tokens.token_endpoint !== identity.token_endpoint + ) { + throw new AuthError( + "LOGIN_EXPIRED", + "This login belongs to a different OAuth client or environment", + "Run `hyperframes auth login` to sign into the selected HyperFrames environment.", + ); + } +} diff --git a/packages/cli/src/auth/errors.ts b/packages/cli/src/auth/errors.ts index bb307c9d3c..365afbc952 100644 --- a/packages/cli/src/auth/errors.ts +++ b/packages/cli/src/auth/errors.ts @@ -37,7 +37,7 @@ 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.", + "Delete ~/.hyperframes/credentials and run `hyperframes auth login` to re-create it.", ); export const ErrUnauthenticated = (detail?: string) => diff --git a/packages/cli/src/auth/media-environment.test.ts b/packages/cli/src/auth/media-environment.test.ts new file mode 100644 index 0000000000..c49abd56b2 --- /dev/null +++ b/packages/cli/src/auth/media-environment.test.ts @@ -0,0 +1,78 @@ +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setupTempAuthEnv } from "./_test-utils.js"; +import { persistFreshOAuth } from "./oauth.js"; +import { withMediaAuthEnvironment } from "./media-environment.js"; + +describe("media-use authentication", () => { + let fixture: Awaited>; + beforeEach(async () => { + fixture = await setupTempAuthEnv(); + }); + afterEach(async () => { + await fixture.restore(); + }); + + it("passes a fresh access token through a private, temporary Go credential file", async () => { + await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" }); + let path = ""; + await withMediaAuthEnvironment(async (env) => { + path = join(env.HEYGEN_CONFIG_DIR!, "credentials"); + expect(env.HEYGEN_ACCESS_TOKEN).toBe("hf-access"); + expect(env.HEYGEN_API_BASE).toBe("https://api.heygen.com"); + expect(env.HEYGEN_API_KEY).toBeUndefined(); + const copied = JSON.parse(await fs.readFile(path, "utf8")); + expect(copied.oauth.access_token).toBe("hf-access"); + expect(copied.oauth.refresh_token).toBeUndefined(); + expect((await fs.stat(path)).mode & 0o777).toBe(0o600); + expect((await fs.stat(env.HEYGEN_CONFIG_DIR!)).mode & 0o777).toBe(0o700); + }); + await expect(fs.stat(path)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await fs.readFile(join(fixture.dir, "credentials"), "utf8")).toContain("hf-refresh"); + }); + + it("refuses to send OAuth to a non-HeyGen media gateway", async () => { + await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" }); + process.env["HEYGEN_API_BASE"] = "https://gateway.example"; + try { + await expect(withMediaAuthEnvironment(() => undefined)).rejects.toThrow( + "requires its own API credential", + ); + } finally { + delete process.env["HEYGEN_API_BASE"]; + } + }); + + it("isolates an anonymous operation from an existing HeyGen login", async () => { + process.env["HEYGEN_CONFIG_DIR"] = join(fixture.dir, "heygen"); + await fs.mkdir(process.env["HEYGEN_CONFIG_DIR"]); + const legacyPath = join(process.env["HEYGEN_CONFIG_DIR"], "credentials"); + await fs.writeFile(legacyPath, "hg_other_wallet"); + await withMediaAuthEnvironment(async (env) => { + expect(env.HEYGEN_CONFIG_DIR).not.toBe(process.env["HEYGEN_CONFIG_DIR"]); + expect(await fs.readdir(env.HEYGEN_CONFIG_DIR!)).toEqual([]); + expect(env.HEYGEN_API_KEY).toBeUndefined(); + }); + expect(await fs.readFile(legacyPath, "utf8")).toBe("hg_other_wallet"); + }); + + it("preserves an explicit host gateway and cleans up after a subprocess error", async () => { + process.env["HEYGEN_API_BASE"] = "https://gateway.example"; + process.env["HEYGEN_API_KEY"] = "gateway-key"; + let dir = ""; + try { + await expect( + withMediaAuthEnvironment((env) => { + dir = env.HEYGEN_CONFIG_DIR!; + expect(env.HEYGEN_API_BASE).toBe("https://gateway.example"); + expect(env.HEYGEN_API_KEY).toBe("gateway-key"); + throw new Error("subprocess failed"); + }), + ).rejects.toThrow("subprocess failed"); + await expect(fs.stat(dir)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + delete process.env["HEYGEN_API_BASE"]; + } + }); +}); diff --git a/packages/cli/src/auth/media-environment.ts b/packages/cli/src/auth/media-environment.ts new file mode 100644 index 0000000000..531124572d --- /dev/null +++ b/packages/cli/src/auth/media-environment.ts @@ -0,0 +1,62 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { apiBaseUrl } from "./client.js"; +import { refreshTokens } from "./oauth.js"; +import { AuthError, isAuthError } from "./errors.js"; +import { resolveCredential, tryResolveCredential } from "./resolver.js"; +import { writeStore } from "./store.js"; + +/** The Go CLI accepts OAuth through a credential file, but cannot refresh a HyperFrames grant. */ +export async function withMediaAuthEnvironment(run: (env: NodeJS.ProcessEnv) => T): Promise { + let credential; + try { + credential = await tryResolveCredential(); + } catch (error) { + // An expired login must not stop anonymous/local providers or select another wallet. + if (!isAuthError(error) || error.code !== "LOGIN_EXPIRED") throw error; + } + if (credential?.type === "oauth" && credential.refreshable && credential.refresh_token) { + await refreshTokens(credential.refresh_token); + credential = await resolveCredential(); + } + + const dir = await fs.mkdtemp(join(tmpdir(), "hyperframes-media-auth-")); + try { + await fs.chmod(dir, 0o700); + const env: NodeJS.ProcessEnv = { ...process.env, HEYGEN_CONFIG_DIR: dir }; + env.HEYGEN_API_BASE ||= apiBaseUrl(); + if (credential?.type === "oauth") { + const host = new URL(env.HEYGEN_API_BASE).hostname; + if (host !== "heygen.com" && !host.endsWith(".heygen.com")) { + throw new AuthError( + "OAUTH_NOT_CONFIGURED", + "A media gateway requires its own API credential", + ); + } + delete env.HEYGEN_API_KEY; + delete env.HYPERFRAMES_API_KEY; + env.HEYGEN_ACCESS_TOKEN = credential.access_token; + await writeStore( + { + oauth: { + access_token: credential.access_token, + expires_at: credential.expires_at?.toISOString(), + token_type: "Bearer", + }, + }, + join(dir, "credentials"), + ); + } else if (credential?.type === "api_key") { + env.HEYGEN_API_KEY = credential.key; + delete env.HEYGEN_ACCESS_TOKEN; + } else { + delete env.HEYGEN_ACCESS_TOKEN; + delete env.HEYGEN_API_KEY; + delete env.HYPERFRAMES_API_KEY; + } + return await run(env); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} diff --git a/packages/cli/src/auth/oauth.test.ts b/packages/cli/src/auth/oauth.test.ts index a391ef7f7d..d5d76a2740 100644 --- a/packages/cli/src/auth/oauth.test.ts +++ b/packages/cli/src/auth/oauth.test.ts @@ -1,3 +1,4 @@ +import { oauthIdentity } from "./config.js"; import { promises as fs } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setupTempAuthEnv } from "./_test-utils.js"; @@ -12,7 +13,7 @@ import { startAuthorizationCodeFlow, startDeviceAuthorizationFlow, } from "./oauth.js"; -import { readStore, writeStore } from "./store.js"; +import { readStore, writeStore as writeUnboundStore, type Credentials } from "./store.js"; // Mock the interactive bits so startAuthorizationCodeFlow runs headless. vi.mock("./loopback.js", () => ({ @@ -68,6 +69,13 @@ async function loginWithTokens(body: Record) { return (await readStore()).credentials; } +async function writeStore(credentials: Credentials): Promise { + await writeUnboundStore({ + ...credentials, + ...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}), + }); +} + describe("auth/oauth", () => { let fixture: Awaited>; @@ -170,6 +178,26 @@ describe("auth/oauth", () => { }); describe("refreshTokens", () => { + it.each([undefined, "foreign-client"])( + "does not send a refresh token with client binding %s", + async (client_id) => { + await writeUnboundStore({ + oauth: { + access_token: "at", + refresh_token: "rt", + client_id, + token_endpoint: oauthIdentity().token_endpoint, + }, + }); + const fetchImpl = vi.fn(); + await expect(refreshTokens("rt", { fetchImpl })).rejects.toMatchObject({ + code: "LOGIN_EXPIRED", + }); + expect(fetchImpl).not.toHaveBeenCalled(); + expect((await readStore()).credentials.oauth?.refresh_token).toBe("rt"); + }, + ); + it("posts grant_type=refresh_token and persists the response", async () => { process.env["HEYGEN_API_URL"] = "https://api.test.example"; await writeStore({ oauth: { access_token: "old_at", refresh_token: "old_rt" } }); @@ -196,7 +224,7 @@ describe("auth/oauth", () => { // Should have persisted. const { credentials } = await readStore(); - expect(credentials.oauth?.access_token).toBe("new_at"); + expect(credentials.oauth).toMatchObject({ ...oauthIdentity(), access_token: "new_at" }); }); it("preserves the prior refresh_token when the server omits it (no rotation)", async () => { @@ -264,6 +292,7 @@ describe("auth/oauth", () => { path, JSON.stringify({ oauth: { + ...oauthIdentity(), access_token: "old_at", refresh_token: "keep_me_rt", id_token: "future_id_token_value", @@ -281,6 +310,7 @@ describe("auth/oauth", () => { }); it("throws REFRESH_FAILED on 400/401", async () => { + await writeStore({ oauth: { access_token: "at", refresh_token: "bad_rt" } }); const fetchImpl = (async () => new Response("invalid_grant", { status: 400, @@ -291,6 +321,7 @@ describe("auth/oauth", () => { }); it("throws API_ERROR on 5xx", async () => { + await writeStore({ oauth: { access_token: "at", refresh_token: "rt" } }); const fetchImpl = (async () => new Response("upstream", { status: 503 })) as unknown as typeof fetch; await expect(refreshTokens("rt", { fetchImpl })).rejects.toSatisfy((err) => { @@ -300,6 +331,20 @@ describe("auth/oauth", () => { }); describe("revokeTokens", () => { + it("does not revoke another client's stored grant", async () => { + await writeUnboundStore({ + oauth: { + access_token: "foreign-at", + refresh_token: "foreign-rt", + client_id: "another-client", + token_endpoint: oauthIdentity().token_endpoint, + }, + }); + const fetchImpl = vi.fn(); + await revokeTokens("foreign-rt", { fetchImpl }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("never throws on network failure (best-effort)", async () => { const fetchImpl = (async () => { throw new Error("connection refused"); @@ -348,6 +393,7 @@ describe("auth/oauth", () => { describe("error scrubbing", () => { it("refreshTokens does not leak token-shaped secrets from the error body", async () => { + await writeStore({ oauth: { access_token: "at", refresh_token: "rt_leak_secret" } }); const fetchImpl = (async () => new Response( '{"error":"invalid_grant","echoed":"refresh_token=rt_leak_secret&code_verifier=cv_leak"}', diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index 4ef646beb8..e37fceef2e 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -38,7 +38,6 @@ import { ErrApi, ErrDeviceAuthFailed, ErrLoginChanged, - ErrOAuthNotConfigured, ErrRefreshFailed, isAuthError, } from "./errors.js"; @@ -55,25 +54,25 @@ import { type StoredUserInfo, } from "./store.js"; import { c } from "../ui/colors.js"; +import { + assertOAuthIdentity, + authorizeEndpoint, + deviceAuthorizationEndpoint, + oauthIdentity, + resolveClientId, + revokeEndpoint, + tokenEndpoint, +} from "./config.js"; + +export { resolveClientId } from "./config.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"; -const DEFAULT_DEVICE_AUTHORIZATION_URL = "https://api2.heygen.com/v1/oauth/device_authorization"; const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; const MAX_DEVICE_FLOW_SECONDS = 30 * 60; const MIN_DEVICE_POLL_SECONDS = 5; @@ -81,19 +80,6 @@ const MAX_DEVICE_POLL_SECONDS = 60; const MAX_DEVICE_RESPONSE_BYTES = 64 * 1024; const DEVICE_REQUEST_TIMEOUT_MS = 15_000; -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; -} -function deviceAuthorizationEndpoint(): string { - return process.env["HYPERFRAMES_OAUTH_DEVICE_URL"] || DEFAULT_DEVICE_AUTHORIZATION_URL; -} - export interface AuthorizeFlowOptions { /** Override scopes (default `openid profile email`). */ scope?: string; @@ -134,14 +120,6 @@ export interface DeviceAuthorizationFlowOptions { onChallenge?: (challenge: DeviceAuthorizationChallenge) => void | Promise; } -/** 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 @@ -385,6 +363,9 @@ export async function refreshTokens( refresh_token: string, opts: RefreshOptions = {}, ): Promise { + const { credentials } = await readStore(); + if (credentials.oauth?.refresh_token !== refresh_token) throw ErrLoginChanged(); + assertOAuthIdentity(credentials.oauth); const clientId = resolveClientId(); const fetchImpl = opts.fetchImpl ?? fetch; const body = new URLSearchParams({ @@ -432,6 +413,13 @@ export async function revokeTokens(token: string, opts: RevokeOptions = {}): Pro let clientId: string; try { clientId = resolveClientId(); + const { credentials } = await readStore(); + if ( + credentials.oauth && + (credentials.oauth.access_token === token || credentials.oauth.refresh_token === token) + ) { + assertOAuthIdentity(credentials.oauth); + } } catch { // OAuth not configured — nothing to revoke server-side. return; @@ -622,7 +610,9 @@ async function persistOAuth(tokens: OAuthTokens, opts: { refreshed?: string } = throw ErrLoginChanged(); } const oauth: OAuthTokens = - opts.refreshed !== undefined ? { ...existing.oauth, ...tokens } : { ...tokens }; + opts.refreshed !== undefined + ? { ...existing.oauth, ...tokens, ...oauthIdentity() } + : { ...tokens, ...oauthIdentity() }; // Start from the existing record so co-located data survives: the // api_key, the friendly-display `user` block, AND any unknown/foreign // keys another CLI wrote (carried on a hidden symbol slot by spread). @@ -656,7 +646,7 @@ export async function persistVerifiedOAuthSession( } const next: Credentials = { ...credentials, - oauth: { ...tokens }, + oauth: { ...tokens, ...oauthIdentity() }, }; if (user.email || user.first_name || user.last_name || user.username) { // Preserve only unknown/foreign user fields from the existing record. diff --git a/packages/cli/src/auth/paths.test.ts b/packages/cli/src/auth/paths.test.ts index ec78ef4556..785543c46f 100644 --- a/packages/cli/src/auth/paths.test.ts +++ b/packages/cli/src/auth/paths.test.ts @@ -1,32 +1,40 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } 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"]; + const original = process.env["HYPERFRAMES_CONFIG_DIR"]; beforeEach(() => { - delete process.env["HEYGEN_CONFIG_DIR"]; + delete process.env["HYPERFRAMES_CONFIG_DIR"]; }); afterEach(() => { - if (original !== undefined) process.env["HEYGEN_CONFIG_DIR"] = original; - else delete process.env["HEYGEN_CONFIG_DIR"]; + vi.unstubAllEnvs(); + if (original !== undefined) process.env["HYPERFRAMES_CONFIG_DIR"] = original; + else delete process.env["HYPERFRAMES_CONFIG_DIR"]; }); - it("defaults to ~/.heygen", () => { - expect(configDir()).toBe(join(homedir(), ".heygen")); + it("defaults to ~/.hyperframes", () => { + expect(configDir()).toBe(join(homedir(), ".hyperframes")); }); - it("honors HEYGEN_CONFIG_DIR override", () => { - process.env["HEYGEN_CONFIG_DIR"] = "/tmp/some-test-dir"; + it("honors HYPERFRAMES_CONFIG_DIR override", () => { + process.env["HYPERFRAMES_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")); + it("treats empty HYPERFRAMES_CONFIG_DIR as unset", () => { + process.env["HYPERFRAMES_CONFIG_DIR"] = ""; + expect(configDir()).toBe(join(homedir(), ".hyperframes")); + }); + + it("ignores the HeyGen CLI directory and isolates the dev environment", () => { + vi.stubEnv("HEYGEN_CONFIG_DIR", "/tmp/heygen-other-account"); + expect(configDir()).toBe(join(homedir(), ".hyperframes")); + vi.stubEnv("HYPERFRAMES_ENV", "dev"); + expect(configDir()).toBe(join(homedir(), ".hyperframes", "dev")); }); }); diff --git a/packages/cli/src/auth/paths.ts b/packages/cli/src/auth/paths.ts index 3df82f4fc2..c909b24650 100644 --- a/packages/cli/src/auth/paths.ts +++ b/packages/cli/src/auth/paths.ts @@ -1,23 +1,19 @@ /** - * 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. + * HyperFrames credentials are isolated from the HeyGen CLI and between environments. */ import { homedir } from "node:os"; import { join } from "node:path"; +import { oauthEnvironment } from "./config.js"; /** - * 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`. + * Filename for the HyperFrames credential store (JSON, no extension). */ 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"); + const root = process.env["HYPERFRAMES_CONFIG_DIR"] || join(homedir(), ".hyperframes"); + return oauthEnvironment() === "dev" ? join(root, "dev") : root; } export function credentialPath(): string { diff --git a/packages/cli/src/auth/resolver.test.ts b/packages/cli/src/auth/resolver.test.ts index 6119879381..9450d74475 100644 --- a/packages/cli/src/auth/resolver.test.ts +++ b/packages/cli/src/auth/resolver.test.ts @@ -1,10 +1,18 @@ +import { oauthIdentity } from "./config.js"; import { promises as fs } from "node:fs"; 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"; +import { writeStore as writeUnboundStore, type Credentials } from "./store.js"; + +async function writeStore(credentials: Credentials): Promise { + await writeUnboundStore({ + ...credentials, + ...(credentials.oauth ? { oauth: { ...oauthIdentity(), ...credentials.oauth } } : {}), + }); +} describe("auth/resolver", () => { let fixture: Awaited>; @@ -102,15 +110,20 @@ describe("auth/resolver", () => { if (r.type === "oauth") expect(r.refreshable).toBe(true); }); - it("skips expired non-refreshable oauth and falls through to api_key", async () => { + it("refuses an expired OAuth login without switching to the stored API wallet", 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"); + await expect(resolveCredential()).rejects.toMatchObject({ code: "LOGIN_EXPIRED" }); + }); + + it("requires a new login for an unbound or foreign OAuth credential", async () => { + await writeUnboundStore({ api_key: "fallback", oauth: { access_token: "legacy" } }); + await expect(resolveCredential()).rejects.toMatchObject({ code: "LOGIN_EXPIRED" }); + await writeStore({ oauth: { access_token: "foreign", client_id: "shared-heygen-cli" } }); + await expect(resolveCredential()).rejects.toMatchObject({ code: "LOGIN_EXPIRED" }); }); it("reports an expired login with no refresh token as expired, not as never signed in", async () => { diff --git a/packages/cli/src/auth/resolver.ts b/packages/cli/src/auth/resolver.ts index f2278ab730..f09559b28a 100644 --- a/packages/cli/src/auth/resolver.ts +++ b/packages/cli/src/auth/resolver.ts @@ -1,11 +1,11 @@ /** - * Chain resolver for HeyGen credentials. + * Chain resolver for HyperFrames 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_ACCESS_TOKEN` env (host-managed OAuth) - * 4. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key + * 4. `~/.hyperframes/credentials` (JSON) — bound OAuth or an explicit API key * * Absent sources fall through. Broken files surface `ErrInvalidStore` immediately; * silently falling back would mask user configuration errors. @@ -13,11 +13,12 @@ * 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, else `ErrLoginExpired`, not `ErrNotConfigured`. + * session requires sign-in; a saved API key never replaces expired OAuth. */ import { isHeaderSafe, readStore } from "./store.js"; import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js"; +import { assertOAuthIdentity } from "./config.js"; type EnvSource = "env" | "env_alias" | "env_oauth"; type CredentialSource = EnvSource | "file_json" | "file_legacy"; @@ -79,12 +80,14 @@ export async function resolveCredential(opts: ResolveOptions = {}): Promise