Skip to content

Commit f2530cf

Browse files
committed
fix(auth): accept host-managed HeyGen access tokens
1 parent 8738726 commit f2530cf

7 files changed

Lines changed: 99 additions & 4 deletions

File tree

‎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/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: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
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
*
910
* Absent sources fall through. A broken file (parse error, bad shape)
1011
* surfaces immediately as `ErrInvalidStore` — silently falling back
@@ -19,7 +20,7 @@
1920
import { isHeaderSafe, readStore } from "./store.js";
2021
import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js";
2122

22-
type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy";
23+
type CredentialSource = "env" | "env_alias" | "env_oauth" | "file_json" | "file_legacy";
2324

2425
interface ApiKeyCredential {
2526
type: "api_key";
@@ -65,6 +66,14 @@ export async function resolveCredential(opts: ResolveOptions = {}): Promise<Reso
6566
return { type: "api_key", key: hfEnv, source: "env_alias" };
6667
}
6768

69+
const accessToken = process.env["HEYGEN_ACCESS_TOKEN"];
70+
if (accessToken) {
71+
if (!isHeaderSafe(accessToken)) {
72+
throw ErrInvalidStore("HEYGEN_ACCESS_TOKEN contains control characters");
73+
}
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

‎packages/cli/src/commands/auth/status-user.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ describe("auth status — persisted user block surface", () => {
6464
return JSON.parse(stdout[stdout.length - 1] ?? "{}");
6565
}
6666

67+
it("reports host-managed OAuth without showing its token or a stored identity", async () => {
68+
process.env["HEYGEN_ACCESS_TOKEN"] = "fixture-host-secret";
69+
await writeStore({ user: { email: "stored@example.com" } });
70+
expect(await runStatus(true)).toBe(0);
71+
expect(lastJson()).toMatchObject({ source: "env_oauth", persisted_user: null });
72+
expect(await runStatus(false)).toBe(0);
73+
expect(stdout.join("\n")).toContain("env (HEYGEN_ACCESS_TOKEN)");
74+
expect(stdout.join("\n")).not.toContain("fixture-host-secret");
75+
expect(stdout.join("\n")).not.toContain("stored@example.com");
76+
});
77+
6778
it("surfaces the persisted user block (with resolved display_name) for a file credential", async () => {
6879
await writeStore({
6980
api_key: "hg_x",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ function identityRows(user: UserInfo): [string, string][] {
260260
const SOURCE_LABELS: Record<ResolvedCredential["source"], string> = {
261261
env: "env (HEYGEN_API_KEY)",
262262
env_alias: "env (HYPERFRAMES_API_KEY)",
263+
env_oauth: "env (HEYGEN_ACCESS_TOKEN)",
263264
file_legacy: "file (~/.heygen/credentials — legacy plaintext)",
264265
file_json: "file (~/.heygen/credentials)",
265266
};

‎skills/media-use/audio/scripts/lib/heygen.mjs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
22
// heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
3-
// pipeline. The credential resolver matches the hyperframes CLI auth: first
3+
// pipeline. The credential resolver uses the shared HeyGen store: first
44
// usable source wins — a host-injected OAuth $HEYGEN_ACCESS_TOKEN (Bearer) →
55
// $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
66
// credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR
@@ -10,7 +10,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1010
import { homedir } from "node:os";
1111
import { dirname, join, resolve } from "node:path";
1212

13-
export const HEYGEN_BASE = "https://api.heygen.com/v3";
13+
const apiHost = process.env.HEYGEN_API_URL || "https://api.heygen.com";
14+
export const HEYGEN_BASE = `${apiHost.replace(/\/+$/, "")}/v3`;
1415
export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
1516
// Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
1617
// the backend can isolate media-use consumption from other free TTS / avatar video.

‎skills/media-use/audio/scripts/lib/heygen.test.mjs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createServer } from "node:http";
2+
import { once } from "node:events";
13
import { test } from "node:test";
24
import assert from "node:assert/strict";
35
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
@@ -183,3 +185,49 @@ test("heygenAuthHeaders says to fix an unreadable credentials path, and to log i
183185
}
184186
});
185187
});
188+
189+
test("host-managed OAuth reaches its paired API host in both distributed helpers", async () => {
190+
const originalFetch = globalThis.fetch;
191+
const previousHost = process.env.HEYGEN_API_URL;
192+
const previousToken = process.env.HEYGEN_ACCESS_TOKEN;
193+
const calls = [];
194+
const server = createServer((request, response) => {
195+
calls.push({ path: request.url, authorization: request.headers.authorization });
196+
response.setHeader("content-type", "application/json");
197+
response.end(JSON.stringify({ ok: true }));
198+
});
199+
server.listen(0, "127.0.0.1");
200+
await once(server, "listening");
201+
try {
202+
process.env.HEYGEN_API_URL = `http://127.0.0.1:${server.address().port}///`;
203+
process.env.HEYGEN_ACCESS_TOKEN = "fixture-token";
204+
const origin = new URL(process.env.HEYGEN_API_URL).origin;
205+
globalThis.fetch = (url, options) => {
206+
assert.equal(new URL(url).origin, origin, "this test must never reach the internet");
207+
return originalFetch(url, options);
208+
};
209+
for (const file of [
210+
"./heygen.mjs?paired-host",
211+
"../../../../../packages/cli/src/audio/scripts/lib/heygen.mjs?paired-host",
212+
]) {
213+
const helper = await import(new URL(file, import.meta.url));
214+
assert.deepEqual(
215+
await helper.heygenJSON("/voices", { headers: helper.heygenAuthHeaders() }),
216+
{
217+
ok: true,
218+
},
219+
);
220+
}
221+
assert.deepEqual(calls, [
222+
{ path: "/v3/voices", authorization: "Bearer fixture-token" },
223+
{ path: "/v3/voices", authorization: "Bearer fixture-token" },
224+
]);
225+
} finally {
226+
globalThis.fetch = originalFetch;
227+
if (previousHost === undefined) delete process.env.HEYGEN_API_URL;
228+
else process.env.HEYGEN_API_URL = previousHost;
229+
if (previousToken === undefined) delete process.env.HEYGEN_ACCESS_TOKEN;
230+
else process.env.HEYGEN_ACCESS_TOKEN = previousToken;
231+
await new Promise((resolve) => server.close(resolve));
232+
}
233+
});

0 commit comments

Comments
 (0)