fix(cli): address code-review findings on OAuth PR

This commit is contained in:
James
2026-05-28 05:59:24 +00:00
parent 8a9291c434
commit 81aff68397
20 changed files with 1722 additions and 125 deletions
+54
View File
@@ -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 };
}
+36
View File
@@ -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)}`);
}
+109
View File
@@ -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) => {
+64 -20
View File
@@ -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.
+21 -1
View File
@@ -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;
}
+7
View File
@@ -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";
+76
View File
@@ -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);
});
});
+214
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+300
View File
@@ -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");
});
});
});
+438
View File
@@ -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 "";
}
}
+45
View File
@@ -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());
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* PKCE (Proof Key for Code Exchange, RFC 7636) helpers.
*
* The flow:
* 1. Generate a high-entropy `code_verifier` (43128 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(/=+$/, "");
}
+5 -20
View File
@@ -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 () => {
+74
View File
@@ -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");
});
});
+45
View File
@@ -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>"');
}
+10 -5
View File
@@ -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);
+94 -77
View File
@@ -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,
+32 -1
View File
@@ -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");
+48
View File
@@ -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;
}
},
});
+7 -1
View File
@@ -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 };