mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(cli): persist + show friendly user identity; preserve unknown credential fields The `~/.heygen/credentials` file is SHARED with the Go `heygen` CLI. This is the hyperframes-side mirror of heygen-cli#197, which adds an optional `user` block to that file. Two CLIs writing one file must round-trip each other's data without loss. Load-bearing change: the credentials reader/writer now PRESERVES unknown fields on round-trip. Previously readStore/writeStore stripped any key this CLI didn't model, so writing the file back would silently drop the `user` block heygen-cli wrote (and any future key). Unrecognized top-level keys, and unknown keys inside `oauth` / `user`, are captured on a hidden symbol slot and re-emitted verbatim. Known fields stay strictly validated. Also mirrors heygen-cli#197's friendly-display feature: - New optional `user` block schema (email/first_name/last_name/username), all omitempty; legacy files without it parse fine. - After login (OAuth + api-key paths) probe /v3/users/me, persist the block, and show a friendly name (email > "first last" > username). Probe failure is non-fatal (login still succeeds); a stale block is cleared on probe failure so a wrong account can't surface. - `auth status` surfaces the persisted block (persisted_user in JSON, a cached Account row in human output) for file-sourced credentials; env-sourced credentials skip it (the on-disk block may belong to a different key). - Fixed the OAuth write path to carry the user block + unknown keys across a fresh login / refresh (it previously rebuilt a minimal record). Tests: preserve-unknown-fields round-trip (top-level, oauth, user), the exact cross-CLI `user`-block scenario, schema round-trip + omitempty, backwards-compat with legacy files, login persistence + graceful probe failure + stale-clear, and the `auth status` surface. Full CLI suite (1009 tests) green; oxlint + oxfmt + tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): preserve unknown credential data in cleanup/rollback paths Addresses Magi's REQUEST_CHANGES on #1741. The credentials reader/writer already round-trips unknown/foreign keys (the cross-CLI forward-compat contract), but three destructive paths still deleted the whole file when no known api_key/oauth survived — even when the hidden Symbol-keyed unknown-field bag held a future credential another CLI owns. That clobbers exactly the data this PR preserves. - Add `hasPreservedUnknownData(record)` to store.ts (checks the top-level unknown bag + the oauth/user sub-object bags) and export it via the barrel. - `clearOAuth`, `clearUserInfo`, and the failed `auth login --api-key` rollback now write the credential-less remnant (carrying the unknown bag) instead of deleting the file when unknown/foreign data survives. They still delete when nothing worth preserving remains. - Regression tests: rollback path + both cleanup paths (clearOAuth, clearUserInfo) preserve a foreign top-level key; `hasPreservedUnknownData` unit tests at all three levels. Also addresses the review's minor items: - Add a refresh-path round-trip test (`refreshTokens`) proving an unknown key inside the oauth sub-object survives a no-rotation refresh — the most-frequent write path, previously only implicitly covered. - Clarify the `userDisplayName` / `combineName` docstrings: precedence is `email > "first last" > first-only > last-only > username`. - Replace the stale `expires_at` example date in store.ts with `<ISO-8601 UTC>`. Full CLI suite green (1020 tests); tsc, oxlint, oxfmt --check all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
/**
|
|
* OAuth 2.0 + PKCE driver for the HeyGen public OAuth flow.
|
|
*
|
|
* Entry points:
|
|
* - `startAuthorizationCodeFlow()` — full interactive login (browser
|
|
* opens, loopback waits, code → tokens, persist).
|
|
* - `refreshTokens()` — POST <token-endpoint> with grant_type=refresh_token.
|
|
* - `revokeTokens()` — best-effort POST <revoke-endpoint>.
|
|
*
|
|
* Endpoints split across two hosts (verified live):
|
|
* - **Authorize** — GET https://app.heygen.com/oauth/authorize
|
|
* Renders the consent screen, has to live on the same origin as the
|
|
* user's web session (cookies). The Next.js SPA shell serves this.
|
|
* - **Token** — POST https://api2.heygen.com/v1/oauth/token
|
|
* Server-to-server JSON API. `app.heygen.com/oauth/token` returns
|
|
* the SPA HTML for direct POSTs — confirmed by curl; only the api2
|
|
* route returns JSON OAuth responses.
|
|
* - **Revoke** — POST https://api2.heygen.com/v1/oauth/revoke
|
|
* Same host/prefix as token.
|
|
*
|
|
* The `heygen-oauth-urls.ts` default in `hyperframes-internal/demo-next`
|
|
* lists `app.heygen.com/oauth/token` as the default — that's either
|
|
* proxied via a Next.js rewrite or set via `HEYGEN_OAUTH_TOKEN_URL` in
|
|
* their prod env. A direct fetch to it returns the SPA shell, so we
|
|
* use the api2 endpoint here.
|
|
*
|
|
* Overrides:
|
|
* - `HYPERFRAMES_OAUTH_AUTHORIZE_URL`
|
|
* - `HYPERFRAMES_OAUTH_TOKEN_URL`
|
|
* - `HYPERFRAMES_OAUTH_REVOKE_URL`
|
|
* - `HYPERFRAMES_OAUTH_CLIENT_ID`
|
|
*
|
|
* Public client — no `client_secret`.
|
|
*/
|
|
|
|
import { ErrApi, ErrOAuthNotConfigured, ErrRefreshFailed, isAuthError } from "./errors.js";
|
|
import { generatePkcePair, generateState } from "./pkce.js";
|
|
import { startLoopback } from "./loopback.js";
|
|
import { openBrowser } from "./browser.js";
|
|
import { scrubCredentials } from "./scrub.js";
|
|
import {
|
|
isHeaderSafe,
|
|
readStore,
|
|
writeStore,
|
|
type Credentials,
|
|
type OAuthTokens,
|
|
} from "./store.js";
|
|
import { c } from "../ui/colors.js";
|
|
|
|
const REVOKE_TIMEOUT_MS = 5_000;
|
|
const MIN_EXPIRES_IN_SECONDS = 30;
|
|
|
|
/**
|
|
* Default OAuth client_id baked at build time. Override with the
|
|
* `HYPERFRAMES_OAUTH_CLIENT_ID` env var. Empty string means "not
|
|
* configured" — `resolveClientId()` errors cleanly with a pointer
|
|
* at `--api-key`.
|
|
*/
|
|
const DEFAULT_CLIENT_ID = "q2A2QRSke2LrFTPJhoDbHtXh";
|
|
const DEFAULT_SCOPES = "openid profile email";
|
|
|
|
// Endpoint defaults — see file-header comment for why these straddle two
|
|
// hosts. Each is independently overridable.
|
|
const DEFAULT_AUTHORIZE_URL = "https://app.heygen.com/oauth/authorize";
|
|
const DEFAULT_TOKEN_URL = "https://api2.heygen.com/v1/oauth/token";
|
|
const DEFAULT_REVOKE_URL = "https://api2.heygen.com/v1/oauth/revoke";
|
|
|
|
function authorizeEndpoint(): string {
|
|
return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL;
|
|
}
|
|
function tokenEndpoint(): string {
|
|
return process.env["HYPERFRAMES_OAUTH_TOKEN_URL"] || DEFAULT_TOKEN_URL;
|
|
}
|
|
function revokeEndpoint(): string {
|
|
return process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL;
|
|
}
|
|
|
|
export interface AuthorizeFlowOptions {
|
|
/** Override scopes (default `openid profile email`). */
|
|
scope?: string;
|
|
/** Inject a custom fetch (used by tests). */
|
|
fetchImpl?: typeof fetch;
|
|
/** Override timeout in ms (default 120s). */
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface AuthorizeFlowResult {
|
|
tokens: OAuthTokens;
|
|
/** Returned identity info for friendly post-login UX. */
|
|
userInfo?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface RefreshOptions {
|
|
fetchImpl?: typeof fetch;
|
|
}
|
|
|
|
/** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */
|
|
export function resolveClientId(): string {
|
|
const override = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"];
|
|
const id = override && override.length > 0 ? override : DEFAULT_CLIENT_ID;
|
|
if (!id || id.length === 0) throw ErrOAuthNotConfigured();
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* For command-entry points: throw the standard `ErrOAuthNotConfigured`
|
|
* via `resolveClientId()`; on a real misconfig, print a friendly hint
|
|
* and exit with status 1 rather than dumping a stack. Throws non-auth
|
|
* errors so callers can surface programmer bugs.
|
|
*/
|
|
export function assertOAuthConfiguredOrExit(): void {
|
|
try {
|
|
resolveClientId();
|
|
} catch (err) {
|
|
if (isAuthError(err) && err.code === "OAUTH_NOT_CONFIGURED") {
|
|
console.error(`Error: ${err.message}`);
|
|
if (err.hint) console.error(err.hint);
|
|
process.exit(1);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function startAuthorizationCodeFlow(
|
|
opts: AuthorizeFlowOptions = {},
|
|
): Promise<AuthorizeFlowResult> {
|
|
const clientId = resolveClientId();
|
|
const scope = opts.scope ?? DEFAULT_SCOPES;
|
|
const pkce = generatePkcePair();
|
|
const state = generateState();
|
|
|
|
const loopback = await startLoopback({ state, timeoutMs: opts.timeoutMs });
|
|
const authorizeUrl = buildAuthorizeUrl({
|
|
clientId,
|
|
redirectUri: loopback.redirectUri,
|
|
scope,
|
|
state,
|
|
challenge: pkce.challenge,
|
|
});
|
|
|
|
// Print the host+path only (no state / code_challenge) so live
|
|
// values can't leak into scrollback / CI logs during the 120s window.
|
|
console.log(`Opening browser to ${c.dim(authorizeHost(authorizeUrl))} ...`);
|
|
const { opened } = await openBrowser(authorizeUrl);
|
|
if (!opened) {
|
|
// openBrowser already printed the manual URL; surface it as the
|
|
// last on-screen instruction so it isn't buried above "Waiting…".
|
|
console.log(c.dim("(open the URL above to continue)"));
|
|
}
|
|
console.log(`Waiting for callback on ${c.accent(loopback.redirectUri)} ...`);
|
|
|
|
let codeResult;
|
|
try {
|
|
codeResult = await loopback.result;
|
|
} catch (err) {
|
|
await loopback.close().catch(() => {});
|
|
throw err;
|
|
}
|
|
|
|
const tokens = await exchangeCodeForTokens({
|
|
clientId,
|
|
code: codeResult.code,
|
|
redirectUri: codeResult.redirectUri,
|
|
verifier: pkce.verifier,
|
|
fetchImpl: opts.fetchImpl,
|
|
});
|
|
|
|
// Fresh login → clean OAuth block (no inherited refresh_token).
|
|
await persistOAuth(tokens, { preserveMissing: false });
|
|
return { tokens };
|
|
}
|
|
|
|
export async function refreshTokens(
|
|
refresh_token: string,
|
|
opts: RefreshOptions = {},
|
|
): Promise<OAuthTokens> {
|
|
const clientId = resolveClientId();
|
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
const body = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token,
|
|
client_id: clientId,
|
|
});
|
|
|
|
const res = await fetchImpl(tokenEndpoint(), {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/x-www-form-urlencoded",
|
|
accept: "application/json",
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
|
|
if (res.status === 400 || res.status === 401) {
|
|
throw ErrRefreshFailed(await safeText(res));
|
|
}
|
|
if (!res.ok) {
|
|
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
|
|
}
|
|
|
|
const payload = await readJsonOrThrow(res);
|
|
const tokens = parseTokenResponse(payload);
|
|
// Refresh grant → preserve a refresh_token the server didn't rotate.
|
|
await persistOAuth(tokens, { preserveMissing: true });
|
|
return tokens;
|
|
}
|
|
|
|
export interface RevokeOptions extends RefreshOptions {
|
|
/** Hint to the server about which token we're sending (RFC 7009). */
|
|
token_type_hint?: "access_token" | "refresh_token";
|
|
/** Abort the revoke after this many ms (default 5s). */
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
/**
|
|
* RFC 7009 revoke. Best-effort: never throws. A hung IdP or unset
|
|
* client_id MUST NOT block local logout — both are caught here so the
|
|
* caller can wipe local state immediately afterward regardless.
|
|
*/
|
|
export async function revokeTokens(token: string, opts: RevokeOptions = {}): Promise<void> {
|
|
let clientId: string;
|
|
try {
|
|
clientId = resolveClientId();
|
|
} catch {
|
|
// OAuth not configured — nothing to revoke server-side.
|
|
return;
|
|
}
|
|
|
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
const params: Record<string, string> = { token, client_id: clientId };
|
|
if (opts.token_type_hint) params["token_type_hint"] = opts.token_type_hint;
|
|
const body = new URLSearchParams(params);
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? REVOKE_TIMEOUT_MS);
|
|
try {
|
|
const res = await fetchImpl(revokeEndpoint(), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: body.toString(),
|
|
signal: controller.signal,
|
|
});
|
|
if (!res.ok) {
|
|
console.error(
|
|
c.dim(`Note: token revoke returned HTTP ${res.status}; local credentials cleared anyway.`),
|
|
);
|
|
}
|
|
} catch {
|
|
/* timeout or network error — silent, this is best-effort */
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function authorizeHost(fullUrl: string): string {
|
|
try {
|
|
const u = new URL(fullUrl);
|
|
return `${u.origin}${u.pathname}`;
|
|
} catch {
|
|
return fullUrl.split("?")[0] ?? fullUrl;
|
|
}
|
|
}
|
|
|
|
function buildAuthorizeUrl(args: {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
scope: string;
|
|
state: string;
|
|
challenge: string;
|
|
}): string {
|
|
const params = new URLSearchParams({
|
|
response_type: "code",
|
|
client_id: args.clientId,
|
|
redirect_uri: args.redirectUri,
|
|
scope: args.scope,
|
|
state: args.state,
|
|
code_challenge: args.challenge,
|
|
code_challenge_method: "S256",
|
|
});
|
|
return `${authorizeEndpoint()}?${params.toString()}`;
|
|
}
|
|
|
|
async function exchangeCodeForTokens(args: {
|
|
clientId: string;
|
|
code: string;
|
|
redirectUri: string;
|
|
verifier: string;
|
|
fetchImpl?: typeof fetch;
|
|
}): Promise<OAuthTokens> {
|
|
const fetchImpl = args.fetchImpl ?? fetch;
|
|
const body = new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
code: args.code,
|
|
redirect_uri: args.redirectUri,
|
|
client_id: args.clientId,
|
|
code_verifier: args.verifier,
|
|
});
|
|
const res = await fetchImpl(tokenEndpoint(), {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/x-www-form-urlencoded",
|
|
accept: "application/json",
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
if (res.status === 400 || res.status === 401) {
|
|
// The authorization code is single-use and short-lived. A 400/401
|
|
// here almost always means it expired during the loopback wait or
|
|
// was already redeemed — surface an actionable message instead of
|
|
// a bare "HeyGen API error (400)".
|
|
const detail = (await safeText(res)) || res.statusText;
|
|
throw ErrRefreshFailed(
|
|
`authorization code rejected (${detail}); please run \`auth login\` again`,
|
|
);
|
|
}
|
|
if (!res.ok) {
|
|
throw ErrApi(res.status, (await safeText(res)) || res.statusText);
|
|
}
|
|
return parseTokenResponse(await readJsonOrThrow(res));
|
|
}
|
|
|
|
/**
|
|
* Parse the RFC 6749 token response. Backend may also include
|
|
* `id_token` (OIDC) but we ignore that today.
|
|
*
|
|
* Throws `ErrInvalidTokenResponse` (an AuthError carrying
|
|
* REFRESH_FAILED code, so callers using `tryRefresh` consistently
|
|
* route the user to "log in again" instead of a generic API error)
|
|
* on shape failures.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
export function parseTokenResponse(payload: unknown): OAuthTokens {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
throw ErrRefreshFailed("token endpoint returned a non-object payload");
|
|
}
|
|
const obj = payload as Record<string, unknown>;
|
|
const accessToken = stringField(obj, "access_token");
|
|
if (!accessToken) {
|
|
throw ErrRefreshFailed("token endpoint did not return an access_token");
|
|
}
|
|
if (!isHeaderSafe(accessToken)) {
|
|
throw ErrRefreshFailed("access_token contains control characters");
|
|
}
|
|
|
|
const out: OAuthTokens = { access_token: accessToken };
|
|
const refreshToken = stringField(obj, "refresh_token");
|
|
if (refreshToken) {
|
|
if (!isHeaderSafe(refreshToken)) {
|
|
throw ErrRefreshFailed("refresh_token contains control characters");
|
|
}
|
|
out.refresh_token = refreshToken;
|
|
}
|
|
const tokenType = stringField(obj, "token_type");
|
|
if (tokenType) out.token_type = tokenType;
|
|
const scope = stringField(obj, "scope");
|
|
if (scope) out.scope = scope;
|
|
|
|
const expiresIn = numericField(obj, "expires_in");
|
|
if (expiresIn !== undefined) {
|
|
// Clamp to a sensible minimum so a misbehaving / clock-skewed
|
|
// server returning 0 or a negative value doesn't put expires_at in
|
|
// the past and cause the 401-refresh path to loop.
|
|
const clamped = Math.max(expiresIn, MIN_EXPIRES_IN_SECONDS);
|
|
out.expires_at = new Date(Date.now() + clamped * 1000).toISOString();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string): string | undefined {
|
|
const v = obj[key];
|
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
}
|
|
|
|
function numericField(obj: Record<string, unknown>, key: string): number | undefined {
|
|
const v = obj[key];
|
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
if (typeof v === "string") {
|
|
const n = Number.parseFloat(v);
|
|
return Number.isFinite(n) ? n : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Persist a new OAuth token set, always preserving a co-located
|
|
* `api_key`.
|
|
*
|
|
* `preserveMissing` controls how the new tokens combine with whatever
|
|
* OAuth block is already on disk:
|
|
* - `false` (fresh authorization-code login): overwrite the OAuth
|
|
* block entirely. A new interactive login is a clean session — it
|
|
* must NOT inherit the previous session's refresh_token, or a
|
|
* response that omits one would pair a new access token with a
|
|
* stale refresh token and break/misroute the next refresh.
|
|
* - `true` (refresh grant): keep the prior refresh_token / scope /
|
|
* token_type when the response omits them. RFC 6749 §6 lets the
|
|
* token endpoint skip refresh_token on a no-rotation refresh, and
|
|
* dropping it would brick future refreshes.
|
|
*/
|
|
async function persistOAuth(
|
|
tokens: OAuthTokens,
|
|
opts: { preserveMissing: boolean },
|
|
): Promise<void> {
|
|
let existing: Credentials = {};
|
|
try {
|
|
const { credentials } = await readStore();
|
|
existing = credentials;
|
|
} catch {
|
|
// Treat unreadable existing file as empty — we're about to
|
|
// overwrite the OAuth block anyway.
|
|
existing = {};
|
|
}
|
|
|
|
const oauth: OAuthTokens = opts.preserveMissing
|
|
? { ...existing.oauth, ...tokens }
|
|
: { ...tokens };
|
|
// Start from the existing record so co-located data survives: the
|
|
// api_key, the friendly-display `user` block, AND any unknown/foreign
|
|
// keys another CLI wrote (carried on a hidden symbol slot by spread).
|
|
// Only the `oauth` block is overwritten here.
|
|
await writeStore({ ...existing, oauth });
|
|
}
|
|
|
|
async function readJsonOrThrow(res: Response): Promise<unknown> {
|
|
try {
|
|
return await res.json();
|
|
} catch (err) {
|
|
throw ErrApi(res.status, `non-JSON body: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async function safeText(res: Response): Promise<string> {
|
|
try {
|
|
// Token/revoke requests carry refresh_token, authorization code, and
|
|
// code_verifier in the form body; a server/proxy error page can echo
|
|
// request data back. Scrub before the text reaches any error message.
|
|
return scrubCredentials((await res.text()).slice(0, 500));
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|