mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
fix(cli): address code-review findings on OAuth PR
This commit is contained in:
@@ -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