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:
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Shared fixtures for auth-module tests. Centralises the env-snapshot
|
||||
* + tmp-config-dir pattern so resolver.test.ts and oauth.test.ts don't
|
||||
* each maintain a copy of the same beforeEach/afterEach plumbing.
|
||||
*
|
||||
* Only loaded by `*.test.ts` — runtime code doesn't depend on it.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"HEYGEN_API_KEY",
|
||||
"HYPERFRAMES_API_KEY",
|
||||
"HEYGEN_CONFIG_DIR",
|
||||
"HEYGEN_API_URL",
|
||||
"HYPERFRAMES_OAUTH_CLIENT_ID",
|
||||
] as const;
|
||||
|
||||
type EnvKey = (typeof ENV_KEYS)[number];
|
||||
|
||||
export interface EnvFixture {
|
||||
/** Tmp config dir; deleted on `restore()`. */
|
||||
dir: string;
|
||||
/** Restore env + delete tmp dir. Idempotent. */
|
||||
restore: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a snapshot of the auth-related env, clear them, make a tmp
|
||||
* `HEYGEN_CONFIG_DIR`, and return a `restore()` that undoes all of
|
||||
* the above.
|
||||
*/
|
||||
export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFixture> {
|
||||
const dir = await fs.mkdtemp(join(tmpdir(), prefix));
|
||||
const saved: Partial<Record<EnvKey, string | undefined>> = {};
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env["HEYGEN_CONFIG_DIR"] = dir;
|
||||
|
||||
const restore = async (): Promise<void> => {
|
||||
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 });
|
||||
};
|
||||
|
||||
return { dir, restore };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Open a URL in the user's default browser. Falls back to printing the
|
||||
* URL when no browser is openable (SSH session, CI, `BROWSER=none`,
|
||||
* or `open` rejects).
|
||||
*/
|
||||
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
export interface OpenBrowserResult {
|
||||
/** True when we successfully invoked the platform "open" command. */
|
||||
opened: boolean;
|
||||
}
|
||||
|
||||
export async function openBrowser(url: string): Promise<OpenBrowserResult> {
|
||||
if (process.env["BROWSER"] === "none" || process.env["HF_NO_BROWSER"] === "1") {
|
||||
printManualInstructions(url);
|
||||
return { opened: false };
|
||||
}
|
||||
try {
|
||||
const open = (await import("open")).default;
|
||||
await open(url);
|
||||
return { opened: true };
|
||||
} catch (err) {
|
||||
printManualInstructions(url, err instanceof Error ? err.message : String(err));
|
||||
return { opened: false };
|
||||
}
|
||||
}
|
||||
|
||||
function printManualInstructions(url: string, detail?: string): void {
|
||||
if (detail) {
|
||||
console.error(c.warn(`Could not open browser automatically (${detail}).`));
|
||||
} else {
|
||||
console.error(c.warn("Browser auto-open is disabled."));
|
||||
}
|
||||
console.error(`Open this URL manually to continue:\n ${c.accent(url)}`);
|
||||
}
|
||||
@@ -159,6 +159,115 @@ describe("auth/client", () => {
|
||||
throw new Error("expected rejection");
|
||||
});
|
||||
|
||||
it("getCurrentUser retries once on 401 when refresh hook is configured for OAuth", async () => {
|
||||
let callCount = 0;
|
||||
const observed: string[] = [];
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
callCount++;
|
||||
const headers = (init?.headers as Record<string, string>) ?? {};
|
||||
observed.push(headers["authorization"] ?? "");
|
||||
if (callCount === 1) return new Response("expired", { status: 401 });
|
||||
return new Response(JSON.stringify({ email: "a@b" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new AuthClient({
|
||||
baseUrl: "https://api.test.example",
|
||||
fetchImpl,
|
||||
onUnauthenticatedRefresh: async () => ({
|
||||
access_token: "fresh_at",
|
||||
refresh_token: "fresh_rt",
|
||||
}),
|
||||
});
|
||||
const user = await client.getCurrentUser({
|
||||
type: "oauth",
|
||||
access_token: "stale_at",
|
||||
refresh_token: "rt",
|
||||
source: "file_json",
|
||||
refreshable: true,
|
||||
});
|
||||
expect(user.email).toBe("a@b");
|
||||
expect(observed[0]).toBe("Bearer stale_at");
|
||||
expect(observed[1]).toBe("Bearer fresh_at");
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("getCurrentUser hook returns full token set so the retry credential carries any rotated refresh_token", async () => {
|
||||
// The hook contract now returns `OAuthTokens` (access_token plus an
|
||||
// optional rotated refresh_token), not just an access_token string.
|
||||
// For IdPs that rotate refresh_tokens on every exchange, any future
|
||||
// retry on the in-memory credential needs the FRESH rt — the in-
|
||||
// memory credential must be rebuilt from the hook's return, not
|
||||
// re-use the stale rt from the original credential.
|
||||
let receivedRt = "";
|
||||
const fetchImpl = (async () =>
|
||||
new Response("expired", { status: 401 })) as unknown as typeof fetch;
|
||||
const client = new AuthClient({
|
||||
baseUrl: "https://api.test.example",
|
||||
fetchImpl,
|
||||
onUnauthenticatedRefresh: async (rt) => {
|
||||
receivedRt = rt;
|
||||
// Hook MUST be able to return a rotated refresh_token — this
|
||||
// would have been impossible with the old `Promise<string>`
|
||||
// contract. The type checker fails the build if the shape drifts.
|
||||
return { access_token: "fresh_at", refresh_token: "rotated_rt" };
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
client.getCurrentUser({
|
||||
type: "oauth",
|
||||
access_token: "stale_at",
|
||||
refresh_token: "ORIGINAL_rt",
|
||||
source: "file_json",
|
||||
refreshable: true,
|
||||
}),
|
||||
).rejects.toSatisfy((err) => isAuthError(err));
|
||||
expect(receivedRt).toBe("ORIGINAL_rt");
|
||||
});
|
||||
|
||||
it("getCurrentUser does NOT retry on 401 for api_key credentials", async () => {
|
||||
let callCount = 0;
|
||||
const fetchImpl = (async () => {
|
||||
callCount++;
|
||||
return new Response("invalid", { status: 401 });
|
||||
}) as unknown as typeof fetch;
|
||||
const client = new AuthClient({
|
||||
baseUrl: "https://api.test.example",
|
||||
fetchImpl,
|
||||
onUnauthenticatedRefresh: async () => ({ access_token: "fresh" }),
|
||||
});
|
||||
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
|
||||
});
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("getCurrentUser surfaces 401 when refresh hook returns null (refresh failed)", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response("nope", { status: 401 })) as unknown as typeof fetch;
|
||||
const { ErrRefreshFailed } = await import("./errors.js");
|
||||
const client = new AuthClient({
|
||||
baseUrl: "https://api.test.example",
|
||||
fetchImpl,
|
||||
onUnauthenticatedRefresh: async () => {
|
||||
throw ErrRefreshFailed("invalid_grant");
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
client.getCurrentUser({
|
||||
type: "oauth",
|
||||
access_token: "stale",
|
||||
refresh_token: "rt",
|
||||
source: "file_json",
|
||||
refreshable: true,
|
||||
}),
|
||||
).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
|
||||
});
|
||||
});
|
||||
|
||||
it("getCurrentUser sends the right header for oauth credentials", async () => {
|
||||
let captured: Record<string, string> = {};
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
* `movio/api_service/app/controller/user_v3.py`.
|
||||
*/
|
||||
|
||||
import { ErrApi, ErrUnauthenticated } from "./errors.js";
|
||||
import { ErrApi, ErrUnauthenticated, isAuthError } from "./errors.js";
|
||||
import type { ResolvedCredential } from "./resolver.js";
|
||||
import { scrubCredentials } from "./scrub.js";
|
||||
import type { OAuthTokens } from "./store.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.heygen.com";
|
||||
|
||||
@@ -74,27 +76,75 @@ export interface AuthClientOptions {
|
||||
baseUrl?: string;
|
||||
/** Inject a custom fetch (used by tests). */
|
||||
fetchImpl?: typeof fetch;
|
||||
/**
|
||||
* Hook for refreshing an OAuth credential on 401. The hook should
|
||||
* exchange the supplied refresh_token for new tokens, persist them,
|
||||
* and return the FULL new token set — at minimum a fresh
|
||||
* `access_token`, plus a fresh `refresh_token` if the IdP rotated it.
|
||||
* Returning the full set lets the retry's credential carry the new
|
||||
* refresh_token, so any subsequent refresh on the same in-memory
|
||||
* credential doesn't re-use a now-invalidated rotated RT.
|
||||
* Wired in by the auth commands; injectable for tests.
|
||||
*/
|
||||
onUnauthenticatedRefresh?: (refresh_token: string) => Promise<OAuthTokens>;
|
||||
}
|
||||
|
||||
export class AuthClient {
|
||||
private readonly base: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly onRefresh?: (refresh_token: string) => Promise<OAuthTokens>;
|
||||
|
||||
constructor(opts: AuthClientOptions = {}) {
|
||||
this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, "");
|
||||
this.fetchImpl = opts.fetchImpl ?? fetch;
|
||||
this.onRefresh = opts.onUnauthenticatedRefresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi`
|
||||
* on any other non-2xx or non-JSON body.
|
||||
*
|
||||
* On OAuth 401 with a refresh hook configured, the request is
|
||||
* retried once after refreshing the access token. The retry's
|
||||
* outcome is what the caller sees — if the refresh itself fails
|
||||
* (REFRESH_FAILED) or the retry still 401s, the user lands on a
|
||||
* "please log in again" path upstream.
|
||||
*/
|
||||
async getCurrentUser(credential: ResolvedCredential): Promise<UserInfo> {
|
||||
const url = `${this.base}/v3/users/me`;
|
||||
return await this.fetchUser(url, credential, true);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
private async fetchUser(
|
||||
url: string,
|
||||
credential: ResolvedCredential,
|
||||
allowRefresh: boolean,
|
||||
): Promise<UserInfo> {
|
||||
const headers = buildAuthHeaders(credential);
|
||||
const res = await this.fetchImpl(url, { method: "GET", headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
if (
|
||||
allowRefresh &&
|
||||
credential.type === "oauth" &&
|
||||
credential.refresh_token &&
|
||||
this.onRefresh
|
||||
) {
|
||||
const refreshed = await this.tryRefresh(credential.refresh_token);
|
||||
if (refreshed) {
|
||||
// Carry the new refresh_token forward too — for IdPs that
|
||||
// rotate RTs on every refresh, a future retry on this
|
||||
// in-memory credential would otherwise re-send the old
|
||||
// (now-invalidated) one.
|
||||
const next: ResolvedCredential = {
|
||||
...credential,
|
||||
access_token: refreshed.access_token,
|
||||
...(refreshed.refresh_token ? { refresh_token: refreshed.refresh_token } : {}),
|
||||
};
|
||||
return await this.fetchUser(url, next, false);
|
||||
}
|
||||
}
|
||||
const detail = await safeText(res);
|
||||
throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
@@ -110,6 +160,19 @@ export class AuthClient {
|
||||
}
|
||||
return extractUserInfo(payload);
|
||||
}
|
||||
|
||||
private async tryRefresh(refresh_token: string): Promise<OAuthTokens | null> {
|
||||
if (!this.onRefresh) return null;
|
||||
try {
|
||||
return await this.onRefresh(refresh_token);
|
||||
} catch (err) {
|
||||
// Refresh failure should be surfaced upstream by the caller via
|
||||
// the retry's 401, not by throwing here — so callers consistently
|
||||
// see "please log in again" rather than mixed error types.
|
||||
if (isAuthError(err) && err.code === "REFRESH_FAILED") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {
|
||||
@@ -128,25 +191,6 @@ async function safeText(res: Response): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
* can map specific failures to friendly UX without parsing messages.
|
||||
*/
|
||||
|
||||
export type AuthErrorCode = "NOT_CONFIGURED" | "INVALID_STORE" | "API_ERROR" | "UNAUTHENTICATED";
|
||||
export type AuthErrorCode =
|
||||
| "NOT_CONFIGURED"
|
||||
| "INVALID_STORE"
|
||||
| "API_ERROR"
|
||||
| "UNAUTHENTICATED"
|
||||
| "OAUTH_NOT_CONFIGURED"
|
||||
| "REFRESH_FAILED";
|
||||
|
||||
export class AuthError extends Error {
|
||||
readonly code: AuthErrorCode;
|
||||
@@ -41,6 +47,20 @@ export const ErrUnauthenticated = (detail?: string) =>
|
||||
export const ErrApi = (status: number, detail: string) =>
|
||||
new AuthError("API_ERROR", `HeyGen API error (${status}): ${detail}`);
|
||||
|
||||
export const ErrOAuthNotConfigured = () =>
|
||||
new AuthError(
|
||||
"OAUTH_NOT_CONFIGURED",
|
||||
"OAuth client is not configured",
|
||||
"Set HYPERFRAMES_OAUTH_CLIENT_ID, or run `hyperframes auth login --api-key`.",
|
||||
);
|
||||
|
||||
export const ErrRefreshFailed = (detail?: string) =>
|
||||
new AuthError(
|
||||
"REFRESH_FAILED",
|
||||
detail ? `Failed to refresh OAuth tokens: ${detail}` : "Failed to refresh OAuth tokens",
|
||||
"Run `hyperframes auth login` to re-authenticate.",
|
||||
);
|
||||
|
||||
export function isAuthError(err: unknown): err is AuthError {
|
||||
return err instanceof AuthError;
|
||||
}
|
||||
|
||||
@@ -15,3 +15,10 @@ export type { ResolvedCredential } from "./resolver.js";
|
||||
|
||||
export { AuthClient } from "./client.js";
|
||||
export type { UserInfo } from "./client.js";
|
||||
|
||||
export {
|
||||
assertOAuthConfiguredOrExit,
|
||||
refreshTokens,
|
||||
revokeTokens,
|
||||
startAuthorizationCodeFlow,
|
||||
} from "./oauth.js";
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startLoopback, type LoopbackHandle } from "./loopback.js";
|
||||
|
||||
describe("auth/loopback", () => {
|
||||
let active: LoopbackHandle | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (active) {
|
||||
await active.close().catch(() => {});
|
||||
active = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("captures `code` when state matches", async () => {
|
||||
const handle = await startLoopback({ state: "expected_state", timeoutMs: 5_000 });
|
||||
active = handle;
|
||||
const redirect = new URL(handle.redirectUri);
|
||||
const callback = new URL(`${handle.redirectUri}?code=abc123&state=expected_state`);
|
||||
|
||||
const fetchPromise = fetch(callback.toString());
|
||||
const result = await handle.result;
|
||||
const res = await fetchPromise;
|
||||
|
||||
expect(result.code).toBe("abc123");
|
||||
expect(result.redirectUri).toContain(redirect.host);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("Signed in");
|
||||
});
|
||||
|
||||
async function expectRejection(args: {
|
||||
expectedState: string;
|
||||
query: string;
|
||||
pattern: RegExp;
|
||||
}): Promise<void> {
|
||||
const handle = await startLoopback({ state: args.expectedState, timeoutMs: 5_000 });
|
||||
active = handle;
|
||||
await fetch(`${handle.redirectUri}?${args.query}`).catch(() => {});
|
||||
await expect(handle.result).rejects.toThrow(args.pattern);
|
||||
}
|
||||
|
||||
it("rejects when state does not match", async () => {
|
||||
await expectRejection({
|
||||
expectedState: "expected",
|
||||
query: "code=abc&state=wrong",
|
||||
pattern: /state mismatch/i,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when the IdP returns an error", async () => {
|
||||
await expectRejection({
|
||||
expectedState: "s",
|
||||
query: "error=access_denied&error_description=user+denied&state=s",
|
||||
pattern: /access_denied/,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when code is missing from the callback", async () => {
|
||||
await expectRejection({ expectedState: "s", query: "state=s", pattern: /code/ });
|
||||
});
|
||||
|
||||
it("times out when no callback arrives", async () => {
|
||||
const handle = await startLoopback({ state: "s", timeoutMs: 200 });
|
||||
active = handle;
|
||||
await expect(handle.result).rejects.toThrow(/timed out/i);
|
||||
});
|
||||
|
||||
it("404s non-callback paths and does not resolve the flow", async () => {
|
||||
const handle = await startLoopback({ state: "s", timeoutMs: 1_000 });
|
||||
active = handle;
|
||||
const res = await fetch(`${handle.redirectUri.replace("/oauth/callback", "/other")}`);
|
||||
expect(res.status).toBe(404);
|
||||
// Flow is still waiting — kill it via timeout.
|
||||
await expect(handle.result).rejects.toThrow(/timed out/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Loopback HTTP server for the OAuth authorization-code callback.
|
||||
*
|
||||
* The CLI binds a server to `127.0.0.1:0` (ephemeral port), sends the
|
||||
* user's browser to `/v1/oauth/authorize?redirect_uri=…` pointing at
|
||||
* this server, and waits for the redirect carrying `?code=…&state=…`.
|
||||
*
|
||||
* The backend wildcards localhost ports for public clients
|
||||
* (`movio/model/oauth2.py:check_redirect_uri`), so the registered
|
||||
* redirect URI's port is just a placeholder — the actual port the
|
||||
* server lands on is what matters at runtime.
|
||||
*
|
||||
* Times out after 120s. Validates `state` matches the value we
|
||||
* generated. Renders a small "you can close this window" page back
|
||||
* to the browser before shutting down.
|
||||
*/
|
||||
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { AddressInfo } from "node:net";
|
||||
|
||||
export interface LoopbackOptions {
|
||||
/** Expected `state` value — flow fails if it doesn't match. */
|
||||
state: string;
|
||||
/** Timeout in ms (default 120s). */
|
||||
timeoutMs?: number;
|
||||
/** Override port for tests (default 0 = ephemeral). */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface LoopbackResult {
|
||||
/** Authorization code from the IdP. */
|
||||
code: string;
|
||||
/** The full redirect_uri (with port) we listened on. */
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface LoopbackHandle {
|
||||
/** Promise that resolves with the captured code, or rejects on timeout/error. */
|
||||
result: Promise<LoopbackResult>;
|
||||
/** Redirect URI to pass to /v1/oauth/authorize. */
|
||||
redirectUri: string;
|
||||
/** Stop the server early (e.g. user cancels). */
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CALLBACK_PATH = "/oauth/callback";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
export async function startLoopback(opts: LoopbackOptions): Promise<LoopbackHandle> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
let resolveResult!: (value: LoopbackResult) => void;
|
||||
let rejectResult!: (err: Error) => void;
|
||||
const result = new Promise<LoopbackResult>((resolve, reject) => {
|
||||
resolveResult = resolve;
|
||||
rejectResult = reject;
|
||||
});
|
||||
|
||||
// redirectUri is the value the IdP sees on /authorize. RFC 6749 §4.1.3
|
||||
// requires the token exchange's redirect_uri to be byte-identical to
|
||||
// it, so we capture this string once and reuse it on both hops — never
|
||||
// reconstructing from req.socket.localAddress later (which can drift
|
||||
// on dual-stack hosts).
|
||||
let redirectUri = "";
|
||||
|
||||
const server = createServer((req, res) =>
|
||||
handleRequest(req, res, opts.state, redirectUri, resolveResult, rejectResult),
|
||||
);
|
||||
|
||||
await listen(server, opts.port ?? 0);
|
||||
const address = server.address() as AddressInfo;
|
||||
redirectUri = `http://127.0.0.1:${address.port}${CALLBACK_PATH}`;
|
||||
|
||||
let closed = false;
|
||||
const close = async (): Promise<void> => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearTimeout(timer);
|
||||
// `server.close()` only refuses NEW connections — it does NOT
|
||||
// terminate existing keep-alive sockets, which browsers default to
|
||||
// and idle for minutes (Chrome ~5min). Without `closeAllConnections`
|
||||
// the CLI process hangs after "Signed in" until the browser closes
|
||||
// its idle socket. `respond()` also emits `Connection: close` so the
|
||||
// browser doesn't try to keep-alive in the first place.
|
||||
server.closeAllConnections?.();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
rejectResult(new Error(`OAuth callback timed out after ${timeoutMs}ms`));
|
||||
void close();
|
||||
}, timeoutMs);
|
||||
|
||||
// When result settles, drain the timer + shutdown.
|
||||
result.finally(close).catch(() => {});
|
||||
|
||||
return { result, redirectUri, close };
|
||||
}
|
||||
|
||||
async function listen(server: Server, port: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function handleRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
expectedState: string,
|
||||
redirectUri: string,
|
||||
resolveResult: (value: LoopbackResult) => void,
|
||||
rejectResult: (err: Error) => void,
|
||||
): void {
|
||||
// Only GET is part of the OAuth redirect contract. Anything else is
|
||||
// probe-traffic on the ephemeral port; reject without leaking that a
|
||||
// CLI is listening there.
|
||||
if (req.method !== "GET") {
|
||||
res.writeHead(405, { "content-type": "text/plain" }).end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname !== CALLBACK_PATH) {
|
||||
res.writeHead(404, { "content-type": "text/plain" }).end("Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
const params = url.searchParams;
|
||||
const error = params.get("error");
|
||||
if (error) {
|
||||
const desc = params.get("error_description") ?? "";
|
||||
respond(res, 400, errorPage(error, desc));
|
||||
rejectResult(new Error(`OAuth authorize returned error: ${error}${desc ? ` — ${desc}` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const state = params.get("state");
|
||||
if (!state || !stateMatches(state, expectedState)) {
|
||||
respond(res, 400, errorPage("invalid_state", "State parameter did not match."));
|
||||
rejectResult(new Error("OAuth state mismatch — possible CSRF, aborting."));
|
||||
return;
|
||||
}
|
||||
|
||||
const code = params.get("code");
|
||||
if (!code) {
|
||||
respond(
|
||||
res,
|
||||
400,
|
||||
errorPage("missing_code", "Authorization code is missing from the redirect."),
|
||||
);
|
||||
rejectResult(new Error("OAuth redirect did not include `code`."));
|
||||
return;
|
||||
}
|
||||
|
||||
respond(res, 200, successPage());
|
||||
resolveResult({ code, redirectUri });
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time comparison for the OAuth `state` parameter. Real
|
||||
* exploitability is very low (loopback, 256-bit entropy, narrow flow
|
||||
* window), but the rest of the auth path uses crypto-grade primitives
|
||||
* and a `!==` here would be a gratuitous deviation in security review.
|
||||
*/
|
||||
function stateMatches(actual: string, expected: string): boolean {
|
||||
const a = Buffer.from(actual, "utf8");
|
||||
const b = Buffer.from(expected, "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function respond(res: ServerResponse, status: number, body: string): void {
|
||||
res
|
||||
.writeHead(status, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
// Tell the browser not to keep the TCP socket alive — otherwise
|
||||
// `server.close()` blocks on the idle keep-alive timeout
|
||||
// (Chrome ~5min). Combined with `server.closeAllConnections()`
|
||||
// in `close()` this guarantees the CLI exits promptly after the
|
||||
// user sees the success page.
|
||||
connection: "close",
|
||||
})
|
||||
.end(body);
|
||||
}
|
||||
|
||||
function successPage(): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Signed in to HeyGen</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0b0f14;color:#e6e8eb}main{max-width:480px;text-align:center;padding:32px;border-radius:12px;background:#11161d;border:1px solid #1f2630}h1{font-weight:600;margin:0 0 8px;color:#3CE6AC}p{margin:0;color:#9aa3ad}</style>
|
||||
</head><body><main><h1>You're signed in.</h1><p>You can close this tab and return to your terminal.</p></main></body></html>`;
|
||||
}
|
||||
|
||||
function errorPage(code: string, description: string): string {
|
||||
const safeCode = escapeHtml(code);
|
||||
const safeDesc = escapeHtml(description);
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Sign-in failed</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#1a0b0b;color:#e6e8eb}main{max-width:480px;text-align:center;padding:32px;border-radius:12px;background:#1d1111;border:1px solid #301f1f}h1{font-weight:600;margin:0 0 8px;color:#ff7a7a}code{background:#2a1414;padding:2px 6px;border-radius:4px}p{margin:8px 0 0;color:#9aa3ad}</style>
|
||||
</head><body><main><h1>Sign-in failed</h1><p><code>${safeCode}</code></p><p>${safeDesc}</p></main></body></html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setupTempAuthEnv } from "./_test-utils.js";
|
||||
import { isAuthError } from "./errors.js";
|
||||
import {
|
||||
parseTokenResponse,
|
||||
refreshTokens,
|
||||
resolveClientId,
|
||||
revokeTokens,
|
||||
startAuthorizationCodeFlow,
|
||||
} from "./oauth.js";
|
||||
import { readStore, writeStore } from "./store.js";
|
||||
|
||||
// Mock the interactive bits so startAuthorizationCodeFlow runs headless.
|
||||
vi.mock("./loopback.js", () => ({
|
||||
startLoopback: vi.fn(async () => ({
|
||||
result: Promise.resolve({
|
||||
code: "auth_code_123",
|
||||
redirectUri: "http://127.0.0.1:12345/oauth/callback",
|
||||
}),
|
||||
redirectUri: "http://127.0.0.1:12345/oauth/callback",
|
||||
close: vi.fn(async () => {}),
|
||||
})),
|
||||
}));
|
||||
vi.mock("./browser.js", () => ({
|
||||
openBrowser: vi.fn(async () => ({ opened: true })),
|
||||
}));
|
||||
|
||||
describe("auth/oauth", () => {
|
||||
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
fixture = await setupTempAuthEnv("hf-oauth-");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fixture.restore();
|
||||
});
|
||||
|
||||
describe("resolveClientId", () => {
|
||||
it("returns the env override when set", () => {
|
||||
process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "test_client_id";
|
||||
expect(resolveClientId()).toBe("test_client_id");
|
||||
});
|
||||
|
||||
it("returns the build-time default when env is unset", () => {
|
||||
expect(resolveClientId()).toMatch(/.+/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTokenResponse", () => {
|
||||
it("parses a full token response", () => {
|
||||
const tokens = parseTokenResponse({
|
||||
access_token: "at_123",
|
||||
refresh_token: "rt_456",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
scope: "openid profile",
|
||||
});
|
||||
expect(tokens.access_token).toBe("at_123");
|
||||
expect(tokens.refresh_token).toBe("rt_456");
|
||||
expect(tokens.token_type).toBe("Bearer");
|
||||
expect(tokens.scope).toBe("openid profile");
|
||||
expect(tokens.expires_at).toBeDefined();
|
||||
const expiresAt = new Date(tokens.expires_at!);
|
||||
// Should be approximately 1 hour in the future
|
||||
const diff = expiresAt.getTime() - Date.now();
|
||||
expect(diff).toBeGreaterThan(3500 * 1000);
|
||||
expect(diff).toBeLessThan(3700 * 1000);
|
||||
});
|
||||
|
||||
it("accepts expires_in as a string (some servers serialize as string)", () => {
|
||||
const tokens = parseTokenResponse({ access_token: "at", expires_in: "1800" });
|
||||
expect(tokens.expires_at).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects responses missing access_token", () => {
|
||||
expect(() => parseTokenResponse({ token_type: "Bearer" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects array payloads", () => {
|
||||
expect(() => parseTokenResponse([])).toThrow();
|
||||
});
|
||||
|
||||
it("rejects null payloads", () => {
|
||||
expect(() => parseTokenResponse(null)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects access_token containing CR/LF", () => {
|
||||
expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toSatisfy(
|
||||
() => true, // assertion done below via toThrow
|
||||
);
|
||||
expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toThrow(
|
||||
/control characters/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects refresh_token containing CR/LF", () => {
|
||||
expect(() => parseTokenResponse({ access_token: "at", refresh_token: "rt\nbad" })).toThrow(
|
||||
/control characters/,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps non-positive expires_in to avoid an immediate-refresh loop", () => {
|
||||
const zero = parseTokenResponse({ access_token: "at", expires_in: 0 });
|
||||
const negative = parseTokenResponse({ access_token: "at", expires_in: -100 });
|
||||
// both should resolve to a future time
|
||||
expect(new Date(zero.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
|
||||
expect(new Date(negative.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
|
||||
});
|
||||
|
||||
it("uses REFRESH_FAILED error code on shape failures (not API_ERROR)", () => {
|
||||
try {
|
||||
parseTokenResponse(null);
|
||||
} catch (err) {
|
||||
expect(isAuthError(err)).toBe(true);
|
||||
if (isAuthError(err)) expect(err.code).toBe("REFRESH_FAILED");
|
||||
return;
|
||||
}
|
||||
throw new Error("expected throw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshTokens", () => {
|
||||
it("posts grant_type=refresh_token and persists the response", async () => {
|
||||
process.env["HEYGEN_API_URL"] = "https://api.test.example";
|
||||
let capturedBody: string | undefined;
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new_at",
|
||||
refresh_token: "new_rt",
|
||||
expires_in: 3600,
|
||||
token_type: "Bearer",
|
||||
scope: "openid profile",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const tokens = await refreshTokens("old_rt", { fetchImpl });
|
||||
expect(tokens.access_token).toBe("new_at");
|
||||
expect(tokens.refresh_token).toBe("new_rt");
|
||||
expect(capturedBody).toContain("grant_type=refresh_token");
|
||||
expect(capturedBody).toContain("refresh_token=old_rt");
|
||||
|
||||
// Should have persisted.
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||||
});
|
||||
|
||||
it("preserves the prior refresh_token when the server omits it (no rotation)", async () => {
|
||||
await writeStore({
|
||||
oauth: {
|
||||
access_token: "old_at",
|
||||
refresh_token: "keep_me_rt",
|
||||
expires_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
const fetchImpl = (async () =>
|
||||
new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch;
|
||||
await refreshTokens("keep_me_rt", { fetchImpl });
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||||
// Critical: refresh_token MUST survive a no-rotation refresh.
|
||||
expect(credentials.oauth?.refresh_token).toBe("keep_me_rt");
|
||||
});
|
||||
|
||||
it("preserves an existing api_key when persisting refreshed oauth", async () => {
|
||||
await writeStore({ api_key: "hg_keep" });
|
||||
const fetchImpl = (async () =>
|
||||
new Response(JSON.stringify({ access_token: "new_at", expires_in: 60 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch;
|
||||
await refreshTokens("old_rt", { fetchImpl });
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.api_key).toBe("hg_keep");
|
||||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||||
});
|
||||
|
||||
it("throws REFRESH_FAILED on 400/401", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response("invalid_grant", { status: 400 })) as unknown as typeof fetch;
|
||||
await expect(refreshTokens("bad_rt", { fetchImpl })).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "REFRESH_FAILED";
|
||||
});
|
||||
});
|
||||
|
||||
it("throws API_ERROR on 5xx", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response("upstream", { status: 503 })) as unknown as typeof fetch;
|
||||
await expect(refreshTokens("rt", { fetchImpl })).rejects.toSatisfy((err) => {
|
||||
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeTokens", () => {
|
||||
it("never throws on network failure (best-effort)", async () => {
|
||||
const fetchImpl = (async () => {
|
||||
throw new Error("connection refused");
|
||||
}) as unknown as typeof fetch;
|
||||
await expect(revokeTokens("any_token", { fetchImpl })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects the timeout", async () => {
|
||||
let aborted = false;
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
const signal = init?.signal;
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
signal?.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
await revokeTokens("token", { fetchImpl, timeoutMs: 50 });
|
||||
expect(aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("sends token_type_hint when provided", async () => {
|
||||
let capturedBody = "";
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
capturedBody = init?.body as string;
|
||||
return new Response("", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
await revokeTokens("tok", { fetchImpl, token_type_hint: "refresh_token" });
|
||||
expect(capturedBody).toContain("token_type_hint=refresh_token");
|
||||
});
|
||||
|
||||
it("returns silently when client_id is unconfigured (no throw)", async () => {
|
||||
process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "";
|
||||
// With the baked-in default cleared from env, revokeTokens still has
|
||||
// the build-time default. Force it to fail by setting the override to
|
||||
// a value AND nulling the default isn't possible from a test — instead
|
||||
// verify that the function never throws via the standard path.
|
||||
const fetchImpl = (async () => new Response("", { status: 200 })) as unknown as typeof fetch;
|
||||
await expect(revokeTokens("tok", { fetchImpl })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error scrubbing", () => {
|
||||
it("refreshTokens does not leak token-shaped secrets from the error body", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response(
|
||||
'{"error":"invalid_grant","echoed":"refresh_token=rt_leak_secret&code_verifier=cv_leak"}',
|
||||
{ status: 400 },
|
||||
)) as unknown as typeof fetch;
|
||||
try {
|
||||
await refreshTokens("rt_leak_secret", { fetchImpl });
|
||||
throw new Error("expected rejection");
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
expect(msg).not.toContain("rt_leak_secret");
|
||||
expect(msg).not.toContain("cv_leak");
|
||||
expect(msg).toContain("<redacted>");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("startAuthorizationCodeFlow persistence", () => {
|
||||
function tokenFetch(body: Record<string, unknown>): typeof fetch {
|
||||
return (async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it("overwrites the OAuth block on fresh login (no inherited refresh_token)", async () => {
|
||||
// Pre-seed a prior session whose refresh_token must NOT leak into
|
||||
// the new login when the new response omits one.
|
||||
await writeStore({
|
||||
oauth: { access_token: "old_at", refresh_token: "OLD_rt_should_not_survive" },
|
||||
});
|
||||
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
|
||||
await startAuthorizationCodeFlow({ fetchImpl });
|
||||
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||||
// Fresh login is a clean session — the old refresh_token is gone.
|
||||
expect(credentials.oauth?.refresh_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves a co-located api_key across fresh login", async () => {
|
||||
await writeStore({ api_key: "hg_keep_me" });
|
||||
const fetchImpl = tokenFetch({ access_token: "new_at", refresh_token: "new_rt" });
|
||||
await startAuthorizationCodeFlow({ fetchImpl });
|
||||
|
||||
const { credentials } = await readStore();
|
||||
expect(credentials.api_key).toBe("hg_keep_me");
|
||||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||||
expect(credentials.oauth?.refresh_token).toBe("new_rt");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 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 };
|
||||
// Keep co-located API key in both modes.
|
||||
await writeStore({ ...(existing.api_key ? { api_key: existing.api_key } : {}), 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 "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { generatePkcePair, generateState } from "./pkce.js";
|
||||
|
||||
describe("auth/pkce", () => {
|
||||
it("generates a verifier within RFC 7636 length bounds (43-128 chars)", () => {
|
||||
const { verifier } = generatePkcePair();
|
||||
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
||||
expect(verifier.length).toBeLessThanOrEqual(128);
|
||||
});
|
||||
|
||||
it("uses only URL-safe base64 characters", () => {
|
||||
const { verifier, challenge } = generatePkcePair();
|
||||
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it("challenge is the SHA-256 hash of the verifier, base64url-encoded", () => {
|
||||
const { verifier, challenge } = generatePkcePair();
|
||||
const expected = createHash("sha256")
|
||||
.update(verifier)
|
||||
.digest("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
expect(challenge).toBe(expected);
|
||||
});
|
||||
|
||||
it("always uses S256 method", () => {
|
||||
expect(generatePkcePair().method).toBe("S256");
|
||||
});
|
||||
|
||||
it("generates distinct verifiers on each call", () => {
|
||||
const a = generatePkcePair().verifier;
|
||||
const b = generatePkcePair().verifier;
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("generates state values with sufficient entropy", () => {
|
||||
const state = generateState();
|
||||
expect(state.length).toBeGreaterThanOrEqual(40);
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(state).not.toBe(generateState());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange, RFC 7636) helpers.
|
||||
*
|
||||
* The flow:
|
||||
* 1. Generate a high-entropy `code_verifier` (43–128 URL-safe chars).
|
||||
* 2. Send `code_challenge = base64url(SHA-256(code_verifier))` to
|
||||
* `/v1/oauth/authorize`.
|
||||
* 3. After the user consents, exchange the returned `code` + the
|
||||
* original `code_verifier` at `/v1/oauth/token`. The server hashes
|
||||
* the verifier and rejects the exchange if it doesn't match the
|
||||
* challenge that opened the flow.
|
||||
*
|
||||
* PKCE removes the need for a client secret — perfect for a CLI that
|
||||
* can't keep one.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const VERIFIER_BYTES = 64; // 64 random bytes → 86 base64url chars (well within 43-128)
|
||||
|
||||
export interface PkcePair {
|
||||
/** Sent on the exchange; kept secret by the CLI between the two HTTP hops. */
|
||||
verifier: string;
|
||||
/** Sent on the authorize URL. */
|
||||
challenge: string;
|
||||
/** Always "S256" for HeyGen's backend (`code_challenge_method`). */
|
||||
method: "S256";
|
||||
}
|
||||
|
||||
export function generatePkcePair(): PkcePair {
|
||||
const verifier = base64UrlEncode(randomBytes(VERIFIER_BYTES));
|
||||
const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest());
|
||||
return { verifier, challenge, method: "S256" };
|
||||
}
|
||||
|
||||
/** OAuth `state` parameter — a CSRF token bound to this flow. */
|
||||
export function generateState(): string {
|
||||
return base64UrlEncode(randomBytes(32));
|
||||
}
|
||||
|
||||
function base64UrlEncode(buf: Buffer): string {
|
||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
@@ -1,37 +1,22 @@
|
||||
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 { setupTempAuthEnv } from "./_test-utils.js";
|
||||
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 fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
|
||||
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;
|
||||
fixture = await setupTempAuthEnv("hf-auth-resolve-");
|
||||
dir = fixture.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 });
|
||||
await fixture.restore();
|
||||
});
|
||||
|
||||
it("prefers HEYGEN_API_KEY over everything else", async () => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scrubCredentials } from "./scrub.js";
|
||||
|
||||
describe("auth/scrub", () => {
|
||||
it("redacts HeyGen API keys", () => {
|
||||
const out = scrubCredentials("rejected key hg_supersecret_abc123 from header");
|
||||
expect(out).not.toContain("hg_supersecret_abc123");
|
||||
expect(out).toContain("hg_<redacted>");
|
||||
});
|
||||
|
||||
it("redacts sk_V2_ keys echoed inline (not just after Authorization:)", () => {
|
||||
// Real HeyGen keys are `sk_V2_…`. A token echoed in a stack trace or
|
||||
// JSON payload — without a header-name anchor — must still be
|
||||
// redacted, per the threat model the scrubber is written for.
|
||||
const out = scrubCredentials(
|
||||
"error: token sk_V2_hgu_supersecret_real999 was rejected upstream",
|
||||
);
|
||||
expect(out).not.toContain("sk_V2_hgu_supersecret_real999");
|
||||
expect(out).toContain("sk_<redacted>");
|
||||
});
|
||||
|
||||
it("redacts authorization / x-api-key header lines", () => {
|
||||
const out = scrubCredentials("x-api-key: hg_zzz999\nauthorization: Bearer abc");
|
||||
expect(out).not.toContain("hg_zzz999");
|
||||
expect(out).not.toContain("Bearer abc");
|
||||
expect(out).toContain("x-api-key: <redacted>");
|
||||
expect(out).toContain("authorization: <redacted>");
|
||||
});
|
||||
|
||||
it("redacts the full Authorization: Bearer value, not just the scheme", () => {
|
||||
const out = scrubCredentials("echoed Authorization: Bearer at_opaque_secret_999\nnext line");
|
||||
// The opaque token after `Bearer` must be gone.
|
||||
expect(out).not.toContain("at_opaque_secret_999");
|
||||
expect(out).not.toContain("Bearer at_opaque_secret_999");
|
||||
expect(out).toContain("Authorization: <redacted>");
|
||||
// Redaction stops at the line break — unrelated following lines survive.
|
||||
expect(out).toContain("next line");
|
||||
});
|
||||
|
||||
it("redacts JWT-shaped tokens", () => {
|
||||
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcDEF_123-xyz";
|
||||
const out = scrubCredentials(`token was ${jwt}`);
|
||||
expect(out).not.toContain(jwt);
|
||||
expect(out).toContain("<jwt-redacted>");
|
||||
});
|
||||
|
||||
it("redacts form-encoded OAuth secrets (refresh_token, code, code_verifier)", () => {
|
||||
const body =
|
||||
"error: grant_type=refresh_token&refresh_token=rt_secret_abc&code=auth_code_xyz&code_verifier=verif_123";
|
||||
const out = scrubCredentials(body);
|
||||
expect(out).not.toContain("rt_secret_abc");
|
||||
expect(out).not.toContain("auth_code_xyz");
|
||||
expect(out).not.toContain("verif_123");
|
||||
expect(out).toContain("refresh_token=<redacted>");
|
||||
expect(out).toContain("code=<redacted>");
|
||||
expect(out).toContain("code_verifier=<redacted>");
|
||||
// grant_type is not a secret — keep it for debuggability.
|
||||
expect(out).toContain("grant_type=refresh_token");
|
||||
});
|
||||
|
||||
it("redacts JSON-encoded OAuth secrets", () => {
|
||||
const body = '{"error":"invalid","access_token":"at_leak","refresh_token":"rt_leak"}';
|
||||
const out = scrubCredentials(body);
|
||||
expect(out).not.toContain("at_leak");
|
||||
expect(out).not.toContain("rt_leak");
|
||||
expect(out).toContain('"access_token":"<redacted>"');
|
||||
expect(out).toContain('"refresh_token":"<redacted>"');
|
||||
});
|
||||
|
||||
it("does not over-redact unrelated text", () => {
|
||||
const out = scrubCredentials("error_code=42 the request failed validation");
|
||||
expect(out).toBe("error_code=42 the request failed validation");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Redact credential-shaped substrings from error bodies before they
|
||||
* reach user-facing messages, `--json` output, or logs.
|
||||
*
|
||||
* Both the `/v3/users/me` client and the OAuth token/revoke endpoints
|
||||
* surface upstream error bodies. A misbehaving server or proxy can echo
|
||||
* request data (API keys, OAuth `refresh_token` / `code` / `code_verifier`,
|
||||
* bearer tokens, JWTs) into those bodies — this scrubber makes sure none
|
||||
* of it lands in scrollback or CI logs.
|
||||
*/
|
||||
|
||||
// Both HeyGen key prefixes: legacy `hg_…` and current `sk_V2_…` (plus
|
||||
// any `sk_<segment>_…` partner format). A bare key echoed inline in an
|
||||
// error body — without an Authorization:/x-api-key: header anchor —
|
||||
// must still be redacted, so we match the prefix directly.
|
||||
const HEYGEN_KEY = /\b(hg|sk)_[A-Za-z0-9_-]{4,}/g;
|
||||
// Redact the ENTIRE header value to end-of-line. `Authorization: Bearer
|
||||
// <token>` is two whitespace-separated words, so a `\S+` would stop after
|
||||
// the scheme and leave the opaque token exposed.
|
||||
const HEADER_LINE = /(authorization|x-api-key)[ \t]*[:=][ \t]*[^\r\n]+/gi;
|
||||
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
||||
|
||||
// Sensitive OAuth/credential field names. Matched both as
|
||||
// form-encoded (`name=value`) and JSON (`"name":"value"`).
|
||||
const SECRET_FIELDS = [
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"code_verifier",
|
||||
"client_secret",
|
||||
"id_token",
|
||||
"token",
|
||||
"code",
|
||||
];
|
||||
|
||||
const FORM_FIELD = new RegExp(`\\b(${SECRET_FIELDS.join("|")})=[^&\\s"']+`, "gi");
|
||||
const JSON_FIELD = new RegExp(`("(?:${SECRET_FIELDS.join("|")})"\\s*:\\s*)"[^"]*"`, "gi");
|
||||
|
||||
export function scrubCredentials(s: string): string {
|
||||
return s
|
||||
.replace(HEYGEN_KEY, "$1_<redacted>")
|
||||
.replace(HEADER_LINE, "$1: <redacted>")
|
||||
.replace(JWT, "<jwt-redacted>")
|
||||
.replace(FORM_FIELD, "$1=<redacted>")
|
||||
.replace(JSON_FIELD, '$1"<redacted>"');
|
||||
}
|
||||
@@ -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