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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions docs/guides/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,19 @@ The credential lives in `~/.heygen/credentials` (mode `0600`) — no per-repo `.

## How the HeyGen credential resolves

Bundled media workflows use the HeyGen credential for hosted TTS and music /
The bundled audio scripts use the HeyGen credential for hosted TTS and music /
sound retrieval. It resolves first-match-wins:

1. `HEYGEN_API_KEY` — environment variable
2. `HYPERFRAMES_API_KEY` — alias, for parity with other tools
3. `~/.heygen/credentials` — written by `hyperframes auth login` (or `heygen auth login`)
1. `HEYGEN_API_BASE` with `HEYGEN_API_KEY` — a host app's own gateway, set by the app that started the workflow
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`)

Point at a different config directory with `HEYGEN_CONFIG_DIR`, or a different backend with `HEYGEN_API_URL`.
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.

## Providers used by agent workflows

Expand Down Expand Up @@ -135,9 +140,11 @@ to a shared space with `--space`.

| Variable | Used for |
|----------|----------|
| `HEYGEN_API_KEY` | HeyGen credential — voice + music/SFX retrieval. Highest priority. |
| `HEYGEN_API_KEY` | HeyGen credential — voice + music/SFX retrieval. |
| `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. |
| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). |
| `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`). |
| `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`. |
Expand Down
4 changes: 3 additions & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1383,7 +1383,8 @@ First match wins:

1. `HEYGEN_API_KEY`
2. `HYPERFRAMES_API_KEY` (a HyperFrames alias for the same thing)
3. `~/.heygen/credentials`
3. `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved)
4. `~/.heygen/credentials`

### `auth login`

Expand Down Expand Up @@ -1441,6 +1442,7 @@ hyperframes auth logout --yes # no prompt
| --------------------- | ------------------------------------------------ |
| `HEYGEN_API_KEY` | Override the stored credential. |
| `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`). |

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/auth/_test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { join } from "node:path";

const ENV_KEYS = [
"HEYGEN_API_KEY",
"HEYGEN_ACCESS_TOKEN",
"HYPERFRAMES_API_KEY",
"HEYGEN_CONFIG_DIR",
"HEYGEN_API_URL",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export {

export { configDir, credentialPath } from "./paths.js";

export { tryResolveCredential } from "./resolver.js";
export { ENV_CREDENTIAL_VAR, tryResolveCredential } from "./resolver.js";
export type { ResolvedCredential } from "./resolver.js";

export { AuthClient } from "./client.js";
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/auth/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe("auth/resolver", () => {
});

it("prefers HEYGEN_API_KEY over everything else", async () => {
process.env["HEYGEN_ACCESS_TOKEN"] = "host-token";
process.env["HEYGEN_API_KEY"] = "env-key";
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
Expand All @@ -28,12 +29,35 @@ describe("auth/resolver", () => {
});

it("falls through to HYPERFRAMES_API_KEY", async () => {
process.env["HEYGEN_ACCESS_TOKEN"] = "host-token";
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
expect(r).toEqual({ type: "api_key", key: "alias-key", source: "env_alias" });
});

it("accepts host-managed OAuth without a credential file or a refresh token", async () => {
process.env["HEYGEN_ACCESS_TOKEN"] = "host-token";
expect(await resolveCredential()).toEqual({
type: "oauth",
access_token: "host-token",
source: "env_oauth",
refreshable: false,
});
expect(await fs.readdir(dir)).toEqual([]);
});

it("rejects an unsafe host token without exposing it", async () => {
process.env["HEYGEN_ACCESS_TOKEN"] = "secret\r\nInjected: value";
await expect(resolveCredential()).rejects.toMatchObject({ code: "INVALID_STORE" });
await expect(resolveCredential()).rejects.not.toThrow("secret");
});

it("treats an empty host token as absent", async () => {
process.env["HEYGEN_ACCESS_TOKEN"] = "";
expect(await tryResolveCredential()).toBeNull();
});

it("returns file api_key when no env is set", async () => {
await writeStore({ api_key: "file-key" });
const r = await resolveCredential();
Expand Down
47 changes: 32 additions & 15 deletions packages/cli/src/auth/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
* Priority — first non-empty wins:
* 1. `HEYGEN_API_KEY` env (matches heygen-cli)
* 2. `HYPERFRAMES_API_KEY` env (alias for parity with other tools)
* 3. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key
* 3. `HEYGEN_ACCESS_TOKEN` env (host-managed OAuth)
* 4. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key
*
* Absent sources fall through. A broken file (parse error, bad shape)
* surfaces immediately as `ErrInvalidStore` — silently falling back
* would mask user config bugs.
* Absent sources fall through. Broken files surface `ErrInvalidStore` immediately;
* silently falling back would mask user configuration errors.
*
* Expiry policy: an OAuth access_token whose `expires_at` is in the
* past (60s skew) is considered expired. If a `refresh_token` is also
Expand All @@ -19,7 +19,17 @@
import { isHeaderSafe, readStore } from "./store.js";
import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js";

type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy";
type EnvSource = "env" | "env_alias" | "env_oauth";
type CredentialSource = EnvSource | "file_json" | "file_legacy";

export const ENV_CREDENTIAL_VAR: Record<EnvSource, string> = {
env: "HEYGEN_API_KEY",
env_alias: "HYPERFRAMES_API_KEY",
env_oauth: "HEYGEN_ACCESS_TOKEN",
};

export const envCredentialVar = (source: CredentialSource): string | undefined =>
source in ENV_CREDENTIAL_VAR ? ENV_CREDENTIAL_VAR[source as EnvSource] : undefined;

interface ApiKeyCredential {
type: "api_key";
Expand Down Expand Up @@ -49,22 +59,21 @@ export interface ResolveOptions {
export async function resolveCredential(opts: ResolveOptions = {}): Promise<ResolvedCredential> {
const now = (opts.now ?? (() => new Date()))();

const heygenEnv = process.env["HEYGEN_API_KEY"];
if (heygenEnv && heygenEnv.length > 0) {
if (!isHeaderSafe(heygenEnv)) {
throw ErrInvalidStore("HEYGEN_API_KEY contains control characters");
}
const heygenEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env);
if (heygenEnv) {
return { type: "api_key", key: heygenEnv, source: "env" };
}

const hfEnv = process.env["HYPERFRAMES_API_KEY"];
if (hfEnv && hfEnv.length > 0) {
if (!isHeaderSafe(hfEnv)) {
throw ErrInvalidStore("HYPERFRAMES_API_KEY contains control characters");
}
const hfEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env_alias);
if (hfEnv) {
return { type: "api_key", key: hfEnv, source: "env_alias" };
}

const accessToken = headerSafeEnv(ENV_CREDENTIAL_VAR.env_oauth);
if (accessToken) {
return { type: "oauth", access_token: accessToken, source: "env_oauth", refreshable: false };
}

const { credentials, source } = await readStore();
if (source === "absent") throw ErrNotConfigured();

Expand All @@ -78,6 +87,14 @@ export async function resolveCredential(opts: ResolveOptions = {}): Promise<Reso
throw credentials.oauth ? ErrLoginExpired() : ErrNotConfigured();
}

function headerSafeEnv(name: string): string | undefined {
const value = process.env[name];
if (value && !isHeaderSafe(value)) {
throw ErrInvalidStore(`${name} contains control characters`);
}
return value;
}

/** Like `resolveCredential` but returns `null` instead of throwing `NOT_CONFIGURED`. */
export async function tryResolveCredential(
opts: ResolveOptions = {},
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ ${c.bold("SUBCOMMANDS:")}
${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_ACCESS_TOKEN")} OAuth token from a host app; used after the API keys, never refreshed or saved.
${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).
Expand Down
30 changes: 28 additions & 2 deletions packages/cli/src/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,33 @@ const deviceAuth = vi.hoisted(() => ({
revoke: vi.fn(async () => {}),
}));

const usersByToken = vi.hoisted((): Record<string, Record<string, unknown>> => ({}));

const browserAuth = vi.hoisted(() => ({
start: vi.fn(async () => {
const tokens = { access_token: "browser-at", token_type: "Bearer" };
const { writeStore: write } = await import("../../auth/store.js");
await write({ oauth: { access_token: tokens.access_token } });
return { tokens };
}),
}));

vi.mock("../../auth/index.js", async (orig) => {
const actual = await orig<typeof import("../../auth/index.js")>();
class MockAuthClient {
async getCurrentUser(): Promise<Record<string, unknown>> {
async getCurrentUser(credential: { access_token?: string }): Promise<Record<string, unknown>> {
if (verifyState.reject) {
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
throw rej("invalid token");
}
return verifyState.user;
return (credential.access_token && usersByToken[credential.access_token]) || verifyState.user;
}
}
return {
...actual,
AuthClient: MockAuthClient,
assertOAuthConfiguredOrExit: () => {},
startAuthorizationCodeFlow: browserAuth.start,
startDeviceAuthorizationFlow: deviceAuth.start,
persistVerifiedOAuthSession: deviceAuth.persist,
revokeTokens: deviceAuth.revoke,
Expand Down Expand Up @@ -111,6 +124,7 @@ describe("auth login", () => {
verifyState.reject = false;
verifyState.user = { email: "alice@example.com" };
deviceChallenge.verificationUriComplete = undefined;
for (const token of Object.keys(usersByToken)) delete usersByToken[token];
for (const fn of Object.values(telemetry)) fn.mockClear();
for (const fn of Object.values(deviceAuth)) fn.mockClear();
vi.spyOn(console, "log").mockImplementation(() => {});
Expand Down Expand Up @@ -339,6 +353,18 @@ describe("auth login", () => {
expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("device", "rejected");
});

it("reports the account a browser login just signed in, not a host token in the environment", async () => {
process.env["HEYGEN_ACCESS_TOKEN"] = "env-at";
usersByToken["env-at"] = { email: "a@example.com" };
usersByToken["browser-at"] = { email: "b@example.com" };
await runCommand({});

const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("browser-at");
expect(credentials.user?.email).toBe("b@example.com");
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("oauth", "b@example.com");
});

it("refuses device authorization in CI", async () => {
process.env["CI"] = "true";
await expect(runCommand({ device: true })).rejects.toThrow(/Invalid command usage/);
Expand Down
38 changes: 17 additions & 21 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import {
persistVerifiedOAuthSession,
startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
tryResolveCredential,
userDisplayName,
writeStore,
type Credentials,
Expand Down Expand Up @@ -152,16 +151,9 @@ async function runDeviceLogin(): Promise<void> {
failCommand();
}

const credential = {
type: "oauth" as const,
access_token: tokens.access_token,
...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}),
source: "file_json" as const,
refreshable: false,
};
let user: UserInfo;
try {
user = await new AuthClient().getCurrentUser(credential);
user = await new AuthClient().getCurrentUser(issuedCredential(tokens));
} catch (err) {
await revokeDeviceTokens(tokens);
trackAuthLoginFailed("device", "rejected");
Expand Down Expand Up @@ -193,6 +185,17 @@ async function runDeviceLogin(): Promise<void> {
console.log(c.success(`✓ Signed in as ${identity}.`));
}

// The tokens this login just issued, never a resolved credential: an env credential would outrank them.
function issuedCredential(tokens: { access_token: string; refresh_token?: string }) {
return {
type: "oauth" as const,
access_token: tokens.access_token,
...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}),
source: "file_json" as const,
refreshable: false,
};
}

async function revokeDeviceTokens(tokens: {
access_token: string;
refresh_token?: string;
Expand All @@ -212,8 +215,9 @@ async function runOAuthLogin(): Promise<void> {
const { trackAuthLoginStarted, trackAuthLoginFailed } = await import("../../telemetry/index.js");
trackAuthLoginStarted("oauth");

let tokens;
try {
await startAuthorizationCodeFlow();
({ tokens } = await startAuthorizationCodeFlow());
} catch (err) {
const message = (err as Error).message ?? "";
// The loopback server rejects with "OAuth callback timed out after …" when
Expand All @@ -225,19 +229,11 @@ async function runOAuthLogin(): Promise<void> {
failCommand();
}

await reportIdentity();
await reportIdentity(issuedCredential(tokens));
}

// fallow-ignore-next-line complexity
async function reportIdentity(): Promise<void> {
const { trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
await import("../../telemetry/index.js");
const credential = await tryResolveCredential();
if (!credential) {
trackAuthLoginFailed("oauth", "no_credential");
console.error(c.warn("Sign-in completed but no credential was persisted."));
failCommand();
}
async function reportIdentity(credential: ReturnType<typeof issuedCredential>): Promise<void> {
const { trackAuthLoginCompleted, identifyUser } = await import("../../telemetry/index.js");
// 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
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { setupTempAuthEnv, type EnvFixture } from "../../auth/_test-utils.js";

let envFixture: EnvFixture;

beforeEach(async () => {
envFixture = await setupTempAuthEnv("hf-logout-");
vi.spyOn(console, "log").mockImplementation(() => {});
});

afterEach(async () => {
vi.restoreAllMocks();
await envFixture.restore();
});

it("warns that a host access token still signs commands after logout", async () => {
process.env["HEYGEN_ACCESS_TOKEN"] = "host-token";
const cmd = (await import("./logout.js")).default;
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
args: { yes: true },
});

expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Unset HEYGEN_ACCESS_TOKEN"));
});
Loading
Loading