mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 13:07:11 +00:00
fix(cli): prefer OAuth session over HEYGEN_API_KEY (#7051)
* fix(cli): prefer OAuth session over HEYGEN_API_KEY A signed-in `hyperframes auth login` session now wins over an API key in the environment, so a paid plan on the login is used even when the shell also exports HEYGEN_API_KEY. New order: HEYGEN_ACCESS_TOKEN (host app), the stored login (refreshed when expired), HEYGEN_API_KEY, HYPERFRAMES_API_KEY, the saved API key. The env key still comes first when HEYGEN_API_URL or HEYGEN_API_BASE names a host app's gateway, and takes over for the rest of the process when the login cannot be refreshed. The media-use audio helper follows the same order. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli): fall back to the env key only when HeyGen refuses the refresh A 503, a network error or an overlapping refresh no longer switches the process to HEYGEN_API_KEY. The fallback now needs a refused grant or an expired login with no refresh token. A login another process stored since is re-read first, and refreshes that overlap in one process share one request. The media-use helper no longer treats HeyGen's own API base as a gateway. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli): keep the login when the refresh reply can't be read Only a 400/401 from the token endpoint marks the grant as refused and switches to the env key. A 200 whose body can't be parsed now throws and leaves the login in place, so the next call refreshes again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
92943d99f0
commit
d03bdeae9d
@@ -37,7 +37,7 @@ Signing in is the same OAuth step as creating an account — new users land on t
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
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.
|
||||
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. A signed-in HyperFrames session wins over `HEYGEN_API_KEY` in the environment. The environment key is used only when no session is signed in, when HeyGen refuses to refresh the session, or when a host app routes calls through its own gateway. An expired session never selects a saved API key instead. An API key, from the environment or `auth login --api-key`, selects API-key billing.
|
||||
|
||||
`auth status` verifies identity. For a dedicated HyperFrames OAuth login it reports the billing product, but the HyperFrames balance is unavailable here: `/v3/users/me` returns generic HeyGen account billing fields. Those raw fields remain in JSON and are not presented as the HyperFrames balance. Host-injected or custom-client OAuth is reported as an unverified billing product.
|
||||
|
||||
@@ -54,13 +54,14 @@ sound retrieval. It resolves first-match-wins:
|
||||
|
||||
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. `~/.hyperframes/credentials` — written by `hyperframes auth login`
|
||||
3. The session `hyperframes auth login` saved in `~/.hyperframes/credentials`, while it is unexpired
|
||||
4. `HEYGEN_API_KEY` — environment variable
|
||||
5. `HYPERFRAMES_API_KEY` — alias, for parity with other tools
|
||||
6. An API key saved in `~/.hyperframes/credentials` by `hyperframes auth login --api-key`
|
||||
|
||||
A nearby project `.env` can supply the keys, but never `HEYGEN_API_BASE`. Point at a different config directory
|
||||
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.
|
||||
with `HYPERFRAMES_CONFIG_DIR`. The scripts can't refresh a session; the `hyperframes` CLI's own commands use the same
|
||||
order and refresh an expired session first. 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
|
||||
|
||||
@@ -71,7 +72,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` → `~/.hyperframes` · then `ELEVENLABS_API_KEY` | Kokoro: `pip install kokoro-onnx soundfile` |
|
||||
| **Voice (TTS)** | HeyGen → ElevenLabs → Kokoro | HeyGen credential (above) · 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) |
|
||||
|
||||
+15
-8
@@ -936,8 +936,8 @@ FFmpeg.
|
||||
|
||||
Only at `--quality best` is audio uploaded. Stems and the `api` provider then
|
||||
call HeyGen, which may bill your account, with the same credential as the other
|
||||
HeyGen commands: `HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`, `HEYGEN_ACCESS_TOKEN`,
|
||||
or a credential saved by `hyperframes auth login`, on the API at
|
||||
HeyGen commands (see [`hyperframes auth`](#hyperframes-auth) for the order),
|
||||
on the API at
|
||||
`HEYGEN_API_URL` when set. API keys are sent as `x-api-key`; OAuth credentials
|
||||
are sent as bearer tokens. If the service refuses a request, analysis falls
|
||||
back with a warning. Without a credential, stems are skipped and `api` is
|
||||
@@ -2294,10 +2294,17 @@ of the `heygen` CLI. A previous shared login requires a fresh HyperFrames sign-i
|
||||
|
||||
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. `~/.hyperframes/credentials`
|
||||
1. `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved)
|
||||
2. Your `hyperframes auth login` session in `~/.hyperframes/credentials`. An expired session is refreshed first.
|
||||
3. `HEYGEN_API_KEY`
|
||||
4. `HYPERFRAMES_API_KEY` (a HyperFrames alias for the same thing)
|
||||
5. An API key saved with `hyperframes auth login --api-key`
|
||||
|
||||
A signed-in session wins over an API key in the environment. The environment key
|
||||
is used instead when HeyGen refuses to refresh the session, or when `HEYGEN_API_URL` or
|
||||
`HEYGEN_API_BASE` points at a host app's own gateway. To use the environment key
|
||||
while signed in, run `hyperframes auth logout`. `auth status` shows which
|
||||
credential is in use.
|
||||
|
||||
### `auth login`
|
||||
|
||||
@@ -2358,9 +2365,9 @@ hyperframes auth logout --yes # no prompt
|
||||
|
||||
| Variable | Description |
|
||||
| --------------------- | ------------------------------------------------ |
|
||||
| `HEYGEN_API_KEY` | Override the stored credential. |
|
||||
| `HEYGEN_API_KEY` | API key, used when no session is signed in. Wins over a key saved with `--api-key`. |
|
||||
| `HYPERFRAMES_API_KEY` | Alias for `HEYGEN_API_KEY`. |
|
||||
| `HEYGEN_ACCESS_TOKEN` | OAuth token from a host app; never refreshed. |
|
||||
| `HEYGEN_ACCESS_TOKEN` | OAuth token from a host app; used first, never refreshed. |
|
||||
| `HEYGEN_API_URL` | API base URL (default `https://api.heygen.com`). |
|
||||
| `HYPERFRAMES_CONFIG_DIR` | Credentials root (default `~/.hyperframes`). |
|
||||
| `HYPERFRAMES_ENV` | `prod` (default) or `dev`; `dev` uses a separate credential directory. Hosts and the OAuth client id come from `HEYGEN_API_URL` and `HYPERFRAMES_OAUTH_*`. |
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { _resetLoginRefreshFailureForTests } from "./resolver.js";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"HEYGEN_API_KEY",
|
||||
@@ -48,8 +49,10 @@ export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFix
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env["HYPERFRAMES_CONFIG_DIR"] = dir;
|
||||
_resetLoginRefreshFailureForTests();
|
||||
|
||||
const restore = async (): Promise<void> => {
|
||||
_resetLoginRefreshFailureForTests();
|
||||
for (const k of ENV_KEYS) {
|
||||
const v = saved[k];
|
||||
if (v === undefined) delete process.env[k];
|
||||
|
||||
@@ -17,12 +17,20 @@ export type AuthErrorCode =
|
||||
export class AuthError extends Error {
|
||||
readonly code: AuthErrorCode;
|
||||
readonly hint?: string;
|
||||
/** HeyGen refused a token refresh (400/401), so the grant is gone; a reply the CLI couldn't read is not this. */
|
||||
readonly refused: boolean;
|
||||
|
||||
constructor(code: AuthErrorCode, message: string, hint?: string) {
|
||||
constructor(
|
||||
code: AuthErrorCode,
|
||||
message: string,
|
||||
hint?: string,
|
||||
opts: { refused?: boolean } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
this.code = code;
|
||||
this.hint = hint;
|
||||
this.refused = opts.refused ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +65,12 @@ export const ErrOAuthNotConfigured = () =>
|
||||
"Set HYPERFRAMES_OAUTH_CLIENT_ID, or run `hyperframes auth login --api-key`.",
|
||||
);
|
||||
|
||||
export const ErrRefreshFailed = (detail?: string) =>
|
||||
export const ErrRefreshFailed = (detail?: string, opts: { refused?: boolean } = {}) =>
|
||||
new AuthError(
|
||||
"REFRESH_FAILED",
|
||||
detail ? `Failed to refresh OAuth tokens: ${detail}` : "Failed to refresh OAuth tokens",
|
||||
"Run `hyperframes auth login` to re-authenticate.",
|
||||
opts,
|
||||
);
|
||||
|
||||
export const ErrLoginExpired = () =>
|
||||
|
||||
@@ -61,6 +61,60 @@ describe("media-use authentication", () => {
|
||||
expect(await fs.readFile(join(fixture.dir, "credentials"), "utf8")).toContain("rotated");
|
||||
});
|
||||
|
||||
it("passes the login, not HEYGEN_API_KEY, when both are present", async () => {
|
||||
await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" });
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
await withMediaAuthEnvironment(async (env) => {
|
||||
expect(env.HEYGEN_ACCESS_TOKEN).toBe("hf-access");
|
||||
expect(env.HEYGEN_API_KEY).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to HEYGEN_API_KEY when the login cannot be refreshed", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "expired",
|
||||
refresh_token: "hf-refresh",
|
||||
expires_at: new Date(Date.now() - 60 * 1000).toISOString(),
|
||||
});
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
const fetchMock = vi.fn(async () => new Response("invalid_grant", { status: 400 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await withMediaAuthEnvironment(async (env) => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(env.HEYGEN_API_KEY).toBe("env-key");
|
||||
expect(env.HEYGEN_ACCESS_TOKEN).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("still fails when the login cannot be refreshed and no env key is set", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "expired",
|
||||
refresh_token: "hf-refresh",
|
||||
expires_at: new Date(Date.now() - 60 * 1000).toISOString(),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("invalid_grant", { status: 400 })),
|
||||
);
|
||||
await expect(withMediaAuthEnvironment(() => undefined)).rejects.toMatchObject({
|
||||
code: "REFRESH_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("gives a host gateway its own key even when a login is stored", async () => {
|
||||
await persistFreshOAuth({ access_token: "hf-access", refresh_token: "hf-refresh" });
|
||||
process.env["HEYGEN_API_BASE"] = "https://gateway.example";
|
||||
process.env["HEYGEN_API_KEY"] = "gateway-key";
|
||||
try {
|
||||
await withMediaAuthEnvironment(async (env) => {
|
||||
expect(env.HEYGEN_API_KEY).toBe("gateway-key");
|
||||
expect(env.HEYGEN_ACCESS_TOKEN).toBeUndefined();
|
||||
});
|
||||
} finally {
|
||||
delete process.env["HEYGEN_API_BASE"];
|
||||
}
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { afterRefreshFailure, resolveCredential, tryResolveCredential } from "./resolver.js";
|
||||
import { writeStore } from "./store.js";
|
||||
|
||||
// Avatar helpers can poll for five minutes after submitting paid work.
|
||||
@@ -28,8 +28,11 @@ export async function withMediaAuthEnvironment<T>(run: (env: NodeJS.ProcessEnv)
|
||||
credential.refresh_token &&
|
||||
(credential.refreshable || expiresDuringHelper)
|
||||
) {
|
||||
await refreshTokens(credential.refresh_token);
|
||||
credential = await resolveCredential();
|
||||
const refreshToken = credential.refresh_token;
|
||||
credential = await refreshTokens(refreshToken).then(
|
||||
() => resolveCredential(),
|
||||
(error: unknown) => afterRefreshFailure(error, refreshToken),
|
||||
);
|
||||
}
|
||||
|
||||
const dir = await fs.mkdtemp(join(tmpdir(), "hyperframes-media-auth-"));
|
||||
|
||||
@@ -355,10 +355,21 @@ function retryAfterSeconds(response: Response, nowMs: number): number | undefine
|
||||
return Math.min(Math.max(Math.ceil((retryAt - nowMs) / 1000), 0), MAX_DEVICE_POLL_SECONDS);
|
||||
}
|
||||
|
||||
export async function refreshTokens(
|
||||
const refreshing = new Map<string, Promise<OAuthTokens>>();
|
||||
|
||||
/** Refresh the stored login. Calls that overlap in this process share one request, since the server rotates the token. */
|
||||
export function refreshTokens(
|
||||
refresh_token: string,
|
||||
opts: RefreshOptions = {},
|
||||
): Promise<OAuthTokens> {
|
||||
const pending = refreshing.get(refresh_token);
|
||||
if (pending) return pending;
|
||||
const request = refreshOnce(refresh_token, opts).finally(() => refreshing.delete(refresh_token));
|
||||
refreshing.set(refresh_token, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function refreshOnce(refresh_token: string, opts: RefreshOptions): Promise<OAuthTokens> {
|
||||
const { credentials } = await readStore();
|
||||
if (credentials.oauth?.refresh_token !== refresh_token) throw ErrLoginChanged();
|
||||
assertOAuthIdentity(credentials.oauth);
|
||||
@@ -380,7 +391,7 @@ export async function refreshTokens(
|
||||
});
|
||||
|
||||
if (res.status === 400 || res.status === 401) {
|
||||
throw ErrRefreshFailed(await safeText(res));
|
||||
throw ErrRefreshFailed(await safeText(res), { refused: true });
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
|
||||
|
||||
@@ -3,8 +3,8 @@ 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 { ErrApi, ErrLoginChanged, ErrRefreshFailed, isAuthError } from "./errors.js";
|
||||
import { afterRefreshFailure, resolveCredential, tryResolveCredential } from "./resolver.js";
|
||||
import { writeStore as writeUnboundStore, type Credentials } from "./store.js";
|
||||
|
||||
async function writeStore(credentials: Credentials): Promise<void> {
|
||||
@@ -27,8 +27,103 @@ describe("auth/resolver", () => {
|
||||
await fixture.restore();
|
||||
});
|
||||
|
||||
it("prefers HEYGEN_API_KEY over everything else", async () => {
|
||||
const inOneHour = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const anHourAgo = () => new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
|
||||
it("prefers a signed-in login over HEYGEN_API_KEY", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
|
||||
await writeStore({
|
||||
api_key: "file-key",
|
||||
oauth: { access_token: "fresh-at", refresh_token: "rt", expires_at: inOneHour() },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r).toMatchObject({ type: "oauth", access_token: "fresh-at", source: "file_json" });
|
||||
});
|
||||
|
||||
it("prefers an expired login that can refresh over HEYGEN_API_KEY", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
await writeStore({
|
||||
oauth: { access_token: "stale-at", refresh_token: "rt", expires_at: anHourAgo() },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r).toMatchObject({ type: "oauth", access_token: "stale-at", refreshable: true });
|
||||
});
|
||||
|
||||
it("uses HEYGEN_API_KEY when the login expired and cannot refresh", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
await writeStore({ oauth: { access_token: "stale-at", expires_at: anHourAgo() } });
|
||||
expect(await resolveCredential()).toEqual({ type: "api_key", key: "env-key", source: "env" });
|
||||
});
|
||||
|
||||
it("uses HEYGEN_API_KEY over a login from another OAuth client", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
await writeStore({ oauth: { access_token: "foreign", client_id: "shared-heygen-cli" } });
|
||||
expect(await resolveCredential()).toEqual({ type: "api_key", key: "env-key", source: "env" });
|
||||
});
|
||||
|
||||
it("uses HEYGEN_API_KEY when the credentials file is broken", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
await fs.writeFile(join(dir, "credentials"), "{not valid", { mode: 0o600 });
|
||||
expect(await resolveCredential()).toEqual({ type: "api_key", key: "env-key", source: "env" });
|
||||
});
|
||||
|
||||
it("keeps the env key first when HEYGEN_API_URL names a host app's gateway", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "gateway-token";
|
||||
process.env["HEYGEN_API_URL"] = "http://127.0.0.1:4100";
|
||||
await writeStore({ oauth: { access_token: "fresh-at", expires_at: inOneHour() } });
|
||||
expect(await resolveCredential()).toEqual({
|
||||
type: "api_key",
|
||||
key: "gateway-token",
|
||||
source: "env",
|
||||
});
|
||||
process.env["HEYGEN_API_URL"] = "https://api.dev.heygen.com";
|
||||
expect(await resolveCredential()).toMatchObject({ type: "oauth", access_token: "fresh-at" });
|
||||
process.env["HEYGEN_API_BASE"] = "https://gateway.example";
|
||||
expect(await resolveCredential()).toMatchObject({ type: "api_key", key: "gateway-token" });
|
||||
});
|
||||
|
||||
it("uses the env key for the rest of the process once HeyGen refuses to refresh the login", async () => {
|
||||
await writeStore({
|
||||
oauth: { access_token: "at", refresh_token: "rt", expires_at: anHourAgo() },
|
||||
});
|
||||
const refused = ErrRefreshFailed("invalid_grant", { refused: true });
|
||||
await expect(afterRefreshFailure(refused, "rt")).rejects.toMatchObject({
|
||||
code: "REFRESH_FAILED",
|
||||
});
|
||||
expect(await resolveCredential()).toMatchObject({ type: "oauth", refreshable: true });
|
||||
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
// A server error or a login another process is changing says nothing about the grant.
|
||||
await expect(afterRefreshFailure(ErrApi(503, "down"), "rt")).rejects.toMatchObject({
|
||||
code: "API_ERROR",
|
||||
});
|
||||
await expect(afterRefreshFailure(ErrLoginChanged(), "rt")).rejects.toMatchObject({
|
||||
code: "LOGIN_CHANGED",
|
||||
});
|
||||
// Nor does a reply the CLI couldn't read.
|
||||
await expect(
|
||||
afterRefreshFailure(ErrRefreshFailed("no access_token"), "rt"),
|
||||
).rejects.toMatchObject({ code: "REFRESH_FAILED" });
|
||||
expect(await resolveCredential()).toMatchObject({ type: "oauth", refreshable: true });
|
||||
|
||||
const fallback = { type: "api_key", key: "env-key", source: "env" };
|
||||
expect(await afterRefreshFailure(refused, "rt")).toEqual(fallback);
|
||||
expect(await resolveCredential()).toEqual(fallback);
|
||||
});
|
||||
|
||||
it("prefers a host app's HEYGEN_ACCESS_TOKEN over the login and env keys", async () => {
|
||||
process.env["HEYGEN_ACCESS_TOKEN"] = "host-token";
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
await writeStore({ oauth: { access_token: "fresh-at", expires_at: inOneHour() } });
|
||||
expect(await resolveCredential()).toMatchObject({
|
||||
type: "oauth",
|
||||
access_token: "host-token",
|
||||
source: "env_oauth",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers HEYGEN_API_KEY over HYPERFRAMES_API_KEY and a saved API key", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
|
||||
await writeStore({ api_key: "file-key" });
|
||||
@@ -37,7 +132,6 @@ 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();
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
/**
|
||||
* Chain resolver for HyperFrames credentials.
|
||||
* Chain resolver for HyperFrames credentials. First usable source wins:
|
||||
* 1. `HEYGEN_ACCESS_TOKEN` env (an OAuth token a host app injects)
|
||||
* 2. The stored login: fresh, or expired with a refresh token
|
||||
* 3. `HEYGEN_API_KEY` env, then `HYPERFRAMES_API_KEY`
|
||||
* 4. The API key saved in `~/.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. `~/.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.
|
||||
*
|
||||
* 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
|
||||
* session requires sign-in; a saved API key never replaces expired OAuth.
|
||||
* The env key goes first for a host app's gateway, and once HeyGen refused to refresh the login.
|
||||
* A broken store gives way to an env key, else surfaces `ErrInvalidStore`. A saved API key
|
||||
* never replaces an expired login (60s skew).
|
||||
*/
|
||||
|
||||
import { isHeaderSafe, readStore } from "./store.js";
|
||||
import { ErrInvalidStore, ErrLoginExpired, ErrNotConfigured, isAuthError } from "./errors.js";
|
||||
import { assertOAuthIdentity } from "./config.js";
|
||||
import { apiBaseUrl } from "./client.js";
|
||||
|
||||
type EnvSource = "env" | "env_alias" | "env_oauth";
|
||||
type CredentialSource = EnvSource | "file_json" | "file_legacy";
|
||||
@@ -32,7 +27,7 @@ export const ENV_CREDENTIAL_VAR: Record<EnvSource, string> = {
|
||||
export const envCredentialVar = (source: CredentialSource): string | undefined =>
|
||||
source in ENV_CREDENTIAL_VAR ? ENV_CREDENTIAL_VAR[source as EnvSource] : undefined;
|
||||
|
||||
interface ApiKeyCredential {
|
||||
export interface ApiKeyCredential {
|
||||
type: "api_key";
|
||||
key: string;
|
||||
source: CredentialSource;
|
||||
@@ -60,36 +55,113 @@ export interface ResolveOptions {
|
||||
export async function resolveCredential(opts: ResolveOptions = {}): Promise<ResolvedCredential> {
|
||||
const now = (opts.now ?? (() => new Date()))();
|
||||
|
||||
const heygenEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env);
|
||||
if (heygenEnv) {
|
||||
return { type: "api_key", key: heygenEnv, source: "env" };
|
||||
}
|
||||
|
||||
const hfEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env_alias);
|
||||
if (hfEnv) {
|
||||
return { type: "api_key", key: hfEnv, source: "env_alias" };
|
||||
}
|
||||
const envKey = envApiKey();
|
||||
// A gateway takes the key its host app set and must never receive an OAuth token.
|
||||
if (envKey && !heygenApiHost()) return envKey;
|
||||
|
||||
const accessToken = headerSafeEnv(ENV_CREDENTIAL_VAR.env_oauth);
|
||||
if (accessToken) {
|
||||
return { type: "oauth", access_token: accessToken, source: "env_oauth", refreshable: false };
|
||||
}
|
||||
if (envKey && loginRefreshFailed) return envKey;
|
||||
return await fromStore(now, envKey);
|
||||
}
|
||||
|
||||
const { credentials, source } = await readStore();
|
||||
if (source === "absent") throw ErrNotConfigured();
|
||||
|
||||
const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";
|
||||
|
||||
if (credentials.oauth) assertOAuthIdentity(credentials.oauth);
|
||||
const oauth = credentials.oauth ? pickOAuth(credentials.oauth, now, fileSource) : null;
|
||||
if (oauth) return oauth;
|
||||
if (credentials.oauth) throw ErrLoginExpired();
|
||||
if (credentials.api_key) {
|
||||
return { type: "api_key", key: credentials.api_key, source: fileSource };
|
||||
/** The stored login, else the env key, else the stored API key. */
|
||||
async function fromStore(
|
||||
now: Date,
|
||||
envKey: ApiKeyCredential | undefined,
|
||||
): Promise<ResolvedCredential> {
|
||||
const store = await readStore().catch((err: unknown) => {
|
||||
if (envKey) return null;
|
||||
throw err;
|
||||
});
|
||||
if (!store || store.source === "absent") {
|
||||
if (envKey) return envKey;
|
||||
throw ErrNotConfigured();
|
||||
}
|
||||
const { credentials, source } = store;
|
||||
const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";
|
||||
const login =
|
||||
credentials.oauth && boundToThisClient(credentials.oauth)
|
||||
? pickOAuth(credentials.oauth, now, fileSource)
|
||||
: null;
|
||||
return login ?? envKey ?? storedApiKey(credentials, fileSource);
|
||||
}
|
||||
|
||||
function storedApiKey(
|
||||
credentials: Awaited<ReturnType<typeof readStore>>["credentials"],
|
||||
source: CredentialSource,
|
||||
): ApiKeyCredential {
|
||||
if (credentials.oauth) {
|
||||
assertOAuthIdentity(credentials.oauth);
|
||||
throw ErrLoginExpired();
|
||||
}
|
||||
if (credentials.api_key) return { type: "api_key", key: credentials.api_key, source };
|
||||
throw ErrNotConfigured();
|
||||
}
|
||||
|
||||
function envApiKey(): ApiKeyCredential | undefined {
|
||||
const heygenEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env);
|
||||
if (heygenEnv) return { type: "api_key", key: heygenEnv, source: "env" };
|
||||
const hfEnv = headerSafeEnv(ENV_CREDENTIAL_VAR.env_alias);
|
||||
if (hfEnv) return { type: "api_key", key: hfEnv, source: "env_alias" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** False when `HEYGEN_API_URL` or `HEYGEN_API_BASE` names a host app's gateway, not HeyGen. */
|
||||
function heygenApiHost(): boolean {
|
||||
const mediaBase = process.env["HEYGEN_API_BASE"]?.trim();
|
||||
return [apiBaseUrl(), ...(mediaBase ? [mediaBase] : [])].every((base) => {
|
||||
try {
|
||||
const host = new URL(base).hostname;
|
||||
return host === "heygen.com" || host.endsWith(".heygen.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function boundToThisClient(tokens: Parameters<typeof assertOAuthIdentity>[0]): boolean {
|
||||
try {
|
||||
assertOAuthIdentity(tokens);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let loginRefreshFailed = false;
|
||||
|
||||
/**
|
||||
* After refreshing `refreshToken` failed: a login another process stored since, else the env key when HeyGen refused
|
||||
* the grant (it then replaces the login for this process), else the error. A network or server error, or a reply the
|
||||
* CLI couldn't read, throws: the login may still work, and the next call refreshes again.
|
||||
*/
|
||||
export async function afterRefreshFailure(
|
||||
err: unknown,
|
||||
refreshToken: string | undefined,
|
||||
): Promise<ResolvedCredential> {
|
||||
if (!isAuthError(err)) throw err;
|
||||
if (err.code === "LOGIN_CHANGED" || err.code === "REFRESH_FAILED") {
|
||||
const stored = await readStore().catch(() => null);
|
||||
const current = stored?.credentials.oauth?.refresh_token;
|
||||
if (current && current !== refreshToken) {
|
||||
const next = await resolveCredential();
|
||||
if (next.type !== "oauth" || !next.refreshable) return next;
|
||||
}
|
||||
}
|
||||
if (!err.refused && err.code !== "LOGIN_EXPIRED") throw err;
|
||||
const key = envApiKey();
|
||||
if (!key) throw err;
|
||||
loginRefreshFailed = true;
|
||||
return key;
|
||||
}
|
||||
|
||||
export function _resetLoginRefreshFailureForTests(): void {
|
||||
loginRefreshFailed = false;
|
||||
}
|
||||
|
||||
function headerSafeEnv(name: string): string | undefined {
|
||||
const value = process.env[name];
|
||||
if (value && !isHeaderSafe(value)) {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setupTempAuthEnv } from "../auth/_test-utils.js";
|
||||
import { persistFreshOAuth } from "../auth/oauth.js";
|
||||
import { resolveCredential } from "../auth/resolver.js";
|
||||
import { forceRefreshCredentials, refreshIfNeeded, resolveCloudAuthHeaders } from "./auth.js";
|
||||
|
||||
const anHourAgo = () => new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
|
||||
function tokenResponse(): Response {
|
||||
return new Response(
|
||||
JSON.stringify({ access_token: "refreshed", refresh_token: "rotated", expires_in: 3600 }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
describe("cloud auth: login vs HEYGEN_API_KEY", () => {
|
||||
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
|
||||
beforeEach(async () => {
|
||||
fixture = await setupTempAuthEnv("hf-cloud-auth-");
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await fixture.restore();
|
||||
});
|
||||
|
||||
it("signs with the login when HEYGEN_API_KEY is also set", async () => {
|
||||
await persistFreshOAuth({ access_token: "login-at", refresh_token: "rt" });
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
const headers = await resolveCloudAuthHeaders();
|
||||
expect(headers["authorization"]).toBe("Bearer login-at");
|
||||
expect(headers["x-api-key"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("signs with HEYGEN_API_KEY when no login is stored", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
const headers = await resolveCloudAuthHeaders();
|
||||
expect(headers["x-api-key"]).toBe("env-key");
|
||||
expect(headers["authorization"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refreshes an expired login instead of using HEYGEN_API_KEY", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
expires_at: anHourAgo(),
|
||||
});
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
const fetchMock = vi.fn(async () => tokenResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const headers = await resolveCloudAuthHeaders();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(headers["authorization"]).toBe("Bearer refreshed");
|
||||
});
|
||||
|
||||
it("falls back to HEYGEN_API_KEY when the refresh fails, and does not retry it", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
expires_at: anHourAgo(),
|
||||
});
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
const fetchMock = vi.fn(async () => new Response("invalid_grant", { status: 400 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect((await resolveCloudAuthHeaders())["x-api-key"]).toBe("env-key");
|
||||
expect((await resolveCloudAuthHeaders())["x-api-key"]).toBe("env-key");
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the login through a server error, and refreshes again on the next call", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
expires_at: anHourAgo(),
|
||||
});
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(tokenResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(resolveCloudAuthHeaders()).rejects.toMatchObject({ code: "API_ERROR" });
|
||||
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer refreshed");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the login through a reply it cannot read", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
expires_at: anHourAgo(),
|
||||
});
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ token_type: "Bearer" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(tokenResponse()),
|
||||
);
|
||||
await expect(resolveCloudAuthHeaders()).rejects.toMatchObject({ code: "REFRESH_FAILED" });
|
||||
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer refreshed");
|
||||
});
|
||||
|
||||
it("keeps the login through a network error", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
expires_at: anHourAgo(),
|
||||
});
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
||||
.mockResolvedValueOnce(tokenResponse()),
|
||||
);
|
||||
await expect(resolveCloudAuthHeaders()).rejects.toThrow("fetch failed");
|
||||
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer refreshed");
|
||||
});
|
||||
|
||||
it("sends one refresh for requests that overlap, and signs both with the login", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
expires_at: anHourAgo(),
|
||||
});
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
// The server rotates the token, so a second refresh with "rt" would be refused.
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(tokenResponse())
|
||||
.mockResolvedValue(new Response("invalid_grant", { status: 400 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const both = await Promise.all([resolveCloudAuthHeaders(), resolveCloudAuthHeaders()]);
|
||||
expect(both.map((headers) => headers["authorization"])).toEqual([
|
||||
"Bearer refreshed",
|
||||
"Bearer refreshed",
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer refreshed");
|
||||
});
|
||||
|
||||
it("uses the login another process stored instead of the env key", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
expires_at: anHourAgo(),
|
||||
});
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
const fetchMock = vi.fn(async () => new Response("invalid_grant", { status: 400 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const credential = await resolveCredential();
|
||||
await persistFreshOAuth({ access_token: "other-process", refresh_token: "rt2" });
|
||||
expect(await refreshIfNeeded(credential)).toMatchObject({
|
||||
type: "oauth",
|
||||
access_token: "other-process",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer other-process");
|
||||
});
|
||||
|
||||
it("surfaces the refresh error when no env key is set", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
expires_at: anHourAgo(),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("invalid_grant", { status: 400 })),
|
||||
);
|
||||
await expect(resolveCloudAuthHeaders()).rejects.toMatchObject({ code: "REFRESH_FAILED" });
|
||||
});
|
||||
|
||||
it("does not fall back when the caller opts out", async () => {
|
||||
await persistFreshOAuth({
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
expires_at: anHourAgo(),
|
||||
});
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("invalid_grant", { status: 400 })),
|
||||
);
|
||||
const credential = await resolveCredential();
|
||||
await expect(refreshIfNeeded(credential, { envFallback: false })).rejects.toMatchObject({
|
||||
code: "REFRESH_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("switches to HEYGEN_API_KEY after the server refuses a login that cannot refresh", async () => {
|
||||
await persistFreshOAuth({ access_token: "revoked", refresh_token: "rt" });
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("invalid_grant", { status: 400 })),
|
||||
);
|
||||
expect((await resolveCloudAuthHeaders())["authorization"]).toBe("Bearer revoked");
|
||||
await forceRefreshCredentials();
|
||||
expect((await resolveCloudAuthHeaders())["x-api-key"]).toBe("env-key");
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,13 @@
|
||||
*/
|
||||
|
||||
import { apiBaseUrl, buildAuthHeaders } from "../auth/client.js";
|
||||
import { ErrLoginExpired } from "../auth/errors.js";
|
||||
import { refreshTokens } from "../auth/oauth.js";
|
||||
import { resolveCredential, type ResolvedCredential } from "../auth/resolver.js";
|
||||
import {
|
||||
afterRefreshFailure,
|
||||
resolveCredential,
|
||||
type ResolvedCredential,
|
||||
} from "../auth/resolver.js";
|
||||
|
||||
/**
|
||||
* Build the cloud client's `getAuthHeaders` callback. Each invocation
|
||||
@@ -38,11 +43,25 @@ export function resolveCloudBaseUrl(): string {
|
||||
return apiBaseUrl();
|
||||
}
|
||||
|
||||
export interface RefreshIfNeededOptions {
|
||||
/** When HeyGen refuses the refresh, use an API key from the environment instead of throwing. Default true. */
|
||||
envFallback?: boolean;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function refreshIfNeeded(credential: ResolvedCredential): Promise<ResolvedCredential> {
|
||||
export async function refreshIfNeeded(
|
||||
credential: ResolvedCredential,
|
||||
opts: RefreshIfNeededOptions = {},
|
||||
): Promise<ResolvedCredential> {
|
||||
if (credential.type !== "oauth") return credential;
|
||||
if (!credential.refreshable || !credential.refresh_token) return credential;
|
||||
const fresh = await refreshTokens(credential.refresh_token);
|
||||
let fresh;
|
||||
try {
|
||||
fresh = await refreshTokens(credential.refresh_token);
|
||||
} catch (err) {
|
||||
if (opts.envFallback === false) throw err;
|
||||
return await afterRefreshFailure(err, credential.refresh_token);
|
||||
}
|
||||
return {
|
||||
...credential,
|
||||
access_token: fresh.access_token,
|
||||
@@ -63,15 +82,18 @@ function parseDateOrUndef(value: string | undefined): Date | undefined {
|
||||
* expiry. Used by `createCloudClient`'s 401-retry path when the server
|
||||
* rejects a token that the local resolver thought was still valid (e.g.
|
||||
* server-side revocation, clock skew, IdP rotation). No-op for API-key
|
||||
* credentials.
|
||||
*
|
||||
* Throws if the credential can't be refreshed (no refresh_token, or the
|
||||
* IdP refresh call itself fails). Callers should let the throw surface
|
||||
* — the original 401 is what the user needs to see, not a confusing
|
||||
* "refresh failed" message.
|
||||
* credentials and a host app's token. When HeyGen refuses the refresh,
|
||||
* an env API key takes over; without one, or on a network or server error,
|
||||
* this throws, and callers should surface the original 401 rather than a
|
||||
* confusing "refresh failed".
|
||||
*/
|
||||
export async function forceRefreshCredentials(): Promise<void> {
|
||||
const credential = await resolveCredential();
|
||||
if (credential.type !== "oauth" || !credential.refresh_token) return;
|
||||
await refreshTokens(credential.refresh_token);
|
||||
if (credential.type !== "oauth" || credential.source === "env_oauth") return;
|
||||
try {
|
||||
if (!credential.refresh_token) throw ErrLoginExpired();
|
||||
await refreshTokens(credential.refresh_token);
|
||||
} catch (err) {
|
||||
await afterRefreshFailure(err, credential.refresh_token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,9 @@ ${c.bold("SUBCOMMANDS:")}
|
||||
${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("HEYGEN_API_KEY")} API key; used when no login is active (or it can't refresh), before a saved key.
|
||||
${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_ACCESS_TOKEN")} OAuth token from a host app; used first, never refreshed or saved.
|
||||
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
|
||||
${c.accent("HYPERFRAMES_ENV")} prod (default) or dev; dev keeps credentials in a separate directory.
|
||||
${c.accent("HYPERFRAMES_CONFIG_DIR")} Override the credentials directory (default ~/.hyperframes).
|
||||
|
||||
@@ -192,7 +192,7 @@ 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.
|
||||
// The tokens this login just issued, never a resolved credential: an env credential can outrank them.
|
||||
function issuedCredential(tokens: { access_token: string; refresh_token?: string }) {
|
||||
return {
|
||||
type: "oauth" as const,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type StoredUserInfo,
|
||||
type UserInfo,
|
||||
} from "../../auth/index.js";
|
||||
import { refreshIfNeeded } from "../../cloud/auth.js";
|
||||
import { getSystemMeta } from "../../telemetry/system.js";
|
||||
import { c } from "../../ui/colors.js";
|
||||
import { pageUrl, readPlan, type Plan } from "../../heygen/plan.js";
|
||||
@@ -95,7 +96,8 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await verify(credential);
|
||||
const resolved = credential;
|
||||
const status = await verify(await refreshIfNeeded(resolved).catch(() => resolved));
|
||||
if (asJson) printJsonStatus(status);
|
||||
else printHumanStatus(status);
|
||||
setCommandExitCode(status.apiError ? 1 : 0);
|
||||
|
||||
@@ -194,7 +194,7 @@ export default defineCommand({
|
||||
!credential
|
||||
? `${flag} requires authentication. Run 'hyperframes auth login' first.`
|
||||
: envKey
|
||||
? `${flag} requires a login; ${envKey} cannot own a project. Unset it and run 'hyperframes auth login'.`
|
||||
? `${flag} requires a login; ${envKey} cannot own a project. Run 'hyperframes auth login'.`
|
||||
: `${flag} requires a login; an API key cannot own a project. Run 'hyperframes auth login'.`,
|
||||
)}`,
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("../cloud/auth.js", () => ({
|
||||
}));
|
||||
|
||||
vi.stubEnv("HEYGEN_API_KEY", "k");
|
||||
vi.stubEnv("HEYGEN_API_URL", "https://api.test");
|
||||
|
||||
const account = vi.hoisted(() => ({ scope: "acct" as string | null }));
|
||||
vi.mock("./v3.js", async (load) => ({
|
||||
|
||||
@@ -190,7 +190,9 @@ export async function resolvePublishCredential(
|
||||
const expired =
|
||||
credential.type === "oauth" && isTokenExpired(credential.expires_at, new Date());
|
||||
if (expired && !credential.refresh_token) throw new Error(LOGIN_EXPIRED);
|
||||
return await refreshIfNeeded(expired ? { ...credential, refreshable: true } : credential);
|
||||
return await refreshIfNeeded(expired ? { ...credential, refreshable: true } : credential, {
|
||||
envFallback: checked === undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthError(error) && (error.code === "REFRESH_FAILED" || error.code === "LOGIN_EXPIRED")) {
|
||||
throw new Error(LOGIN_EXPIRED);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"files": 77
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
"hash": "5d58eaa2a0da370d",
|
||||
"hash": "aeb9494dee6777a7",
|
||||
"files": 13
|
||||
},
|
||||
"hyperframes-registry": {
|
||||
@@ -18,7 +18,7 @@
|
||||
"files": 13
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "acd9a1b97b426bd2",
|
||||
"hash": "39040df75b0436da",
|
||||
"files": 117
|
||||
},
|
||||
"remotion-to-hyperframes": {
|
||||
|
||||
@@ -32,7 +32,7 @@ npx hyperframes auth refresh # force-refresh an OAuth token before a
|
||||
npx hyperframes auth logout # clear the stored credential
|
||||
```
|
||||
|
||||
Credential resolution order (first match wins): `HEYGEN_API_KEY`, then `HYPERFRAMES_API_KEY`, then `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved), then `~/.hyperframes/credentials`. `HYPERFRAMES_ENV=dev` selects the dev client, OAuth/API hosts and `~/.hyperframes/dev/credentials` together. `HYPERFRAMES_CONFIG_DIR` overrides the credentials root. `HEYGEN_API_URL` overrides the API host.
|
||||
Credential resolution order (first match wins): `HEYGEN_ACCESS_TOKEN` (an OAuth token a host app injects; never refreshed or saved), then the `hyperframes auth login` session in `~/.hyperframes/credentials` (refreshed when expired), then `HEYGEN_API_KEY`, then `HYPERFRAMES_API_KEY`, then a key saved with `auth login --api-key`. A signed-in session wins over an env key; the env key is used if HeyGen refuses to refresh the session or `HEYGEN_API_URL` points at a host app's gateway. `auth status` shows which one is in use; `auth logout` makes the env key win. `HYPERFRAMES_ENV=dev` selects the dev client, OAuth/API hosts and `~/.hyperframes/dev/credentials` together. `HYPERFRAMES_CONFIG_DIR` overrides the credentials root. `HEYGEN_API_URL` overrides the API host.
|
||||
|
||||
## The render pipeline
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
Run `npx hyperframes auth status` to see what's configured and which engines a workflow will use (see the skill's **Preflight** section). Keys resolve in this order — **first match wins**:
|
||||
|
||||
| Provider | Resolution order (first non-empty wins) | Local deps when used |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
|
||||
| **HeyGen** (TTS + BGM retrieval) | `$HEYGEN_ACCESS_TOKEN` → `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → `~/.hyperframes/credentials` (`$HYPERFRAMES_CONFIG_DIR` overrides the root; dev uses `dev/`) | none (REST) |
|
||||
| **ElevenLabs** (TTS fallback) | `$ELEVENLABS_API_KEY` | `pip install elevenlabs` |
|
||||
| **Lyria** (BGM fallback) | `$GEMINI_API_KEY` → `$GOOGLE_API_KEY` | `pip install google-genai` |
|
||||
| **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` |
|
||||
| **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` |
|
||||
| Provider | Resolution order (first non-empty wins) | Local deps when used |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| **HeyGen** (TTS + BGM retrieval) | `$HEYGEN_ACCESS_TOKEN` → the `~/.hyperframes/credentials` login → `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → a saved API key (`$HYPERFRAMES_CONFIG_DIR` overrides the root; dev uses `dev/`) | none (REST) |
|
||||
| **ElevenLabs** (TTS fallback) | `$ELEVENLABS_API_KEY` | `pip install elevenlabs` |
|
||||
| **Lyria** (BGM fallback) | `$GEMINI_API_KEY` → `$GOOGLE_API_KEY` | `pip install google-genai` |
|
||||
| **Kokoro** (TTS, no key) | always — final voice fallback | `pip install kokoro-onnx soundfile` |
|
||||
| **MusicGen** (BGM, no key) | always — final music fallback | `pip install transformers torch soundfile numpy` |
|
||||
|
||||
`hyperframes auth login` (browser OAuth) is the recommended setup: one sign-in, every project, no per-repo `.env`. An OAuth login is sent as `Authorization: Bearer`; an API key as `X-Api-Key`; only OAuth is tagged with `X-HeyGen-Source: cli`. The server decides HyperFrames eligibility and charges; API keys use API billing. An expired or foreign OAuth session requires refresh/sign-in and never selects a saved API key. With no HeyGen credential, voice/BGM run fully locally (Kokoro / MusicGen) — `hyperframes auth status` and `hyperframes doctor` both report whether those local deps are installed.
|
||||
|
||||
|
||||
@@ -59,15 +59,16 @@ the skill's bundled script, which calls the HeyGen v3 REST API directly and need
|
||||
no CLI provider plumbing:
|
||||
|
||||
The script resolves a HeyGen credential the same way the CLI does — first source
|
||||
wins: a host-injected OAuth `$HEYGEN_ACCESS_TOKEN` → `$HEYGEN_API_KEY` →
|
||||
`$HYPERFRAMES_API_KEY` → a project `.env` (auto-loaded,
|
||||
walks up ≤5 dirs) → `~/.hyperframes/credentials` (isolated from heygen-cli;
|
||||
wins: a host-injected OAuth `$HEYGEN_ACCESS_TOKEN` → an unexpired login in
|
||||
`~/.hyperframes/credentials` → `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → a
|
||||
project `.env` (auto-loaded, walks up ≤5 dirs) → an API key saved in
|
||||
`~/.hyperframes/credentials` (isolated from heygen-cli;
|
||||
`$HYPERFRAMES_CONFIG_DIR` overrides the root; `HYPERFRAMES_ENV=dev` uses `dev/`). An OAuth login is sent as
|
||||
`Authorization: Bearer`; an API key as `X-Api-Key`; only OAuth includes
|
||||
`X-HeyGen-Source: cli`. The server decides HyperFrames eligibility and charges;
|
||||
explicit API keys use API billing. An expired or foreign OAuth session stops
|
||||
rather than selecting a saved API key, with a hint to run
|
||||
`npx hyperframes auth refresh`.
|
||||
explicit API keys use API billing. An expired or foreign OAuth session gives way
|
||||
to an env key; without one it stops rather than selecting a saved API key, with
|
||||
a hint to run `npx hyperframes auth refresh`.
|
||||
|
||||
```bash
|
||||
# Only needed if you haven't run `npx hyperframes auth login`:
|
||||
|
||||
@@ -3,10 +3,12 @@ import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
|
||||
// pipeline. The credential resolver matches the hyperframes CLI auth: first
|
||||
// usable source wins — a host gateway ($HEYGEN_API_BASE with its own
|
||||
// $HEYGEN_API_KEY) → a host-injected OAuth $HEYGEN_ACCESS_TOKEN (Bearer) →
|
||||
// $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.hyperframes/
|
||||
// credentials (OAuth never falls back to an API key; $HYPERFRAMES_CONFIG_DIR
|
||||
// overrides the dir). $HEYGEN_API_BASE moves every request to that host, as it
|
||||
// does for the heygen CLI; plain HTTP needs $HEYGEN_ALLOW_HTTP=1, as there.
|
||||
// the live login in ~/.hyperframes/credentials → $HEYGEN_API_KEY /
|
||||
// $HYPERFRAMES_API_KEY (or a nearby .env) → the API key saved in
|
||||
// ~/.hyperframes/credentials. An expired login gives way to an env key but
|
||||
// never to the saved API key ($HYPERFRAMES_CONFIG_DIR overrides the dir).
|
||||
// $HEYGEN_API_BASE moves every request to that host, as it does for the
|
||||
// heygen CLI; plain HTTP needs $HEYGEN_ALLOW_HTTP=1, as there.
|
||||
// Vendored so the skill ships standalone. Pure node.
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
@@ -40,12 +42,10 @@ function heygenOwnBase() {
|
||||
}
|
||||
}
|
||||
|
||||
// A host gateway: the host named its own API base and the key that base accepts. It pays for every call, so it wins
|
||||
// over any other credential the environment carries.
|
||||
// A host gateway: the host pointed the API base away from HeyGen and set the key that base accepts. It pays for every
|
||||
// call, so it wins over any other credential the environment carries.
|
||||
const hostGatewayKey = () =>
|
||||
process.env.HEYGEN_API_BASE?.trim() && process.env.HEYGEN_API_KEY
|
||||
? process.env.HEYGEN_API_KEY
|
||||
: null;
|
||||
!heygenOwnBase() && process.env.HEYGEN_API_KEY ? process.env.HEYGEN_API_KEY : null;
|
||||
export const HEYGEN_CLI_SOURCE_HEADERS = { "X-HeyGen-Source": "cli" };
|
||||
// Tool-attribution sent on EVERY media-use HeyGen call regardless of auth type, so
|
||||
// the backend can isolate media-use consumption from other free TTS / avatar video.
|
||||
@@ -114,8 +114,14 @@ function resolveCredential() {
|
||||
const accessToken = process.env.HEYGEN_ACCESS_TOKEN;
|
||||
if (accessToken) return { headers: { Authorization: `Bearer ${accessToken}` } };
|
||||
const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
|
||||
if (envKey) return { headers: { "X-Api-Key": envKey } };
|
||||
const stored = storedCredential();
|
||||
// A live login wins over an env key; an expired, foreign or unreadable store gives way to one.
|
||||
if (stored?.headers && "Authorization" in stored.headers) return stored;
|
||||
return envKey ? { headers: { "X-Api-Key": envKey } } : stored;
|
||||
}
|
||||
|
||||
// The credential saved in ~/.hyperframes/credentials: { headers } | { expired: true } | { unreadable } | null.
|
||||
function storedCredential() {
|
||||
const root = process.env.HYPERFRAMES_CONFIG_DIR || join(homedir(), ".hyperframes");
|
||||
const file = join(root, ...(process.env.HYPERFRAMES_ENV === "dev" ? ["dev"] : []), "credentials");
|
||||
let raw;
|
||||
|
||||
@@ -129,6 +129,38 @@ test("a host-injected HEYGEN_ACCESS_TOKEN wins over an API key and is treated as
|
||||
});
|
||||
});
|
||||
|
||||
test("a live login wins over HEYGEN_API_KEY; an expired one gives way to it", () => {
|
||||
withCleanHeygenEnv(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-login-vs-env-"));
|
||||
try {
|
||||
process.env.HYPERFRAMES_CONFIG_DIR = dir;
|
||||
process.env.HEYGEN_API_KEY = "hg_env";
|
||||
const credential = {
|
||||
api_key: "saved-wallet",
|
||||
oauth: {
|
||||
client_id: "Zg3LF4FCLoeJoGs2YqMtZ17g",
|
||||
token_endpoint: "https://api2.heygen.com/v1/oauth/token",
|
||||
access_token: "at_login",
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
writeFileSync(join(dir, "credentials"), JSON.stringify(credential));
|
||||
assert.deepEqual(heygenCredential(), { headers: { Authorization: "Bearer at_login" } });
|
||||
// HeyGen's own base is no gateway, so naming it changes nothing.
|
||||
process.env.HEYGEN_API_BASE = "https://api.heygen.com";
|
||||
assert.deepEqual(heygenCredential(), { headers: { Authorization: "Bearer at_login" } });
|
||||
delete process.env.HEYGEN_API_BASE;
|
||||
credential.oauth.expires_at = "2000-01-01T00:00:00Z";
|
||||
writeFileSync(join(dir, "credentials"), JSON.stringify(credential));
|
||||
assert.deepEqual(heygenCredential(), { headers: { "X-Api-Key": "hg_env" } });
|
||||
writeFileSync(join(dir, "credentials"), JSON.stringify({ api_key: "saved-wallet" }));
|
||||
assert.deepEqual(heygenCredential(), { headers: { "X-Api-Key": "hg_env" } });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("heygenAuthMethod returns api_key for an env API key, without tagging headers", () => {
|
||||
withCleanHeygenEnv(() => {
|
||||
process.env.HEYGEN_API_KEY = "hg_test";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## Signing in
|
||||
|
||||
`npx hyperframes auth status` says what is signed in. The credential is read in this order: `HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`, a host app's `HEYGEN_ACCESS_TOKEN`, then `~/.hyperframes/credentials`, which `npx hyperframes auth login` writes. `heygen auth login` is a separate sign-in that only the `heygen` CLI reads.
|
||||
`npx hyperframes auth status` says what is signed in. The credential is read in this order: a host app's `HEYGEN_ACCESS_TOKEN`, the session `npx hyperframes auth login` saved in `~/.hyperframes/credentials`, `HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`, then an API key saved with `auth login --api-key`. A signed-in session wins over an env key; the env key is used when HeyGen refuses to refresh the session. `heygen auth login` is a separate sign-in that only the `heygen` CLI reads.
|
||||
|
||||
| Credential | Pays with | Covers |
|
||||
| -------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
|
||||
Reference in New Issue
Block a user