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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions packages/cli/src/auth/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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: 4200 } },
},
}),
);
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).toBe(4200);
});

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 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");
});
});
171 changes: 171 additions & 0 deletions packages/cli/src/auth/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* 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;
}

export interface SubscriptionInfo {
plan?: string;
credits?: {
premium_credits?: number;
add_on_credits?: number;
};
}

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>")
.replace(/(authorization|x-api-key)\s*[:=]\s*\S+/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;
}
43 changes: 43 additions & 0 deletions packages/cli/src/auth/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading