mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(cli): add hyperframes auth OAuth (PKCE + loopback + refresh) (#1084)
## What Adds OAuth 2.0 + PKCE login as the default for `hyperframes auth login`, plus refresh-token + 401 auto-retry + `auth refresh`. Stacks on top of PR #1081 (the API-key + shared store work). - `hyperframes auth login` (no flags) — opens the user's browser to `/v1/oauth/authorize`, captures the code on an ephemeral `127.0.0.1:<port>/oauth/callback`, exchanges it for tokens with PKCE S256, and persists. `--api-key` opts back into the legacy long-lived-key path from PR #1081. - `hyperframes auth refresh` — force-refresh the OAuth access token using the stored refresh_token. Mostly useful for testing the path. - `hyperframes auth logout` — best-effort revokes via `POST /v1/oauth/revoke` (RFC 7009) before wiping local state. - `AuthClient` now refreshes-and-retries once on a 401 when the caller wires `onUnauthenticatedRefresh`. `auth status` wires it. Internals added in `packages/cli/src/auth/`: - `pkce.ts` — RFC 7636 code_verifier + S256 code_challenge. - `loopback.ts` — ephemeral 127.0.0.1 HTTP server; state validation, 120s timeout, styled success/error page. - `browser.ts` — wraps `open` with a `BROWSER=none` / `HF_NO_BROWSER=1` fallback that prints the URL. - `oauth.ts` — `startAuthorizationCodeFlow`, `refreshTokens`, `revokeTokens`, `requireOAuthConfigured`, `parseTokenResponse`. ## Why This is the foundation OAuth flow that lets free-tier users authenticate without managing a long-lived key. Refresh + auto-retry means CLI commands keep working past the access_token lifetime without bugging the user. The OAuth client_id (`q2A2QRSke2LrFTPJhoDbHtXh`) is the one James created in the `oauth2_client` table. Baked in as a build-time default; override via `HYPERFRAMES_OAUTH_CLIENT_ID` for dev/test. ## How - Public client: PKCE only, no `client_secret`. Backend already requires PKCE (`movio/logic/oauth2.py:638`). - Loopback port is ephemeral (`server.listen(0)`) — the backend wildcards localhost ports for public clients (`movio/model/oauth2.py:check_redirect_uri`), so the registered redirect URI's port is a placeholder. - State parameter is generated per-flow + validated on callback to prevent CSRF. - Token-response parsing is permissive on `expires_in` type (some servers return it as a string) but strict on `access_token` presence. - 401 retry happens at the `AuthClient.fetchUser` layer, not the command layer — so future endpoints inherit it for free. - `persistOAuth` merges into the existing store (preserves co-located `api_key`). `auth login` (API-key path) does the symmetric thing. ## Test plan - [x] 80 unit tests, all green. `vitest run src/auth/`. - [x] PKCE: verifier within 43-128 chars, challenge = SHA-256, S256 method, distinct outputs each call. - [x] Loopback: state mismatch / IdP error / missing-code / timeout / 404 non-callback paths all rejected; success path captures `code`. - [x] OAuth: `refreshTokens` posts correct body, persists, throws `REFRESH_FAILED` on 400/401 and `API_ERROR` on 5xx. Existing api_key preserved on refresh. - [x] AuthClient: 401 retries with refreshed bearer on OAuth, does NOT retry for api_key, returns 401 if refresh hook fails. - [x] `bunx oxlint` / `bunx oxfmt --check` / `bunx tsc` clean. - [x] `bunx fallow audit --base origin/main --fail-on-issues` — only inherited `help.ts:showUsage` finding (from main, not this PR). - [ ] Smoke test against dev API: `HEYGEN_API_URL=https://api.dev.heygen.com hyperframes auth login` then `hyperframes auth status` then `hyperframes auth refresh`. ## Out of scope - Cloud render commands — separate plan. - PR 4 (heygen-cli read-side JSON support) — independent, ships after.
This commit is contained in:
@@ -16,9 +16,11 @@ import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Sign in via browser (OAuth)", "hyperframes auth login"],
|
||||
["Save an API key (interactive)", "hyperframes auth login --api-key"],
|
||||
["Save an API key from stdin", "echo $HEYGEN_API_KEY | hyperframes auth login --api-key"],
|
||||
["Check who you're signed in as", "hyperframes auth status"],
|
||||
["Force-refresh the OAuth access token", "hyperframes auth refresh"],
|
||||
["Sign out", "hyperframes auth logout"],
|
||||
];
|
||||
|
||||
@@ -29,15 +31,17 @@ Manage HeyGen credentials. Credentials live in
|
||||
${c.accent("~/.heygen/credentials")} and are shared with heygen-cli.
|
||||
|
||||
${c.bold("SUBCOMMANDS:")}
|
||||
${c.accent("login")} ${c.dim("Save a HeyGen API key (--api-key). OAuth login lands in a follow-up.")}
|
||||
${c.accent("login")} ${c.dim("Sign in via browser (default) or --api-key for a long-lived key.")}
|
||||
${c.accent("status")} ${c.dim("Show the active credential's source, type, and identity.")}
|
||||
${c.accent("refresh")} ${c.dim("Force-refresh the OAuth access token.")}
|
||||
${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("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
|
||||
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
|
||||
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen).
|
||||
${c.accent("HEYGEN_API_KEY")} Override the stored credential.
|
||||
${c.accent("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
|
||||
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
|
||||
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen).
|
||||
${c.accent("HYPERFRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test).
|
||||
`;
|
||||
|
||||
export default defineCommand({
|
||||
@@ -46,6 +50,7 @@ export default defineCommand({
|
||||
login: () => import("./auth/login.js").then((m) => m.default),
|
||||
status: () => import("./auth/status.js").then((m) => m.default),
|
||||
logout: () => import("./auth/logout.js").then((m) => m.default),
|
||||
refresh: () => import("./auth/refresh.js").then((m) => m.default),
|
||||
},
|
||||
async run({ args }) {
|
||||
if (!args._?.[0]) console.log(HELP);
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
/**
|
||||
* `hyperframes auth login` — write a HeyGen credential to
|
||||
* `~/.heygen/credentials`.
|
||||
* `hyperframes auth login` — sign in to HeyGen.
|
||||
*
|
||||
* This first cut ships the `--api-key` path only. Running `auth login`
|
||||
* without `--api-key` prints a pointer at the OAuth PKCE work that
|
||||
* lands in a follow-up.
|
||||
* Default: OAuth 2.0 + PKCE via a loopback callback. The CLI opens
|
||||
* the user's browser, captures the authorization code on an
|
||||
* ephemeral 127.0.0.1 port, exchanges it for tokens, and persists
|
||||
* them to `~/.heygen/credentials`.
|
||||
*
|
||||
* Inputs:
|
||||
* - `--api-key=<value>` — take the value inline (note: may leak into
|
||||
* shell history).
|
||||
* - `--api-key` with stdin piped — read one line from stdin.
|
||||
* - `--api-key` interactive — `@clack/prompts` password input.
|
||||
* `--api-key`: opts into the legacy long-lived API-key path.
|
||||
*
|
||||
* Write semantics:
|
||||
* - Read the existing credential file first; preserve any `oauth`
|
||||
* block so saving a new API key doesn't wipe an OAuth session.
|
||||
* - Snapshot existing credentials first; merge so a new OAuth session
|
||||
* preserves an existing API key (and vice versa).
|
||||
* - Sanity-check that the input is non-empty and header-safe (no
|
||||
* CR/LF) before touching disk. The backend's `/v3/users/me` is
|
||||
* the source of truth for whether the key is actually valid —
|
||||
* we do NOT shape-check the prefix (real keys come in multiple
|
||||
* formats: `sk_V2_…`, `hg_…`, partner keys, etc.).
|
||||
* CR/LF) before touching disk. The backend's `/v3/users/me` is the
|
||||
* source of truth for whether the key is actually valid — we do
|
||||
* NOT shape-check the prefix (real keys come in multiple formats:
|
||||
* `sk_V2_…`, `hg_…`, partner keys, etc.).
|
||||
* - Verify via `GET /v3/users/me`. On 401, roll back to the previous
|
||||
* state — leaving a confirmed-invalid key on disk would silently
|
||||
* break subsequent commands. On other errors (network blip, 5xx)
|
||||
* keep the new key so retries don't require re-typing.
|
||||
* state. Network/5xx errors keep the new credential in place per
|
||||
* the transient-blip rationale.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { stdin as input } from "node:process";
|
||||
import {
|
||||
AuthClient,
|
||||
assertOAuthConfiguredOrExit,
|
||||
deleteStore,
|
||||
isAuthError,
|
||||
isHeaderSafe,
|
||||
readStore,
|
||||
refreshTokens,
|
||||
startAuthorizationCodeFlow,
|
||||
tryResolveCredential,
|
||||
writeStore,
|
||||
type Credentials,
|
||||
} from "../../auth/index.js";
|
||||
@@ -49,59 +48,95 @@ const MIN_KEY_LENGTH = 8;
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "login",
|
||||
description: "Sign in to HeyGen by saving an API key (OAuth coming soon)",
|
||||
description: "Sign in to HeyGen (OAuth by default; --api-key for long-lived keys)",
|
||||
},
|
||||
args: {
|
||||
"api-key": {
|
||||
type: "string",
|
||||
description:
|
||||
"API key value. Pass `--api-key` with no value to read from stdin or interactively.",
|
||||
description: "API key value, or pass `--api-key` with no value to read from stdin / prompt.",
|
||||
},
|
||||
},
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
const inlineKey = args["api-key"];
|
||||
if (inlineKey === undefined) {
|
||||
printOAuthPlaceholder();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const key = await collectApiKey(inlineKey);
|
||||
if (!key) {
|
||||
console.error(c.error("No API key provided."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!isHeaderSafe(key)) {
|
||||
// CR/LF in the value would smuggle headers when the key is sent
|
||||
// via `x-api-key`. The backend handles "wrong key" itself, but
|
||||
// header-injection has to be caught here.
|
||||
console.error(c.error("API key must not contain newline or control characters."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (key.length < MIN_KEY_LENGTH) {
|
||||
console.error(c.error(`API key looks too short (got ${key.length} chars).`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const previous = await snapshotStore();
|
||||
const next: Credentials = { ...previous, api_key: key };
|
||||
await writeStore(next);
|
||||
|
||||
const verifyOk = await verifyAndReport(key);
|
||||
if (!verifyOk) {
|
||||
await rollback(previous);
|
||||
process.exit(1);
|
||||
if (inlineKey !== undefined) {
|
||||
await runApiKeyLogin(inlineKey);
|
||||
return;
|
||||
}
|
||||
await runOAuthLogin();
|
||||
},
|
||||
});
|
||||
|
||||
function printOAuthPlaceholder(): void {
|
||||
console.error(
|
||||
`${c.warn("Browser-based login isn't ready yet.")} ` +
|
||||
`Re-run with ${c.accent("--api-key")} to save an API key, ` +
|
||||
`or pipe one in:\n` +
|
||||
` ${c.accent("echo $HEYGEN_API_KEY | hyperframes auth login --api-key")}`,
|
||||
);
|
||||
// fallow-ignore-next-line complexity
|
||||
async function runOAuthLogin(): Promise<void> {
|
||||
assertOAuthConfiguredOrExit();
|
||||
|
||||
try {
|
||||
await startAuthorizationCodeFlow();
|
||||
} catch (err) {
|
||||
console.error(c.error(`Sign-in failed: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await reportIdentity();
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function reportIdentity(): Promise<void> {
|
||||
const credential = await tryResolveCredential();
|
||||
if (!credential) {
|
||||
console.error(c.warn("Sign-in completed but no credential was persisted."));
|
||||
process.exit(1);
|
||||
}
|
||||
// Wire the refresh hook here too — a freshly-minted token shouldn't
|
||||
// need it, but a fast IdP-side rotation (or a misconfigured short
|
||||
// TTL) shouldn't punish the user with a hard failure when the
|
||||
// refresh_token would have transparently fixed it.
|
||||
const client = new AuthClient({
|
||||
onUnauthenticatedRefresh: async (rt) => await refreshTokens(rt),
|
||||
});
|
||||
try {
|
||||
const user = await client.getCurrentUser(credential);
|
||||
const identity = user.email ?? user.username ?? "(unknown user)";
|
||||
console.log(c.success(`✓ Signed in as ${identity}.`));
|
||||
} catch (err) {
|
||||
// Don't roll back — the OAuth tokens are valid on disk; this is a
|
||||
// transient verify-side issue. Surface as a warning so the user
|
||||
// can re-check with `auth status` rather than re-running login.
|
||||
console.error(
|
||||
c.warn(`Signed in. Identity check failed (transient): ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function runApiKeyLogin(inlineKey: string): Promise<void> {
|
||||
const key = await collectApiKey(inlineKey);
|
||||
if (!key) {
|
||||
console.error(c.error("No API key provided."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!isHeaderSafe(key)) {
|
||||
// CR/LF in the value would smuggle headers when the key is sent
|
||||
// via `x-api-key`. The backend handles "wrong key" itself, but
|
||||
// header-injection has to be caught here.
|
||||
console.error(c.error("API key must not contain newline or control characters."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (key.length < MIN_KEY_LENGTH) {
|
||||
console.error(c.error(`API key looks too short (got ${key.length} chars).`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const previous = await snapshotStore();
|
||||
const next: Credentials = { ...previous, api_key: key };
|
||||
await writeStore(next);
|
||||
|
||||
const verifyOk = await verifyAndReport(key);
|
||||
if (!verifyOk) {
|
||||
await rollback(previous);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotStore(): Promise<Credentials> {
|
||||
@@ -109,8 +144,6 @@ async function snapshotStore(): Promise<Credentials> {
|
||||
const { credentials } = await readStore();
|
||||
return { ...credentials };
|
||||
} catch {
|
||||
// Existing file is unreadable; treat as empty so the new key still
|
||||
// lands cleanly. The previous bytes are lost either way.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -132,11 +165,6 @@ async function rollback(previous: Credentials): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` on successful verify, `false` on a 401. Other errors
|
||||
* (network blip, 5xx) bubble out — the caller leaves the new key in
|
||||
* place since the issue is transient.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
async function verifyAndReport(key: string): Promise<boolean> {
|
||||
const client = new AuthClient();
|
||||
@@ -158,12 +186,6 @@ async function verifyAndReport(key: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Citty's arg type for `--api-key` is `string`, so:
|
||||
* - `--api-key=hg_x` → `"hg_x"`
|
||||
* - `--api-key ""` / `--api-key` with no value → `""` → fall through
|
||||
* to stdin/prompt.
|
||||
*/
|
||||
async function collectApiKey(inline: string): Promise<string> {
|
||||
if (inline.length > 0) return inline.trim();
|
||||
if (!input.isTTY) {
|
||||
@@ -172,11 +194,6 @@ async function collectApiKey(inline: string): Promise<string> {
|
||||
return await promptForKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all of stdin, or bail with an empty string after `timeoutMs`.
|
||||
* Hanging forever when stdin is non-TTY but unattached (Docker `-d`,
|
||||
* some CI shells) is worse than a clear timeout.
|
||||
*/
|
||||
async function readAllWithTimeout(
|
||||
stream: NodeJS.ReadableStream,
|
||||
timeoutMs: number,
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { clearOAuth, configDir, credentialPath, deleteStore } from "../../auth/index.js";
|
||||
import {
|
||||
clearOAuth,
|
||||
configDir,
|
||||
credentialPath,
|
||||
deleteStore,
|
||||
readStore,
|
||||
revokeTokens,
|
||||
} from "../../auth/index.js";
|
||||
import { c } from "../../ui/colors.js";
|
||||
|
||||
export default defineCommand({
|
||||
@@ -34,6 +41,10 @@ export default defineCommand({
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Best-effort revoke before we wipe local state. RFC 7009 says
|
||||
// success is empty 200 — we ignore failure either way.
|
||||
await bestEffortRevoke();
|
||||
|
||||
if (keepApiKey) {
|
||||
await clearOAuth();
|
||||
console.log(c.success("✓ OAuth session removed. API key retained."));
|
||||
@@ -62,6 +73,26 @@ async function ensureConfirmed(yes: boolean, keepApiKey: boolean): Promise<boole
|
||||
return confirmInteractive(prompt);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function bestEffortRevoke(): Promise<void> {
|
||||
try {
|
||||
const { credentials, source } = await readStore();
|
||||
if (source === "absent" || !credentials.oauth) return;
|
||||
const { access_token, refresh_token } = credentials.oauth;
|
||||
// Revoke the refresh_token first (per RFC 7009, that typically
|
||||
// invalidates all derived access tokens), but also revoke the
|
||||
// access_token explicitly to cover servers that don't cascade.
|
||||
if (refresh_token) {
|
||||
await revokeTokens(refresh_token, { token_type_hint: "refresh_token" });
|
||||
}
|
||||
if (access_token) {
|
||||
await revokeTokens(access_token, { token_type_hint: "access_token" });
|
||||
}
|
||||
} catch {
|
||||
/* Best-effort — never block local wipe on a network/IdP issue. */
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInteractive(prompt: string): Promise<boolean> {
|
||||
if (!process.stdin.isTTY) return false;
|
||||
const { createInterface } = await import("node:readline");
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* `hyperframes auth refresh` — force-refresh the OAuth access_token
|
||||
* using the stored refresh_token.
|
||||
*
|
||||
* Mostly useful for testing the refresh path or for users on flaky
|
||||
* networks who want to pre-emptively refresh before a long render
|
||||
* job. Status's 401-retry path already does this automatically.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import {
|
||||
assertOAuthConfiguredOrExit,
|
||||
isAuthError,
|
||||
readStore,
|
||||
refreshTokens,
|
||||
} from "../../auth/index.js";
|
||||
import { c } from "../../ui/colors.js";
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "refresh", description: "Force-refresh the OAuth access token" },
|
||||
args: {},
|
||||
// fallow-ignore-next-line complexity
|
||||
async run() {
|
||||
assertOAuthConfiguredOrExit();
|
||||
|
||||
const { credentials, source } = await readStore();
|
||||
if (source === "absent" || !credentials.oauth?.refresh_token) {
|
||||
console.error(c.warn("No OAuth refresh token to use. Run `hyperframes auth login` first."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
// refreshTokens persists via oauth.ts:persistOAuth, which merges
|
||||
// into a freshly-read store (preserving api_key + any
|
||||
// refresh_token the server didn't rotate). Re-writing here would
|
||||
// use a stale snapshot and risks clobbering concurrent writes.
|
||||
await refreshTokens(credentials.oauth.refresh_token);
|
||||
console.log(c.success("✓ Refreshed OAuth access token."));
|
||||
} catch (err) {
|
||||
if (isAuthError(err) && err.code === "REFRESH_FAILED") {
|
||||
console.error(c.error(err.message));
|
||||
if (err.hint) console.error(c.dim(err.hint));
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { defineCommand } from "citty";
|
||||
import {
|
||||
AuthClient,
|
||||
isAuthError,
|
||||
refreshTokens,
|
||||
tryResolveCredential,
|
||||
type ResolvedCredential,
|
||||
type UserInfo,
|
||||
@@ -76,7 +77,12 @@ function handleResolveError(err: unknown, asJson: boolean): never {
|
||||
}
|
||||
|
||||
async function verify(credential: ResolvedCredential): Promise<VerifiedStatus> {
|
||||
const client = new AuthClient();
|
||||
const client = new AuthClient({
|
||||
// Return the full new token set so the retry's credential carries
|
||||
// a rotated refresh_token forward (defends against IdPs that
|
||||
// invalidate the old RT on every refresh).
|
||||
onUnauthenticatedRefresh: async (rt) => await refreshTokens(rt),
|
||||
});
|
||||
try {
|
||||
const user = await client.getCurrentUser(credential);
|
||||
return { credential, user, apiError: null };
|
||||
|
||||
Reference in New Issue
Block a user