mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(cli): address code-review findings on auth PR
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthClient, apiBaseUrl, buildAuthHeaders } from "./client.js";
|
||||
import { isAuthError } from "./errors.js";
|
||||
import type { ResolvedCredential } from "./resolver.js";
|
||||
|
||||
function jsonFetch(body: unknown, status = 200): typeof fetch {
|
||||
return (async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function textFetch(body: string, status: number): typeof fetch {
|
||||
return (async () => new Response(body, { status })) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function apiKeyCred(): ResolvedCredential {
|
||||
return { type: "api_key", key: "hg_x", source: "env" };
|
||||
}
|
||||
|
||||
function makeClient(fetchImpl: typeof fetch): AuthClient {
|
||||
return new AuthClient({ baseUrl: "https://api.test.example", fetchImpl });
|
||||
}
|
||||
|
||||
describe("auth/client", () => {
|
||||
const original = process.env["HEYGEN_API_URL"];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env["HEYGEN_API_URL"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (original !== undefined) process.env["HEYGEN_API_URL"] = original;
|
||||
else delete process.env["HEYGEN_API_URL"];
|
||||
});
|
||||
|
||||
it("apiBaseUrl defaults to https://api.heygen.com", () => {
|
||||
expect(apiBaseUrl()).toBe("https://api.heygen.com");
|
||||
});
|
||||
|
||||
it("apiBaseUrl honors HEYGEN_API_URL and strips trailing slash", () => {
|
||||
process.env["HEYGEN_API_URL"] = "https://api.dev.heygen.com/";
|
||||
expect(apiBaseUrl()).toBe("https://api.dev.heygen.com");
|
||||
});
|
||||
|
||||
it("buildAuthHeaders uses Bearer for oauth", () => {
|
||||
const cred: ResolvedCredential = {
|
||||
type: "oauth",
|
||||
access_token: "at_123",
|
||||
source: "file_json",
|
||||
refreshable: false,
|
||||
};
|
||||
expect(buildAuthHeaders(cred)).toEqual({ authorization: "Bearer at_123" });
|
||||
});
|
||||
|
||||
it("buildAuthHeaders uses x-api-key for api_key", () => {
|
||||
expect(buildAuthHeaders(apiKeyCred())).toEqual({ "x-api-key": "hg_x" });
|
||||
});
|
||||
|
||||
it("getCurrentUser parses a wrapped {data: {...}} payload", async () => {
|
||||
const client = makeClient(
|
||||
jsonFetch({
|
||||
code: 100,
|
||||
message: "ok",
|
||||
data: {
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
billing_type: "subscription",
|
||||
subscription: {
|
||||
plan: "team",
|
||||
credits: {
|
||||
premium_credits: { remaining: 4200, resets_at: "2026-12-01T00:00:00Z" },
|
||||
add_on_credits: { remaining: 9 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const user = await client.getCurrentUser(apiKeyCred());
|
||||
expect(user.username).toBe("alice");
|
||||
expect(user.email).toBe("alice@example.com");
|
||||
expect(user.subscription?.plan).toBe("team");
|
||||
expect(user.subscription?.credits?.premium_credits?.remaining).toBe(4200);
|
||||
expect(user.subscription?.credits?.premium_credits?.resets_at).toBe("2026-12-01T00:00:00Z");
|
||||
expect(user.subscription?.credits?.add_on_credits?.remaining).toBe(9);
|
||||
});
|
||||
|
||||
it("getCurrentUser parses an unwrapped payload", async () => {
|
||||
const client = makeClient(jsonFetch({ email: "bob@example.com" }));
|
||||
const user = await client.getCurrentUser(apiKeyCred());
|
||||
expect(user.email).toBe("bob@example.com");
|
||||
});
|
||||
|
||||
it("getCurrentUser throws ErrUnauthenticated on 401", async () => {
|
||||
const client = makeClient(textFetch("invalid token", 401));
|
||||
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
|
||||
});
|
||||
});
|
||||
|
||||
it("getCurrentUser throws ErrApi on 5xx", async () => {
|
||||
const client = makeClient(textFetch("upstream", 503));
|
||||
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
|
||||
});
|
||||
});
|
||||
|
||||
it("getCurrentUser throws ErrApi when 2xx body is not valid JSON", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response("<html>not json</html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
})) as unknown as typeof fetch;
|
||||
const client = makeClient(fetchImpl);
|
||||
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
|
||||
});
|
||||
});
|
||||
|
||||
it("getCurrentUser returns empty UserInfo when payload.data is an array", async () => {
|
||||
const client = makeClient(jsonFetch({ code: 0, data: [{ email: "x@y" }] }));
|
||||
const user = await client.getCurrentUser(apiKeyCred());
|
||||
expect(user).toEqual({});
|
||||
});
|
||||
|
||||
it("getCurrentUser scrubs hg_ keys and JWTs from 401 detail", async () => {
|
||||
const fetchImpl = textFetch(
|
||||
'invalid request — got header "x-api-key: hg_supersecret_abc123"',
|
||||
401,
|
||||
);
|
||||
const client = makeClient(fetchImpl);
|
||||
try {
|
||||
await client.getCurrentUser(apiKeyCred());
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
expect(msg).not.toContain("hg_supersecret_abc123");
|
||||
expect(msg).toContain("<redacted>");
|
||||
return;
|
||||
}
|
||||
throw new Error("expected rejection");
|
||||
});
|
||||
|
||||
it("getCurrentUser redacts the full Authorization: Bearer value (not just the scheme)", async () => {
|
||||
const fetchImpl = textFetch(
|
||||
"rejected — echoed Authorization: Bearer at_opaque_secret_999",
|
||||
401,
|
||||
);
|
||||
const client = makeClient(fetchImpl);
|
||||
try {
|
||||
await client.getCurrentUser(apiKeyCred());
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
expect(msg).not.toContain("at_opaque_secret_999");
|
||||
expect(msg).not.toContain("Bearer at_opaque_secret_999");
|
||||
expect(msg).toContain("<redacted>");
|
||||
return;
|
||||
}
|
||||
throw new Error("expected rejection");
|
||||
});
|
||||
|
||||
it("getCurrentUser sends the right header for oauth credentials", async () => {
|
||||
let captured: Record<string, string> = {};
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
captured = (init?.headers as Record<string, string>) ?? {};
|
||||
return new Response(JSON.stringify({ email: "alice@example.com" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = makeClient(fetchImpl);
|
||||
await client.getCurrentUser({
|
||||
type: "oauth",
|
||||
access_token: "at_xyz",
|
||||
source: "file_json",
|
||||
refreshable: false,
|
||||
});
|
||||
expect(captured["authorization"]).toBe("Bearer at_xyz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Minimal typed HTTP client for HeyGen endpoints needed by the auth
|
||||
* commands. Hand-written rather than codegen'd because the surface is
|
||||
* one endpoint (`/v3/users/me`) and pulling in an OpenAPI pipeline is
|
||||
* disproportionate.
|
||||
*
|
||||
* Reads `HEYGEN_API_URL` (default `https://api.heygen.com`) so dev
|
||||
* testing is one env var away.
|
||||
*
|
||||
* Auth header selection:
|
||||
* - OAuth → `Authorization: Bearer <token>`
|
||||
* - API key → `x-api-key: <key>`
|
||||
*
|
||||
* The backend `/v3/users/me` accepts both. See
|
||||
* `movio/api_service/app/controller/user_v3.py`.
|
||||
*/
|
||||
|
||||
import { ErrApi, ErrUnauthenticated } from "./errors.js";
|
||||
import type { ResolvedCredential } from "./resolver.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.heygen.com";
|
||||
|
||||
export function apiBaseUrl(): string {
|
||||
const override = process.env["HEYGEN_API_URL"];
|
||||
return override && override.length > 0 ? override.replace(/\/+$/, "") : DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
export type BillingType = "wallet" | "subscription" | "usage_based" | string;
|
||||
|
||||
export interface WalletInfo {
|
||||
currency?: string;
|
||||
remaining_balance?: number;
|
||||
auto_reload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API returns `credits.{premium,add_on}_credits` as nested objects
|
||||
* (`{ remaining, resets_at? }`), not bare numbers — discovered live
|
||||
* against api.heygen.com. Modelling them as nested objects so the row
|
||||
* formatter can render them properly instead of `[object Object]`.
|
||||
*/
|
||||
export interface CreditBalance {
|
||||
remaining?: number;
|
||||
resets_at?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan?: string;
|
||||
credits?: {
|
||||
premium_credits?: CreditBalance;
|
||||
add_on_credits?: CreditBalance;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageBasedInfo {
|
||||
spending_current_usd?: number;
|
||||
spending_cap_usd?: number;
|
||||
}
|
||||
|
||||
/** Subset of the backend response we surface to users today. */
|
||||
export interface UserInfo {
|
||||
username?: string;
|
||||
email?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
billing_type?: BillingType;
|
||||
wallet?: WalletInfo;
|
||||
subscription?: SubscriptionInfo;
|
||||
usage_based?: UsageBasedInfo;
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
/** Override base URL (otherwise `HEYGEN_API_URL` / default). */
|
||||
baseUrl?: string;
|
||||
/** Inject a custom fetch (used by tests). */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export class AuthClient {
|
||||
private readonly base: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(opts: AuthClientOptions = {}) {
|
||||
this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, "");
|
||||
this.fetchImpl = opts.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi`
|
||||
* on any other non-2xx or non-JSON body.
|
||||
*/
|
||||
async getCurrentUser(credential: ResolvedCredential): Promise<UserInfo> {
|
||||
const url = `${this.base}/v3/users/me`;
|
||||
const headers = buildAuthHeaders(credential);
|
||||
const res = await this.fetchImpl(url, { method: "GET", headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
const detail = await safeText(res);
|
||||
throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch (err) {
|
||||
throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`);
|
||||
}
|
||||
return extractUserInfo(payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
|
||||
if (credential.type === "oauth") {
|
||||
return { authorization: `Bearer ${credential.access_token}` };
|
||||
}
|
||||
return { "x-api-key": credential.key };
|
||||
}
|
||||
|
||||
async function safeText(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await res.text()).slice(0, 500);
|
||||
return scrubCredentials(body);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip credential-shaped substrings from error bodies before they
|
||||
* surface in user-facing messages or `--json` output. Some proxies
|
||||
* echo request headers in their error pages and we never want a
|
||||
* HeyGen API key, OAuth bearer, or JWT to land in scrollback / CI
|
||||
* logs because of one of those echoes.
|
||||
*/
|
||||
function scrubCredentials(s: string): string {
|
||||
return (
|
||||
s
|
||||
.replace(/hg_[A-Za-z0-9_-]{4,}/g, "hg_<redacted>")
|
||||
// Redact the ENTIRE header value to end-of-line — `Bearer <token>`
|
||||
// is two whitespace-separated words, so a `\S+` would leave the
|
||||
// opaque token exposed after the scheme.
|
||||
.replace(/(authorization|x-api-key)[ \t]*[:=][ \t]*[^\r\n]+/gi, "$1: <redacted>")
|
||||
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<jwt-redacted>")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend wraps responses in `{code, message, data: {...}}` for some
|
||||
* endpoints and returns raw fields directly for others. Handle both.
|
||||
*/
|
||||
function extractUserInfo(payload: unknown): UserInfo {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const wrapped = obj["data"];
|
||||
const data =
|
||||
wrapped && typeof wrapped === "object" && !Array.isArray(wrapped)
|
||||
? (wrapped as Record<string, unknown>)
|
||||
: obj;
|
||||
return {
|
||||
username: pickString(data, "username"),
|
||||
email: pickString(data, "email"),
|
||||
first_name: pickString(data, "first_name"),
|
||||
last_name: pickString(data, "last_name"),
|
||||
billing_type: pickString(data, "billing_type"),
|
||||
wallet: pickObject(data, "wallet") as WalletInfo | undefined,
|
||||
subscription: pickObject(data, "subscription") as SubscriptionInfo | undefined,
|
||||
usage_based: pickObject(data, "usage_based") as UsageBasedInfo | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function pickString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const v = obj[key];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
}
|
||||
|
||||
function pickObject(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const v = obj[key];
|
||||
return v && typeof v === "object" && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AuthError,
|
||||
ErrApi,
|
||||
ErrInvalidStore,
|
||||
ErrNotConfigured,
|
||||
ErrUnauthenticated,
|
||||
isAuthError,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("auth/errors", () => {
|
||||
it("ErrNotConfigured carries the right code + hint", () => {
|
||||
const err = ErrNotConfigured();
|
||||
expect(err).toBeInstanceOf(AuthError);
|
||||
expect(err.code).toBe("NOT_CONFIGURED");
|
||||
expect(err.hint).toContain("hyperframes auth login");
|
||||
});
|
||||
|
||||
it("ErrInvalidStore wraps the detail", () => {
|
||||
const err = ErrInvalidStore("malformed at line 3");
|
||||
expect(err.code).toBe("INVALID_STORE");
|
||||
expect(err.message).toContain("malformed at line 3");
|
||||
});
|
||||
|
||||
it("ErrUnauthenticated includes detail when provided", () => {
|
||||
expect(ErrUnauthenticated().code).toBe("UNAUTHENTICATED");
|
||||
expect(ErrUnauthenticated("invalid token").message).toContain("invalid token");
|
||||
});
|
||||
|
||||
it("ErrApi captures status + detail", () => {
|
||||
const err = ErrApi(503, "upstream timeout");
|
||||
expect(err.code).toBe("API_ERROR");
|
||||
expect(err.message).toContain("503");
|
||||
expect(err.message).toContain("upstream timeout");
|
||||
});
|
||||
|
||||
it("isAuthError narrows properly", () => {
|
||||
expect(isAuthError(ErrNotConfigured())).toBe(true);
|
||||
expect(isAuthError(new Error("plain"))).toBe(false);
|
||||
expect(isAuthError(null)).toBe(false);
|
||||
expect(isAuthError("string")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Typed errors for the auth layer. Callers branch on `code` so commands
|
||||
* can map specific failures to friendly UX without parsing messages.
|
||||
*/
|
||||
|
||||
export type AuthErrorCode = "NOT_CONFIGURED" | "INVALID_STORE" | "API_ERROR" | "UNAUTHENTICATED";
|
||||
|
||||
export class AuthError extends Error {
|
||||
readonly code: AuthErrorCode;
|
||||
readonly hint?: string;
|
||||
|
||||
constructor(code: AuthErrorCode, message: string, hint?: string) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
this.code = code;
|
||||
this.hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrNotConfigured = () =>
|
||||
new AuthError(
|
||||
"NOT_CONFIGURED",
|
||||
"No HeyGen credentials found",
|
||||
"Run `hyperframes auth login` to sign in.",
|
||||
);
|
||||
|
||||
export const ErrInvalidStore = (detail: string) =>
|
||||
new AuthError(
|
||||
"INVALID_STORE",
|
||||
`Credential file is unreadable: ${detail}`,
|
||||
"Delete ~/.heygen/credentials and run `hyperframes auth login` to re-create it.",
|
||||
);
|
||||
|
||||
export const ErrUnauthenticated = (detail?: string) =>
|
||||
new AuthError(
|
||||
"UNAUTHENTICATED",
|
||||
detail ? `HeyGen rejected the credential: ${detail}` : "HeyGen rejected the credential",
|
||||
"Run `hyperframes auth login` to re-authenticate.",
|
||||
);
|
||||
|
||||
export const ErrApi = (status: number, detail: string) =>
|
||||
new AuthError("API_ERROR", `HeyGen API error (${status}): ${detail}`);
|
||||
|
||||
export function isAuthError(err: unknown): err is AuthError {
|
||||
return err instanceof AuthError;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Public surface of the auth library — only the symbols the auth
|
||||
* commands consume today. Internal types stay in their source files.
|
||||
*/
|
||||
|
||||
export { isAuthError } from "./errors.js";
|
||||
|
||||
export { clearOAuth, deleteStore, isHeaderSafe, readStore, writeStore } from "./store.js";
|
||||
export type { Credentials } from "./store.js";
|
||||
|
||||
export { configDir, credentialPath } from "./paths.js";
|
||||
|
||||
export { tryResolveCredential } from "./resolver.js";
|
||||
export type { ResolvedCredential } from "./resolver.js";
|
||||
|
||||
export { AuthClient } from "./client.js";
|
||||
export type { UserInfo } from "./client.js";
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { CREDENTIAL_FILENAME, configDir, credentialPath } from "./paths.js";
|
||||
|
||||
describe("auth/paths", () => {
|
||||
const original = process.env["HEYGEN_CONFIG_DIR"];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env["HEYGEN_CONFIG_DIR"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (original !== undefined) process.env["HEYGEN_CONFIG_DIR"] = original;
|
||||
else delete process.env["HEYGEN_CONFIG_DIR"];
|
||||
});
|
||||
|
||||
it("defaults to ~/.heygen", () => {
|
||||
expect(configDir()).toBe(join(homedir(), ".heygen"));
|
||||
});
|
||||
|
||||
it("honors HEYGEN_CONFIG_DIR override", () => {
|
||||
process.env["HEYGEN_CONFIG_DIR"] = "/tmp/some-test-dir";
|
||||
expect(configDir()).toBe("/tmp/some-test-dir");
|
||||
expect(credentialPath()).toBe(join("/tmp/some-test-dir", CREDENTIAL_FILENAME));
|
||||
});
|
||||
|
||||
it("treats empty HEYGEN_CONFIG_DIR as unset", () => {
|
||||
process.env["HEYGEN_CONFIG_DIR"] = "";
|
||||
expect(configDir()).toBe(join(homedir(), ".heygen"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Filesystem layout for the shared HeyGen credential store. Mirrors
|
||||
* `heygen-cli/internal/paths/paths.go` so both CLIs read the same file.
|
||||
* `HEYGEN_CONFIG_DIR` overrides the directory.
|
||||
*/
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Filename for the credential store. Matches heygen-cli (no `.json`
|
||||
* suffix) so a `~/.heygen/credentials` written by either CLI is
|
||||
* readable by the other — see `heygen-cli/internal/auth/file_resolver.go`.
|
||||
*/
|
||||
export const CREDENTIAL_FILENAME = "credentials";
|
||||
|
||||
export function configDir(): string {
|
||||
const override = process.env["HEYGEN_CONFIG_DIR"];
|
||||
if (override && override.length > 0) return override;
|
||||
return join(homedir(), ".heygen");
|
||||
}
|
||||
|
||||
export function credentialPath(): string {
|
||||
return join(configDir(), CREDENTIAL_FILENAME);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isAuthError } from "./errors.js";
|
||||
import { resolveCredential, tryResolveCredential } from "./resolver.js";
|
||||
import { writeStore } from "./store.js";
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
return fs.mkdtemp(join(tmpdir(), "hf-auth-resolve-"));
|
||||
}
|
||||
|
||||
const ENV_KEYS = ["HEYGEN_API_KEY", "HYPERFRAMES_API_KEY", "HEYGEN_CONFIG_DIR"] as const;
|
||||
|
||||
describe("auth/resolver", () => {
|
||||
let dir: string;
|
||||
const saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await makeTmpDir();
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env["HEYGEN_CONFIG_DIR"] = dir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const k of ENV_KEYS) {
|
||||
const v = saved[k];
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prefers HEYGEN_API_KEY over everything else", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "env-key";
|
||||
process.env["HYPERFRAMES_API_KEY"] = "alias-key";
|
||||
await writeStore({ api_key: "file-key" });
|
||||
const r = await resolveCredential();
|
||||
expect(r).toEqual({ type: "api_key", key: "env-key", source: "env" });
|
||||
});
|
||||
|
||||
it("falls through to HYPERFRAMES_API_KEY", async () => {
|
||||
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("returns file api_key when no env is set", async () => {
|
||||
await writeStore({ api_key: "file-key" });
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("api_key");
|
||||
if (r.type === "api_key") {
|
||||
expect(r.key).toBe("file-key");
|
||||
expect(r.source).toBe("file_json");
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers fresh oauth over api_key", async () => {
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
await writeStore({
|
||||
api_key: "file-key",
|
||||
oauth: { access_token: "fresh-at", refresh_token: "rt", expires_at: future },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("oauth");
|
||||
if (r.type === "oauth") {
|
||||
expect(r.access_token).toBe("fresh-at");
|
||||
// Fresh access_token does NOT need refresh, even with refresh_token present.
|
||||
expect(r.refreshable).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("oauth without expires_at is treated as fresh (refreshable=false)", async () => {
|
||||
await writeStore({
|
||||
oauth: { access_token: "at", refresh_token: "rt" },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("oauth");
|
||||
if (r.type === "oauth") expect(r.refreshable).toBe(false);
|
||||
});
|
||||
|
||||
it("marks expired-but-refreshable oauth as refreshable", async () => {
|
||||
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
await writeStore({
|
||||
oauth: { access_token: "stale-at", refresh_token: "rt", expires_at: past },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("oauth");
|
||||
if (r.type === "oauth") expect(r.refreshable).toBe(true);
|
||||
});
|
||||
|
||||
it("skips expired non-refreshable oauth and falls through to api_key", async () => {
|
||||
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
await writeStore({
|
||||
api_key: "fallback",
|
||||
oauth: { access_token: "stale-at", expires_at: past },
|
||||
});
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("api_key");
|
||||
if (r.type === "api_key") expect(r.key).toBe("fallback");
|
||||
});
|
||||
|
||||
it("rejects HEYGEN_API_KEY containing CRLF (header-injection guard)", async () => {
|
||||
process.env["HEYGEN_API_KEY"] = "hg_x\r\nX-Evil: 1";
|
||||
await expect(resolveCredential()).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "INVALID_STORE";
|
||||
});
|
||||
});
|
||||
|
||||
it("throws ErrNotConfigured when nothing is configured", async () => {
|
||||
await expect(resolveCredential()).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "NOT_CONFIGURED";
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies legacy plaintext file source", async () => {
|
||||
const path = join(dir, "credentials");
|
||||
await fs.writeFile(path, "hg_legacy_key", { mode: 0o600 });
|
||||
const r = await resolveCredential();
|
||||
expect(r.type).toBe("api_key");
|
||||
if (r.type === "api_key") {
|
||||
expect(r.key).toBe("hg_legacy_key");
|
||||
expect(r.source).toBe("file_legacy");
|
||||
}
|
||||
});
|
||||
|
||||
it("tryResolveCredential returns null when not configured", async () => {
|
||||
expect(await tryResolveCredential()).toBeNull();
|
||||
});
|
||||
|
||||
it("tryResolveCredential surfaces broken-file errors", async () => {
|
||||
const path = join(dir, "credentials");
|
||||
await fs.writeFile(path, "{not valid", { mode: 0o600 });
|
||||
await expect(tryResolveCredential()).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("uses injected now() for expiry decisions", async () => {
|
||||
// expires_at is one hour ago in real time. Injecting `now` two
|
||||
// hours in the past makes the token appear fresh (still valid for
|
||||
// another hour), so the resolver should NOT mark it refreshable.
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
await writeStore({
|
||||
oauth: { access_token: "at", refresh_token: "rt", expires_at: oneHourAgo },
|
||||
});
|
||||
const r = await resolveCredential({
|
||||
now: () => new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
});
|
||||
expect(r.type).toBe("oauth");
|
||||
if (r.type === "oauth") {
|
||||
expect(r.access_token).toBe("at");
|
||||
expect(r.refreshable).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Chain resolver for HeyGen 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/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.
|
||||
*
|
||||
* Expiry policy: an OAuth access_token whose `expires_at` is in the
|
||||
* past (60s skew) is considered expired. If a `refresh_token` is also
|
||||
* present, callers can still use it via `refreshable: true`. Otherwise
|
||||
* the api_key (if any) wins.
|
||||
*/
|
||||
|
||||
import { isHeaderSafe, readStore } from "./store.js";
|
||||
import { ErrInvalidStore, ErrNotConfigured, isAuthError } from "./errors.js";
|
||||
|
||||
type CredentialSource = "env" | "env_alias" | "file_json" | "file_legacy";
|
||||
|
||||
interface ApiKeyCredential {
|
||||
type: "api_key";
|
||||
key: string;
|
||||
source: CredentialSource;
|
||||
}
|
||||
|
||||
interface OAuthCredential {
|
||||
type: "oauth";
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_at?: Date;
|
||||
scope?: string;
|
||||
source: CredentialSource;
|
||||
/** True when the access_token is expired but a refresh_token exists. */
|
||||
refreshable: boolean;
|
||||
}
|
||||
|
||||
export type ResolvedCredential = ApiKeyCredential | OAuthCredential;
|
||||
|
||||
const EXPIRY_SKEW_MS = 60 * 1000;
|
||||
|
||||
export interface ResolveOptions {
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
return { type: "api_key", key: hfEnv, source: "env_alias" };
|
||||
}
|
||||
|
||||
const { credentials, source } = await readStore();
|
||||
if (source === "absent") throw ErrNotConfigured();
|
||||
|
||||
const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json";
|
||||
|
||||
if (credentials.oauth) {
|
||||
const oauth = pickOAuth(credentials.oauth, now, fileSource);
|
||||
if (oauth) return oauth;
|
||||
}
|
||||
if (credentials.api_key) {
|
||||
return { type: "api_key", key: credentials.api_key, source: fileSource };
|
||||
}
|
||||
throw ErrNotConfigured();
|
||||
}
|
||||
|
||||
/** Like `resolveCredential` but returns `null` instead of throwing `NOT_CONFIGURED`. */
|
||||
export async function tryResolveCredential(
|
||||
opts: ResolveOptions = {},
|
||||
): Promise<ResolvedCredential | null> {
|
||||
try {
|
||||
return await resolveCredential(opts);
|
||||
} catch (err) {
|
||||
if (isAuthError(err) && err.code === "NOT_CONFIGURED") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function pickOAuth(
|
||||
tokens: NonNullable<Awaited<ReturnType<typeof readStore>>["credentials"]["oauth"]>,
|
||||
now: Date,
|
||||
source: CredentialSource,
|
||||
): OAuthCredential | null {
|
||||
const expiresAt = parseDate(tokens.expires_at);
|
||||
const expired = expiresAt !== undefined && expiresAt.getTime() - EXPIRY_SKEW_MS < now.getTime();
|
||||
|
||||
if (expired && !tokens.refresh_token) return null;
|
||||
|
||||
const out: OAuthCredential = {
|
||||
type: "oauth",
|
||||
access_token: tokens.access_token,
|
||||
source,
|
||||
refreshable: expired && tokens.refresh_token !== undefined,
|
||||
};
|
||||
if (tokens.refresh_token) out.refresh_token = tokens.refresh_token;
|
||||
if (expiresAt) out.expires_at = expiresAt;
|
||||
if (tokens.scope) out.scope = tokens.scope;
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseDate(s: string | undefined): Date | undefined {
|
||||
if (!s) return undefined;
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isAuthError } from "./errors.js";
|
||||
import { clearOAuth, deleteStore, readStore, writeStore, type Credentials } from "./store.js";
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
return fs.mkdtemp(join(tmpdir(), "hf-auth-store-"));
|
||||
}
|
||||
|
||||
// POSIX file modes don't apply on Windows — `fs.chmod` only toggles the
|
||||
// read-only bit there, so `stat.mode & 0o777` reports 0o666/0o444
|
||||
// regardless of what we requested. Skip the mode assertions on win32;
|
||||
// the 0600/0700 hardening is a Unix concern.
|
||||
const IS_POSIX = process.platform !== "win32";
|
||||
|
||||
describe("auth/store", () => {
|
||||
let dir: string;
|
||||
let path: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await makeTmpDir();
|
||||
path = join(dir, "credentials");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns absent when the file does not exist", async () => {
|
||||
const result = await readStore(path);
|
||||
expect(result).toEqual({ credentials: {}, source: "absent" });
|
||||
});
|
||||
|
||||
it("round-trips api_key only", async () => {
|
||||
const creds: Credentials = { api_key: "hg_test_abc" };
|
||||
await writeStore(creds, path);
|
||||
const result = await readStore(path);
|
||||
expect(result.source).toBe("file_json");
|
||||
expect(result.credentials).toEqual(creds);
|
||||
});
|
||||
|
||||
it("round-trips oauth tokens", async () => {
|
||||
const creds: Credentials = {
|
||||
oauth: {
|
||||
access_token: "at_123",
|
||||
refresh_token: "rt_456",
|
||||
expires_at: "2026-06-25T12:00:00.000Z",
|
||||
scope: "openid profile",
|
||||
token_type: "Bearer",
|
||||
},
|
||||
};
|
||||
await writeStore(creds, path);
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials).toEqual(creds);
|
||||
});
|
||||
|
||||
it("round-trips both api_key and oauth", async () => {
|
||||
const creds: Credentials = {
|
||||
api_key: "hg_test_abc",
|
||||
oauth: { access_token: "at_123" },
|
||||
};
|
||||
await writeStore(creds, path);
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials.api_key).toBe("hg_test_abc");
|
||||
expect(result.credentials.oauth?.access_token).toBe("at_123");
|
||||
});
|
||||
|
||||
it("reads legacy one-line plaintext format", async () => {
|
||||
await fs.writeFile(path, "hg_legacy_key\n", { mode: 0o600 });
|
||||
const result = await readStore(path);
|
||||
expect(result.source).toBe("file_legacy");
|
||||
expect(result.credentials.api_key).toBe("hg_legacy_key");
|
||||
});
|
||||
|
||||
it("treats empty file as absent", async () => {
|
||||
await fs.writeFile(path, "", { mode: 0o600 });
|
||||
const result = await readStore(path);
|
||||
expect(result.source).toBe("absent");
|
||||
});
|
||||
|
||||
it("throws ErrInvalidStore on garbage JSON", async () => {
|
||||
await fs.writeFile(path, "{not valid json", { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("throws ErrInvalidStore on multi-line non-JSON content", async () => {
|
||||
await fs.writeFile(path, "not\na\nkey", { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it.skipIf(!IS_POSIX)("writes file 0600 and dir 0700", async () => {
|
||||
const nested = join(dir, "sub", "deeper");
|
||||
const p = join(nested, "credentials");
|
||||
await writeStore({ api_key: "hg_x" }, p);
|
||||
expect((await fs.stat(p)).mode & 0o777).toBe(0o600);
|
||||
expect((await fs.stat(nested)).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
it("preserves content across overwrites", async () => {
|
||||
await writeStore({ api_key: "first" }, path);
|
||||
await writeStore({ api_key: "second" }, path);
|
||||
if (IS_POSIX) {
|
||||
expect((await fs.stat(path)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials.api_key).toBe("second");
|
||||
});
|
||||
|
||||
it("rejects empty-string api_key", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ api_key: "" }), { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("rejects api_key with CR/LF (header-injection guard)", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x\r\nX-Evil: foo" }), { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("strips oauth fields containing CR/LF rather than crashing later", async () => {
|
||||
await fs.writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
oauth: {
|
||||
access_token: "good_at",
|
||||
refresh_token: "bad_rt\r\nX-Smuggle: 1",
|
||||
},
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials.oauth?.access_token).toBe("good_at");
|
||||
expect(result.credentials.oauth?.refresh_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects access_token containing CR/LF (header-injection guard)", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ oauth: { access_token: "at\r\nX-Evil: 1" } }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("accepts a legacy plaintext key of any HeyGen key format", async () => {
|
||||
// Real HeyGen keys come in multiple formats (`sk_V2_…`, `hg_…`,
|
||||
// partner keys, etc.). The CLI doesn't shape-check — the backend
|
||||
// does. Any single-line printable non-empty value is accepted as
|
||||
// a legacy key here; the next /v3/users/me call decides validity.
|
||||
await fs.writeFile(path, "sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN", {
|
||||
mode: 0o600,
|
||||
});
|
||||
const result = await readStore(path);
|
||||
expect(result.source).toBe("file_legacy");
|
||||
expect(result.credentials.api_key).toBe(
|
||||
"sk_V2_hgu_kVzzCxfI3cT_Yi96MxT2Ki6UamtWxyP7oOIPqsxaFHqN",
|
||||
);
|
||||
});
|
||||
|
||||
it("still rejects plaintext that contains a space (not a credential shape)", async () => {
|
||||
await fs.writeFile(path, "hello world this is not a key", { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("still rejects too-short plaintext", async () => {
|
||||
await fs.writeFile(path, "tiny", { mode: 0o600 });
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("rejects oauth without access_token", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ oauth: { refresh_token: "rt" } }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await expect(readStore(path)).rejects.toSatisfy((err) => isAuthError(err));
|
||||
});
|
||||
|
||||
it("drops unknown top-level keys", async () => {
|
||||
await fs.writeFile(path, JSON.stringify({ api_key: "hg_x", future_field: { stuff: 1 } }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials).toEqual({ api_key: "hg_x" });
|
||||
});
|
||||
|
||||
it("deleteStore is idempotent", async () => {
|
||||
await writeStore({ api_key: "hg_x" }, path);
|
||||
await deleteStore(path);
|
||||
await deleteStore(path);
|
||||
await expect(fs.access(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("clearOAuth removes only the oauth field", async () => {
|
||||
await writeStore({ api_key: "hg_keep", oauth: { access_token: "drop_me" } }, path);
|
||||
await clearOAuth(path);
|
||||
const result = await readStore(path);
|
||||
expect(result.credentials.oauth).toBeUndefined();
|
||||
expect(result.credentials.api_key).toBe("hg_keep");
|
||||
});
|
||||
|
||||
it("clearOAuth removes the whole file when no api_key remains", async () => {
|
||||
await writeStore({ oauth: { access_token: "only" } }, path);
|
||||
await clearOAuth(path);
|
||||
await expect(fs.access(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("clearOAuth is a no-op when file is absent", async () => {
|
||||
await clearOAuth(path);
|
||||
await expect(fs.access(path)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Read/write the shared `~/.heygen/credentials` file (JSON contents,
|
||||
* no `.json` extension — the path matches heygen-cli).
|
||||
*
|
||||
* Current format:
|
||||
* {
|
||||
* "api_key": "hg_...",
|
||||
* "oauth": {
|
||||
* "access_token": "...",
|
||||
* "refresh_token": "...",
|
||||
* "expires_at": "2026-06-25T12:00:00Z",
|
||||
* "scope": "openid profile",
|
||||
* "token_type": "Bearer"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Legacy: a single-line plaintext API key (the format heygen-cli has
|
||||
* written historically). If `JSON.parse` rejects the file, we treat the
|
||||
* trimmed contents as an API key; the next write upgrades to JSON.
|
||||
*
|
||||
* Writes go to a temp file + rename, 0600 mode, parent dir 0700.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { credentialPath } from "./paths.js";
|
||||
import { ErrInvalidStore } from "./errors.js";
|
||||
|
||||
const FILE_MODE = 0o600;
|
||||
const DIR_MODE = 0o700;
|
||||
|
||||
export interface OAuthTokens {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
/** ISO-8601 UTC. */
|
||||
expires_at?: string;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
api_key?: string;
|
||||
oauth?: OAuthTokens;
|
||||
}
|
||||
|
||||
export type StoreSource = "file_json" | "file_legacy" | "absent";
|
||||
|
||||
export interface ReadResult {
|
||||
credentials: Credentials;
|
||||
source: StoreSource;
|
||||
}
|
||||
|
||||
export async function readStore(path = credentialPath()): Promise<ReadResult> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, "utf8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return { credentials: {}, source: "absent" };
|
||||
}
|
||||
throw ErrInvalidStore(`unable to read ${path}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return { credentials: {}, source: "absent" };
|
||||
|
||||
if (trimmed.startsWith("{")) {
|
||||
return { credentials: parseJsonStore(trimmed), source: "file_json" };
|
||||
}
|
||||
|
||||
if (looksLikeApiKey(trimmed)) {
|
||||
return { credentials: { api_key: trimmed }, source: "file_legacy" };
|
||||
}
|
||||
|
||||
throw ErrInvalidStore("file is not JSON and does not look like a plain API key");
|
||||
}
|
||||
|
||||
export async function writeStore(credentials: Credentials, path = credentialPath()): Promise<void> {
|
||||
await ensureDir(dirname(path));
|
||||
const body = JSON.stringify(serializeCredentials(credentials), null, 2);
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.writeFile(tmp, `${body}\n`, { mode: FILE_MODE, encoding: "utf8" });
|
||||
// `mode` on `writeFile` is masked by umask and only applies on file
|
||||
// creation — explicit chmod is the only reliable way to land on 0600.
|
||||
// `rename` moves the (already-0600) tmp inode over the destination,
|
||||
// so the final file carries the tmp's mode; no post-rename chmod
|
||||
// needed even when overwriting a looser-permissioned file.
|
||||
await fs.chmod(tmp, FILE_MODE);
|
||||
await fs.rename(tmp, path);
|
||||
}
|
||||
|
||||
export async function deleteStore(path = credentialPath()): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(path);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove only the `oauth` block. Used by `auth logout --keep-api-key`. */
|
||||
export async function clearOAuth(path = credentialPath()): Promise<void> {
|
||||
const { credentials, source } = await readStore(path);
|
||||
if (source === "absent" || !credentials.oauth) return;
|
||||
if (!credentials.api_key) {
|
||||
await deleteStore(path);
|
||||
return;
|
||||
}
|
||||
await writeStore({ api_key: credentials.api_key }, path);
|
||||
}
|
||||
|
||||
async function ensureDir(dir: string): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.stat(dir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw ErrInvalidStore(`${dir} exists and is not a directory`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
await fs.mkdir(dir, { recursive: true, mode: DIR_MODE });
|
||||
}
|
||||
try {
|
||||
await fs.chmod(dir, DIR_MODE);
|
||||
} catch {
|
||||
/* perm-less filesystems are fine */
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonStore(text: string): Credentials {
|
||||
const obj = parseJsonObject(text, "credential file root");
|
||||
const out: Credentials = {};
|
||||
const apiKey = pickRequiredStringOrAbsent(obj, "api_key", "api_key");
|
||||
if (apiKey !== undefined) {
|
||||
if (!isHeaderSafe(apiKey)) {
|
||||
throw ErrInvalidStore("api_key must not contain control characters");
|
||||
}
|
||||
out.api_key = apiKey;
|
||||
}
|
||||
if (obj["oauth"] !== undefined && obj["oauth"] !== null) {
|
||||
out.oauth = parseOAuth(obj["oauth"]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseOAuth(raw: unknown): OAuthTokens {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw ErrInvalidStore("oauth must be a JSON object");
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const accessToken = pickHeaderSafeString(obj, "access_token");
|
||||
if (!accessToken) {
|
||||
throw ErrInvalidStore("oauth.access_token must be a non-empty string with no control chars");
|
||||
}
|
||||
const out: OAuthTokens = { access_token: accessToken };
|
||||
const refresh = pickHeaderSafeString(obj, "refresh_token");
|
||||
if (refresh) out.refresh_token = refresh;
|
||||
const exp = pickNonEmptyString(obj, "expires_at");
|
||||
if (exp) out.expires_at = exp;
|
||||
const scope = pickNonEmptyString(obj, "scope");
|
||||
if (scope) out.scope = scope;
|
||||
const tokenType = pickNonEmptyString(obj, "token_type");
|
||||
if (tokenType) out.token_type = tokenType;
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string, label: string): Record<string, unknown> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw ErrInvalidStore(`invalid JSON: ${(err as Error).message}`);
|
||||
}
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw ErrInvalidStore(`${label} must be a JSON object`);
|
||||
}
|
||||
return raw as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function pickNonEmptyString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const v = obj[key];
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
/** Like `pickNonEmptyString` but rejects values containing control chars. */
|
||||
function pickHeaderSafeString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const v = pickNonEmptyString(obj, key);
|
||||
return v !== undefined && isHeaderSafe(v) ? v : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header-safety check for credential strings: reject any string with
|
||||
* CR, LF, NUL, or other C0 control characters. Without this, a
|
||||
* malicious credentials.json could smuggle extra request headers via
|
||||
* `Authorization` / `x-api-key` (RFC 7230 header injection).
|
||||
*/
|
||||
export function isHeaderSafe(s: string): boolean {
|
||||
// Reject U+0000-U+001F (C0 controls) and U+007F (DEL) — bytes that
|
||||
// aren't allowed in HTTP header values. Using charCodeAt avoids
|
||||
// embedding control characters in regex source (lint requirement).
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i);
|
||||
if (c < 0x20 || c === 0x7f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant: returns the string when present and non-empty,
|
||||
* `undefined` when the key is absent or null, and throws when the
|
||||
* field is present-but-invalid (wrong type or empty string).
|
||||
*/
|
||||
function pickRequiredStringOrAbsent(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
errorLabel: string,
|
||||
): string | undefined {
|
||||
const v = obj[key];
|
||||
if (v === undefined || v === null) return undefined;
|
||||
if (typeof v !== "string" || v.length === 0) {
|
||||
throw ErrInvalidStore(`${errorLabel} must be a non-empty string`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function serializeCredentials(c: Credentials): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (c.api_key) out["api_key"] = c.api_key;
|
||||
if (c.oauth) {
|
||||
const oauth: Record<string, unknown> = { access_token: c.oauth.access_token };
|
||||
if (c.oauth.refresh_token) oauth["refresh_token"] = c.oauth.refresh_token;
|
||||
if (c.oauth.expires_at) oauth["expires_at"] = c.oauth.expires_at;
|
||||
if (c.oauth.scope) oauth["scope"] = c.oauth.scope;
|
||||
if (c.oauth.token_type) oauth["token_type"] = c.oauth.token_type;
|
||||
out["oauth"] = oauth;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy-plaintext heuristic. HeyGen API keys come in multiple formats
|
||||
* (`sk_V2_…`, historic `hg_…`, partner keys, etc.) and the CLI should
|
||||
* NOT shape-check them — the backend's `/v3/users/me` is the source of
|
||||
* truth and the existing `auth login` rollback handles bad keys cleanly.
|
||||
* We only require: a single line, printable, of reasonable length, and
|
||||
* header-safe (no CR/LF). JSON files are detected separately by the
|
||||
* leading `{`, so this path can't swallow a JSON fragment.
|
||||
*/
|
||||
function looksLikeApiKey(s: string): boolean {
|
||||
if (s.length < 8) return false;
|
||||
if (!isHeaderSafe(s)) return false;
|
||||
// Single line of printable ASCII (excluding space, since real keys
|
||||
// don't contain spaces — a space-bearing blob is almost certainly
|
||||
// not a credential).
|
||||
return /^[!-~]+$/.test(s);
|
||||
}
|
||||
Reference in New Issue
Block a user