Skip to content

Commit cf1b04b

Browse files
feat(cli): accept host-managed HeyGen access tokens (#5177)
* fix(auth): accept host-managed HeyGen access tokens * fix(auth): share environment header validation * docs(auth): condense credential resolver guidance * fix(auth): report the account a browser login just signed in Identify the account from the tokens the login issued, not a fresh credential lookup that an environment token or API key outranks. * docs(auth): list the host access token in the CLI credential order * fix(cli): name a rejected host access token on publish Publish told a host-token user to log in again, which cannot help while the token outranks the login. * docs(auth): show the media credential order main resolves * fix(cli): warn at logout that a host access token still signs commands One map of environment credential variables, owned by the resolver, now feeds the logout warning and both publish messages. * docs(auth): scope the media credential order to the audio scripts * refactor(cli): read every env credential name from the resolver's map
1 parent 042ec2e commit cf1b04b

19 files changed

Lines changed: 192 additions & 73 deletions

File tree

‎docs/guides/authentication.mdx‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,19 @@ The credential lives in `~/.heygen/credentials` (mode `0600`) — no per-repo `.
4747

4848
## How the HeyGen credential resolves
4949

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

53-
1. `HEYGEN_API_KEY` — environment variable
54-
2. `HYPERFRAMES_API_KEY` — alias, for parity with other tools
55-
3. `~/.heygen/credentials` — written by `hyperframes auth login` (or `heygen auth login`)
53+
1. `HEYGEN_API_BASE` with `HEYGEN_API_KEY` — a host app's own gateway, set by the app that started the workflow
54+
2. `HEYGEN_ACCESS_TOKEN` — an OAuth token a host app injects
55+
3. `HEYGEN_API_KEY` — environment variable
56+
4. `HYPERFRAMES_API_KEY` — alias, for parity with other tools
57+
5. `~/.heygen/credentials` — written by `hyperframes auth login` (or `heygen auth login`)
5658

57-
Point at a different config directory with `HEYGEN_CONFIG_DIR`, or a different backend with `HEYGEN_API_URL`.
59+
A nearby project `.env` can supply the keys, but never `HEYGEN_API_BASE`. Point at a different config directory
60+
with `HEYGEN_CONFIG_DIR`. The `hyperframes` CLI's own commands check the API keys before `HEYGEN_ACCESS_TOKEN`; see
61+
the [CLI reference](/packages/cli). `media-use resolve` searches through the separate `heygen` CLI, which resolves its
62+
own credential.
5863

5964
## Providers used by agent workflows
6065

@@ -135,9 +140,11 @@ to a shared space with `--space`.
135140

136141
| Variable | Used for |
137142
|----------|----------|
138-
| `HEYGEN_API_KEY` | HeyGen credential — voice + music/SFX retrieval. Highest priority. |
143+
| `HEYGEN_API_KEY` | HeyGen credential — voice + music/SFX retrieval. |
139144
| `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. |
140-
| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). |
145+
| `HEYGEN_ACCESS_TOKEN` | OAuth token a host app injects; never refreshed or saved. |
146+
| `HEYGEN_API_BASE` | A host app's gateway for media workflows; never read from a project `.env`. |
147+
| `HEYGEN_API_URL` | API base URL for `hyperframes` CLI commands (default `https://api.heygen.com`). |
141148
| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). |
142149
| `ELEVENLABS_API_KEY` | ElevenLabs TTS, used when no HeyGen credential is present. |
143150
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Explicit Gemini TTS selection and Lyria music generation; capture descriptions use `GEMINI_API_KEY`. |

‎docs/packages/cli.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1383,7 +1383,8 @@ First match wins:
13831383

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

13881389
### `auth login`
13891390

@@ -1441,6 +1442,7 @@ hyperframes auth logout --yes # no prompt
14411442
| --------------------- | ------------------------------------------------ |
14421443
| `HEYGEN_API_KEY` | Override the stored credential. |
14431444
| `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. |
1445+
| `HEYGEN_ACCESS_TOKEN` | OAuth token from a host app; never refreshed. |
14441446
| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). |
14451447
| `HEYGEN_CONFIG_DIR` | Credentials directory (default `~/.heygen`). |
14461448

‎packages/cli/src/auth/_test-utils.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { join } from "node:path";
1212

1313
const ENV_KEYS = [
1414
"HEYGEN_API_KEY",
15+
"HEYGEN_ACCESS_TOKEN",
1516
"HYPERFRAMES_API_KEY",
1617
"HEYGEN_CONFIG_DIR",
1718
"HEYGEN_API_URL",

‎packages/cli/src/auth/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export {
2525

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

28-
export { tryResolveCredential } from "./resolver.js";
28+
export { ENV_CREDENTIAL_VAR, tryResolveCredential } from "./resolver.js";
2929
export type { ResolvedCredential } from "./resolver.js";
3030

3131
export { AuthClient } from "./client.js";

‎packages/cli/src/auth/resolver.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe("auth/resolver", () => {
2020
});
2121

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

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

39+
it("accepts host-managed OAuth without a credential file or a refresh token", async () => {
40+
process.env["HEYGEN_ACCESS_TOKEN"] = "host-token";
41+
expect(await resolveCredential()).toEqual({
42+
type: "oauth",
43+
access_token: "host-token",
44+
source: "env_oauth",
45+
refreshable: false,
46+
});
47+
expect(await fs.readdir(dir)).toEqual([]);
48+
});
49+
50+
it("rejects an unsafe host token without exposing it", async () => {
51+
process.env["HEYGEN_ACCESS_TOKEN"] = "secret\r\nInjected: value";
52+
await expect(resolveCredential()).rejects.toMatchObject({ code: "INVALID_STORE" });
53+
await expect(resolveCredential()).rejects.not.toThrow("secret");
54+
});
55+
56+
it("treats an empty host token as absent", async () => {
57+
process.env["HEYGEN_ACCESS_TOKEN"] = "";
58+
expect(await tryResolveCredential()).toBeNull();
59+
});
60+
3761
it("returns file api_key when no env is set", async () => {
3862
await writeStore({ api_key: "file-key" });
3963
const r = await resolveCredential();

‎packages/cli/src/auth/resolver.ts‎

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
* Priority — first non-empty wins:
55
* 1. `HEYGEN_API_KEY` env (matches heygen-cli)
66
* 2. `HYPERFRAMES_API_KEY` env (alias for parity with other tools)
7-
* 3. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key
7+
* 3. `HEYGEN_ACCESS_TOKEN` env (host-managed OAuth)
8+
* 4. `~/.heygen/credentials` (JSON) — unexpired OAuth, else api_key
89
*
9-
* Absent sources fall through. A broken file (parse error, bad shape)
10-
* surfaces immediately as `ErrInvalidStore` — silently falling back
11-
* would mask user config bugs.
10+
* Absent sources fall through. Broken files surface `ErrInvalidStore` immediately;
11+
* silently falling back would mask user configuration errors.
1212
*
1313
* Expiry policy: an OAuth access_token whose `expires_at` is in the
1414
* past (60s skew) is considered expired. If a `refresh_token` is also
@@ -19,7 +19,17 @@
1919
import { isHeaderSafe, readStore } from "./store.js";
2020
import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js";
2121

22-
type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy";
22+
type EnvSource = "env" | "env_alias" | "env_oauth";
23+
type CredentialSource = EnvSource | "file_json" | "file_legacy";
24+
25+
export const ENV_CREDENTIAL_VAR: Record<EnvSource, string> = {
26+
env: "HEYGEN_API_KEY",
27+
env_alias: "HYPERFRAMES_API_KEY",
28+
env_oauth: "HEYGEN_ACCESS_TOKEN",
29+
};
30+
31+
export const envCredentialVar = (source: CredentialSource): string | undefined =>
32+
source in ENV_CREDENTIAL_VAR ? ENV_CREDENTIAL_VAR[source as EnvSource] : undefined;
2333

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

52-
const heygenEnv = process.env["HEYGEN_API_KEY"];
53-
if (heygenEnv && heygenEnv.length > 0) {
54-
if (!isHeaderSafe(heygenEnv)) {
55-
throw ErrInvalidStore("HEYGEN_API_KEY contains control characters");
56-
}
62+
const heygenEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env);
63+
if (heygenEnv) {
5764
return { type: "api_key", key: heygenEnv, source: "env" };
5865
}
5966

60-
const hfEnv = process.env["HYPERFRAMES_API_KEY"];
61-
if (hfEnv && hfEnv.length > 0) {
62-
if (!isHeaderSafe(hfEnv)) {
63-
throw ErrInvalidStore("HYPERFRAMES_API_KEY contains control characters");
64-
}
67+
const hfEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env_alias);
68+
if (hfEnv) {
6569
return { type: "api_key", key: hfEnv, source: "env_alias" };
6670
}
6771

72+
const accessToken = headerSafeEnv(ENV_CREDENTIAL_VAR.env_oauth);
73+
if (accessToken) {
74+
return { type: "oauth", access_token: accessToken, source: "env_oauth", refreshable: false };
75+
}
76+
6877
const { credentials, source } = await readStore();
6978
if (source === "absent") throw ErrNotConfigured();
7079

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

90+
function headerSafeEnv(name: string): string | undefined {
91+
const value = process.env[name];
92+
if (value && !isHeaderSafe(value)) {
93+
throw ErrInvalidStore(`${name} contains control characters`);
94+
}
95+
return value;
96+
}
97+
8198
/** Like `resolveCredential` but returns `null` instead of throwing `NOT_CONFIGURED`. */
8299
export async function tryResolveCredential(
83100
opts: ResolveOptions = {},

‎packages/cli/src/commands/auth.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ ${c.bold("SUBCOMMANDS:")}
4040
${c.bold("ENV VARS:")}
4141
${c.accent("HEYGEN_API_KEY")} Override the stored credential.
4242
${c.accent("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
43+
${c.accent("HEYGEN_ACCESS_TOKEN")} OAuth token from a host app; used after the API keys, never refreshed or saved.
4344
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
4445
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen).
4546
${c.accent("HYPERFRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test).

‎packages/cli/src/commands/auth/login.test.ts‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,33 @@ const deviceAuth = vi.hoisted(() => ({
5252
revoke: vi.fn(async () => {}),
5353
}));
5454

55+
const usersByToken = vi.hoisted((): Record<string, Record<string, unknown>> => ({}));
56+
57+
const browserAuth = vi.hoisted(() => ({
58+
start: vi.fn(async () => {
59+
const tokens = { access_token: "browser-at", token_type: "Bearer" };
60+
const { writeStore: write } = await import("../../auth/store.js");
61+
await write({ oauth: { access_token: tokens.access_token } });
62+
return { tokens };
63+
}),
64+
}));
65+
5566
vi.mock("../../auth/index.js", async (orig) => {
5667
const actual = await orig<typeof import("../../auth/index.js")>();
5768
class MockAuthClient {
58-
async getCurrentUser(): Promise<Record<string, unknown>> {
69+
async getCurrentUser(credential: { access_token?: string }): Promise<Record<string, unknown>> {
5970
if (verifyState.reject) {
6071
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
6172
throw rej("invalid token");
6273
}
63-
return verifyState.user;
74+
return (credential.access_token && usersByToken[credential.access_token]) || verifyState.user;
6475
}
6576
}
6677
return {
6778
...actual,
6879
AuthClient: MockAuthClient,
80+
assertOAuthConfiguredOrExit: () => {},
81+
startAuthorizationCodeFlow: browserAuth.start,
6982
startDeviceAuthorizationFlow: deviceAuth.start,
7083
persistVerifiedOAuthSession: deviceAuth.persist,
7184
revokeTokens: deviceAuth.revoke,
@@ -111,6 +124,7 @@ describe("auth login", () => {
111124
verifyState.reject = false;
112125
verifyState.user = { email: "alice@example.com" };
113126
deviceChallenge.verificationUriComplete = undefined;
127+
for (const token of Object.keys(usersByToken)) delete usersByToken[token];
114128
for (const fn of Object.values(telemetry)) fn.mockClear();
115129
for (const fn of Object.values(deviceAuth)) fn.mockClear();
116130
vi.spyOn(console, "log").mockImplementation(() => {});
@@ -339,6 +353,18 @@ describe("auth login", () => {
339353
expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("device", "rejected");
340354
});
341355

356+
it("reports the account a browser login just signed in, not a host token in the environment", async () => {
357+
process.env["HEYGEN_ACCESS_TOKEN"] = "env-at";
358+
usersByToken["env-at"] = { email: "a@example.com" };
359+
usersByToken["browser-at"] = { email: "b@example.com" };
360+
await runCommand({});
361+
362+
const { credentials } = await readStore();
363+
expect(credentials.oauth?.access_token).toBe("browser-at");
364+
expect(credentials.user?.email).toBe("b@example.com");
365+
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("oauth", "b@example.com");
366+
});
367+
342368
it("refuses device authorization in CI", async () => {
343369
process.env["CI"] = "true";
344370
await expect(runCommand({ device: true })).rejects.toThrow(/Invalid command usage/);

‎packages/cli/src/commands/auth/login.ts‎

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ import {
4141
persistVerifiedOAuthSession,
4242
startAuthorizationCodeFlow,
4343
startDeviceAuthorizationFlow,
44-
tryResolveCredential,
4544
userDisplayName,
4645
writeStore,
4746
type Credentials,
@@ -152,16 +151,9 @@ async function runDeviceLogin(): Promise<void> {
152151
failCommand();
153152
}
154153

155-
const credential = {
156-
type: "oauth" as const,
157-
access_token: tokens.access_token,
158-
...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}),
159-
source: "file_json" as const,
160-
refreshable: false,
161-
};
162154
let user: UserInfo;
163155
try {
164-
user = await new AuthClient().getCurrentUser(credential);
156+
user = await new AuthClient().getCurrentUser(issuedCredential(tokens));
165157
} catch (err) {
166158
await revokeDeviceTokens(tokens);
167159
trackAuthLoginFailed("device", "rejected");
@@ -193,6 +185,17 @@ async function runDeviceLogin(): Promise<void> {
193185
console.log(c.success(`✓ Signed in as ${identity}.`));
194186
}
195187

188+
// The tokens this login just issued, never a resolved credential: an env credential would outrank them.
189+
function issuedCredential(tokens: { access_token: string; refresh_token?: string }) {
190+
return {
191+
type: "oauth" as const,
192+
access_token: tokens.access_token,
193+
...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}),
194+
source: "file_json" as const,
195+
refreshable: false,
196+
};
197+
}
198+
196199
async function revokeDeviceTokens(tokens: {
197200
access_token: string;
198201
refresh_token?: string;
@@ -212,8 +215,9 @@ async function runOAuthLogin(): Promise<void> {
212215
const { trackAuthLoginStarted, trackAuthLoginFailed } = await import("../../telemetry/index.js");
213216
trackAuthLoginStarted("oauth");
214217

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

228-
await reportIdentity();
232+
await reportIdentity(issuedCredential(tokens));
229233
}
230234

231-
// fallow-ignore-next-line complexity
232-
async function reportIdentity(): Promise<void> {
233-
const { trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
234-
await import("../../telemetry/index.js");
235-
const credential = await tryResolveCredential();
236-
if (!credential) {
237-
trackAuthLoginFailed("oauth", "no_credential");
238-
console.error(c.warn("Sign-in completed but no credential was persisted."));
239-
failCommand();
240-
}
235+
async function reportIdentity(credential: ReturnType<typeof issuedCredential>): Promise<void> {
236+
const { trackAuthLoginCompleted, identifyUser } = await import("../../telemetry/index.js");
241237
// Wire the refresh hook here too — a freshly-minted token shouldn't
242238
// need it, but a fast IdP-side rotation (or a misconfigured short
243239
// TTL) shouldn't punish the user with a hard failure when the
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
2+
import { setupTempAuthEnv, type EnvFixture } from "../../auth/_test-utils.js";
3+
4+
let envFixture: EnvFixture;
5+
6+
beforeEach(async () => {
7+
envFixture = await setupTempAuthEnv("hf-logout-");
8+
vi.spyOn(console, "log").mockImplementation(() => {});
9+
});
10+
11+
afterEach(async () => {
12+
vi.restoreAllMocks();
13+
await envFixture.restore();
14+
});
15+
16+
it("warns that a host access token still signs commands after logout", async () => {
17+
process.env["HEYGEN_ACCESS_TOKEN"] = "host-token";
18+
const cmd = (await import("./logout.js")).default;
19+
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
20+
args: { yes: true },
21+
});
22+
23+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Unset HEYGEN_ACCESS_TOKEN"));
24+
});

0 commit comments

Comments
 (0)