From 49bdaeb4a92a8f29f7662be1bfceca18fee4e3f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 28 Jul 2026 04:13:57 +0000 Subject: [PATCH 1/4] feat(cli): add device authorization login --- docs/deploy/cloud.mdx | 10 +- packages/cli/src/auth/errors.ts | 8 + packages/cli/src/auth/index.ts | 3 + packages/cli/src/auth/oauth.test.ts | 228 +++++++++++++++- packages/cli/src/auth/oauth.ts | 267 ++++++++++++++++++- packages/cli/src/commands/auth.ts | 4 +- packages/cli/src/commands/auth/login.test.ts | 154 +++++++++-- packages/cli/src/commands/auth/login.ts | 134 +++++++++- packages/cli/src/telemetry/events.ts | 5 +- 9 files changed, 780 insertions(+), 33 deletions(-) diff --git a/docs/deploy/cloud.mdx b/docs/deploy/cloud.mdx index 56f9b988b4..10379f8392 100644 --- a/docs/deploy/cloud.mdx +++ b/docs/deploy/cloud.mdx @@ -36,7 +36,15 @@ Cloud rendering needs a HeyGen credential. Sign in once — the CLI stores it in # ✓ Signed in as you@example.com. ``` - For CI or headless machines, use a long-lived API key instead: + From an attended SSH/headless terminal, use the device flow. Open the + displayed URL on any browser and enter the one-time code: + + ```bash + hyperframes auth login --device + ``` + + Device login is intentionally refused in CI and non-TTY sessions. For + unattended agents and CI, use a long-lived API key instead: ```bash # Interactive hidden-input prompt diff --git a/packages/cli/src/auth/errors.ts b/packages/cli/src/auth/errors.ts index 1c0af12b9d..dbf4893168 100644 --- a/packages/cli/src/auth/errors.ts +++ b/packages/cli/src/auth/errors.ts @@ -9,6 +9,7 @@ export type AuthErrorCode = | "API_ERROR" | "UNAUTHENTICATED" | "OAUTH_NOT_CONFIGURED" + | "DEVICE_AUTH_FAILED" | "REFRESH_FAILED"; export class AuthError extends Error { @@ -61,6 +62,13 @@ export const ErrRefreshFailed = (detail?: string) => "Run `hyperframes auth login` to re-authenticate.", ); +export const ErrDeviceAuthFailed = (detail: string) => + new AuthError( + "DEVICE_AUTH_FAILED", + `Device authorization failed: ${detail}`, + "Run `hyperframes auth login --device` to start a new code.", + ); + export function isAuthError(err: unknown): err is AuthError { return err instanceof AuthError; } diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 710297b9c0..8165dd2850 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -33,7 +33,10 @@ export type { UserInfo } from "./client.js"; export { assertOAuthConfiguredOrExit, + persistFreshOAuth, + persistVerifiedOAuthSession, refreshTokens, revokeTokens, startAuthorizationCodeFlow, + startDeviceAuthorizationFlow, } from "./oauth.js"; diff --git a/packages/cli/src/auth/oauth.test.ts b/packages/cli/src/auth/oauth.test.ts index c280daf451..305e966ccd 100644 --- a/packages/cli/src/auth/oauth.test.ts +++ b/packages/cli/src/auth/oauth.test.ts @@ -4,10 +4,13 @@ import { setupTempAuthEnv } from "./_test-utils.js"; import { isAuthError } from "./errors.js"; import { parseTokenResponse, + persistFreshOAuth, + persistVerifiedOAuthSession, refreshTokens, resolveClientId, revokeTokens, startAuthorizationCodeFlow, + startDeviceAuthorizationFlow, } from "./oauth.js"; import { readStore, writeStore } from "./store.js"; @@ -70,7 +73,10 @@ describe("auth/oauth", () => { }); it("accepts expires_in as a string (some servers serialize as string)", () => { - const tokens = parseTokenResponse({ access_token: "at", expires_in: "1800" }); + const tokens = parseTokenResponse({ + access_token: "at", + expires_in: "1800", + }); expect(tokens.expires_at).toBeDefined(); }); @@ -103,7 +109,10 @@ describe("auth/oauth", () => { 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 }); + 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); @@ -217,7 +226,9 @@ describe("auth/oauth", () => { it("throws REFRESH_FAILED on 400/401", async () => { const fetchImpl = (async () => - new Response("invalid_grant", { status: 400 })) as unknown as typeof fetch; + 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"; }); @@ -261,7 +272,10 @@ describe("auth/oauth", () => { capturedBody = init?.body as string; return new Response("", { status: 200 }); }) as unknown as typeof fetch; - await revokeTokens("tok", { fetchImpl, token_type_hint: "refresh_token" }); + await revokeTokens("tok", { + fetchImpl, + token_type_hint: "refresh_token", + }); expect(capturedBody).toContain("token_type_hint=refresh_token"); }); @@ -308,9 +322,15 @@ describe("auth/oauth", () => { // 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" }, + oauth: { + access_token: "old_at", + refresh_token: "OLD_rt_should_not_survive", + }, + }); + const fetchImpl = tokenFetch({ + access_token: "new_at", + expires_in: 3600, }); - const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); await startAuthorizationCodeFlow({ fetchImpl }); const { credentials } = await readStore(); @@ -321,7 +341,10 @@ describe("auth/oauth", () => { 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" }); + const fetchImpl = tokenFetch({ + access_token: "new_at", + refresh_token: "new_rt", + }); await startAuthorizationCodeFlow({ fetchImpl }); const { credentials } = await readStore(); @@ -344,16 +367,203 @@ describe("auth/oauth", () => { }), { mode: 0o600 }, ); - const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); + 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"); - expect(credentials.user).toEqual({ email: "jane@example.com", username: "jdoe" }); + expect(credentials.user).toEqual({ + email: "jane@example.com", + username: "jdoe", + }); // The unknown key is on a hidden slot — assert via the raw file. const onDisk = JSON.parse(await fs.readFile(path, "utf8")); expect(onDisk.future_field).toEqual({ keep: true }); }); }); + + describe("startDeviceAuthorizationFlow", () => { + it("atomically replaces OAuth and identity while preserving foreign fields", async () => { + const path = (await import("./paths.js")).credentialPath(); + await fs.writeFile( + path, + JSON.stringify({ + api_key: "hg_keep", + oauth: { access_token: "old-at", refresh_token: "old-rt" }, + user: { + email: "old@example.com", + username: "old-user", + future_user_field: "keep-user", + }, + future_root_field: { keep: true }, + }), + { mode: 0o600 }, + ); + + await persistVerifiedOAuthSession( + { access_token: "device-at", refresh_token: "device-rt" }, + { email: "new@example.com" }, + ); + + const onDisk = JSON.parse(await fs.readFile(path, "utf8")); + expect(onDisk.api_key).toBe("hg_keep"); + expect(onDisk.oauth).toMatchObject({ + access_token: "device-at", + refresh_token: "device-rt", + }); + expect(onDisk.user).toEqual({ + email: "new@example.com", + future_user_field: "keep-user", + }); + expect(onDisk.future_root_field).toEqual({ keep: true }); + }); + + it("polls pending and slow_down responses without persisting before identity verification", async () => { + const requests: Array<{ url: string; body: URLSearchParams }> = []; + const responses = [ + new Response( + JSON.stringify({ + device_code: "secret-device-code", + user_code: "ABCD-2345", + verification_uri: "https://app.heygen.com/oauth/device", + expires_in: 600, + interval: 5, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + new Response(JSON.stringify({ error: "authorization_pending" }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + new Response(JSON.stringify({ error: "slow_down" }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + new Response( + JSON.stringify({ + access_token: "device-at", + refresh_token: "device-rt", + token_type: "Bearer", + expires_in: 3600, + scope: "openid profile email", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ]; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ + url: String(url), + body: new URLSearchParams(String(init?.body ?? "")), + }); + const next = responses.shift(); + if (!next) throw new Error("unexpected fetch"); + return next; + }) as typeof fetch; + const sleeps: number[] = []; + const challenges: Array<{ userCode: string; verificationUri: string }> = []; + + const tokens = await startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async (ms) => { + sleeps.push(ms); + }, + now: () => 1_000, + onChallenge: (challenge) => { + challenges.push(challenge); + }, + }); + + expect(tokens.access_token).toBe("device-at"); + expect(challenges).toEqual([ + { + userCode: "ABCD-2345", + verificationUri: "https://app.heygen.com/oauth/device", + }, + ]); + expect(sleeps).toEqual([5_000, 5_000, 10_000]); + expect(requests[0]?.body.get("client_id")).toBe(resolveClientId()); + expect(requests[1]?.body.get("device_code")).toBe("secret-device-code"); + expect(requests[1]?.body.get("grant_type")).toBe( + "urn:ietf:params:oauth:grant-type:device_code", + ); + expect((await readStore()).source).toBe("absent"); + + await persistFreshOAuth(tokens); + expect((await readStore()).credentials.oauth?.access_token).toBe("device-at"); + }); + + it.each(["access_denied", "expired_token"])( + "fails with a bounded error for %s without echoing the device code", + async (oauthError) => { + const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { + const body = new URLSearchParams(String(init?.body ?? "")); + if (body.has("scope")) { + return new Response( + JSON.stringify({ + device_code: "never-log-this-device-code", + user_code: "ABCD-2345", + verification_uri: "https://app.heygen.com/oauth/device", + expires_in: 600, + interval: 5, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + error: oauthError, + error_description: "echo never-log-this-device-code", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + + await expect( + startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async () => {}, + now: () => 1_000, + }), + ).rejects.not.toThrow(/never-log-this-device-code/); + expect((await readStore()).source).toBe("absent"); + }, + ); + + it.each([ + ["credential-bearing URL", "https://user:pass@app.heygen.com/oauth/device", 600, 5], + ["prefix-parsed expiry", "https://app.heygen.com/oauth/device", "600seconds", 5], + ["prefix-parsed interval", "https://app.heygen.com/oauth/device", 600, "5seconds"], + ])("rejects an unsafe or malformed %s response", async (_name, uri, expiresIn, interval) => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ + device_code: "never-log-this-device-code", + user_code: "ABCD-2345", + verification_uri: uri, + expires_in: expiresIn, + interval, + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + + await expect(startDeviceAuthorizationFlow({ fetchImpl })).rejects.toThrow( + /Device authorization failed/, + ); + }); + + it("rejects an oversized device response without exposing its body", async () => { + const marker = "never-log-this-device-code"; + const fetchImpl = (async () => + new Response(JSON.stringify({ padding: marker.repeat(8_000) }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + + await expect(startDeviceAuthorizationFlow({ fetchImpl })).rejects.not.toThrow(marker); + }); + }); }); diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index 6cb4a36487..cf4b949698 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -34,7 +34,13 @@ import { failCommand } from "../utils/commandResult.js"; * Public client — no `client_secret`. */ -import { ErrApi, ErrOAuthNotConfigured, ErrRefreshFailed, isAuthError } from "./errors.js"; +import { + ErrApi, + ErrDeviceAuthFailed, + ErrOAuthNotConfigured, + ErrRefreshFailed, + isAuthError, +} from "./errors.js"; import { generatePkcePair, generateState } from "./pkce.js"; import { startLoopback } from "./loopback.js"; import { openBrowser } from "./browser.js"; @@ -45,6 +51,7 @@ import { writeStore, type Credentials, type OAuthTokens, + type StoredUserInfo, } from "./store.js"; import { c } from "../ui/colors.js"; @@ -65,6 +72,12 @@ const DEFAULT_SCOPES = "openid profile email"; 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"; +const DEFAULT_DEVICE_AUTHORIZATION_URL = "https://api2.heygen.com/v1/oauth/device_authorization"; +const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +const MAX_DEVICE_FLOW_SECONDS = 30 * 60; +const MIN_DEVICE_POLL_SECONDS = 5; +const MAX_DEVICE_POLL_SECONDS = 60; +const MAX_DEVICE_RESPONSE_BYTES = 64 * 1024; function authorizeEndpoint(): string { return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL; @@ -75,6 +88,9 @@ function tokenEndpoint(): string { function revokeEndpoint(): string { return process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL; } +function deviceAuthorizationEndpoint(): string { + return process.env["HYPERFRAMES_OAUTH_DEVICE_URL"] || DEFAULT_DEVICE_AUTHORIZATION_URL; +} export interface AuthorizeFlowOptions { /** Override scopes (default `openid profile email`). */ @@ -95,6 +111,24 @@ export interface RefreshOptions { fetchImpl?: typeof fetch; } +export interface DeviceAuthorizationChallenge { + userCode: string; + verificationUri: string; +} + +export interface DeviceAuthorizationFlowOptions { + /** Override scopes (default `openid profile email`). */ + scope?: string; + /** Inject a custom fetch (used by tests). */ + fetchImpl?: typeof fetch; + /** Inject polling sleep (used by tests). */ + sleepImpl?: (ms: number) => Promise; + /** Inject a monotonic-enough clock in epoch milliseconds (used by tests). */ + now?: () => number; + /** Present the user code and verification URI without exposing device_code. */ + onChallenge?: (challenge: DeviceAuthorizationChallenge) => void | Promise; +} + /** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */ export function resolveClientId(): string { const override = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"]; @@ -171,6 +205,91 @@ export async function startAuthorizationCodeFlow( return { tokens }; } +/** + * RFC 8628 attended device flow. This function deliberately returns an + * unpersisted token set: the command must verify `/v3/users/me` first and only + * then call `persistFreshOAuth`. That ordering prevents a token for the wrong + * account/resource from ever becoming the active shared credential. + */ +export async function startDeviceAuthorizationFlow( + opts: DeviceAuthorizationFlowOptions = {}, +): Promise { + const clientId = resolveClientId(); + const fetchImpl = opts.fetchImpl ?? fetch; + const sleepImpl = + opts.sleepImpl ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const now = opts.now ?? Date.now; + const scope = opts.scope ?? DEFAULT_SCOPES; + + let issuanceResponse: Response; + try { + issuanceResponse = await fetchImpl(deviceAuthorizationEndpoint(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: new URLSearchParams({ client_id: clientId, scope }).toString(), + }); + } catch { + throw ErrDeviceAuthFailed("could not reach the authorization server"); + } + if (!issuanceResponse.ok) { + throw ErrDeviceAuthFailed(`authorization server returned HTTP ${issuanceResponse.status}`); + } + + const issuance = parseDeviceAuthorizationResponse(await readJsonOrDeviceError(issuanceResponse)); + await opts.onChallenge?.({ + userCode: issuance.userCode, + verificationUri: issuance.verificationUri, + }); + + const deadline = now() + Math.min(issuance.expiresIn, MAX_DEVICE_FLOW_SECONDS) * 1000; + let intervalSeconds = issuance.interval; + while (now() < deadline) { + const remainingMs = deadline - now(); + if (remainingMs <= 0) break; + await sleepImpl(Math.min(intervalSeconds * 1000, remainingMs)); + + let pollResponse: Response; + try { + pollResponse = await fetchImpl(tokenEndpoint(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: issuance.deviceCode, + client_id: clientId, + }).toString(), + }); + } catch { + throw ErrDeviceAuthFailed("lost contact with the authorization server"); + } + + if (pollResponse.ok) { + return parseTokenResponse(await readJsonOrDeviceError(pollResponse)); + } + + const error = await readDeviceOAuthError(pollResponse); + if (error === "authorization_pending") continue; + if (error === "slow_down") { + intervalSeconds = Math.min(intervalSeconds + 5, MAX_DEVICE_POLL_SECONDS); + continue; + } + if (error === "access_denied") { + throw ErrDeviceAuthFailed("access was denied"); + } + if (error === "expired_token") { + throw ErrDeviceAuthFailed("the code expired"); + } + throw ErrDeviceAuthFailed(`authorization server returned HTTP ${pollResponse.status}`); + } + throw ErrDeviceAuthFailed("the code expired"); +} + export async function refreshTokens( refresh_token: string, opts: RefreshOptions = {}, @@ -422,6 +541,152 @@ async function persistOAuth( await writeStore({ ...existing, oauth }); } +/** Persist a verified fresh OAuth login while preserving cross-CLI fields. */ +export async function persistFreshOAuth(tokens: OAuthTokens): Promise { + await persistOAuth(tokens, { preserveMissing: false }); +} + +/** + * Atomically install a verified device session and its identity metadata. + * + * This is intentionally one credential-file rename: if the write fails, the + * previous credential remains intact and the caller can revoke the freshly + * minted tokens without leaving a half-installed session or stale identity. + */ +export async function persistVerifiedOAuthSession( + tokens: OAuthTokens, + user: StoredUserInfo, +): Promise { + const { credentials } = await readStore(); + const next: Credentials = { + ...credentials, + oauth: { ...tokens }, + }; + if (user.email || user.first_name || user.last_name || user.username) { + // Preserve only unknown/foreign user fields from the existing record. + // Assigning every known field (including undefined) prevents identity + // fields from the previous account surviving when the new response omits + // them; serializeUser skips undefined values. + next.user = { + ...credentials.user, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + username: user.username, + }; + } else { + delete next.user; + } + await writeStore(next); +} + +interface ParsedDeviceAuthorization { + deviceCode: string; + userCode: string; + verificationUri: string; + expiresIn: number; + interval: number; +} + +function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthorization { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw ErrDeviceAuthFailed("authorization server returned an invalid response"); + } + const data = payload as Record; + const deviceCode = stringField(data, "device_code"); + const userCode = stringField(data, "user_code"); + const verificationUri = stringField(data, "verification_uri"); + const expiresIn = strictNumericField(data, "expires_in"); + const interval = strictNumericField(data, "interval"); + if (!deviceCode || !isHeaderSafe(deviceCode) || !userCode || !verificationUri) { + throw ErrDeviceAuthFailed("authorization server returned an invalid response"); + } + if (!isSafeVerificationUri(verificationUri)) { + throw ErrDeviceAuthFailed("authorization server returned an unsafe verification URL"); + } + if (!expiresIn || expiresIn <= 0 || !interval || interval <= 0) { + throw ErrDeviceAuthFailed("authorization server returned invalid timing values"); + } + return { + deviceCode, + userCode, + verificationUri, + expiresIn, + interval: Math.min( + Math.max(Math.ceil(interval), MIN_DEVICE_POLL_SECONDS), + MAX_DEVICE_POLL_SECONDS, + ), + }; +} + +function isSafeVerificationUri(value: string): boolean { + try { + const url = new URL(value); + if (url.username || url.password) return false; + return ( + url.protocol === "https:" || + (url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname)) + ); + } catch { + return false; + } +} + +async function readJsonOrDeviceError(res: Response): Promise { + return await readBoundedDeviceJson(res); +} + +async function readDeviceOAuthError(res: Response): Promise { + try { + const payload = await readBoundedDeviceJson(res); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; + const error = (payload as Record)["error"]; + return typeof error === "string" ? error : undefined; + } catch { + return undefined; + } +} + +function strictNumericField(obj: Record, key: string): number | undefined { + const value = obj[key]; + if (typeof value === "number") return Number.isFinite(value) ? value : undefined; + if (typeof value !== "string" || value.trim() === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +async function readBoundedDeviceJson(res: Response): Promise { + if (!res.body) throw ErrDeviceAuthFailed("authorization server returned no data"); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_DEVICE_RESPONSE_BYTES) { + await reader.cancel(); + throw ErrDeviceAuthFailed("authorization server response was too large"); + } + chunks.push(value); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)); + } catch (err) { + if (isAuthError(err)) throw err; + throw ErrDeviceAuthFailed("authorization server returned non-JSON data"); + } finally { + reader.releaseLock(); + } +} + async function readJsonOrThrow(res: Response): Promise { try { return await res.json(); diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index f6beeecd23..9c202b5362 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -17,6 +17,7 @@ import { c } from "../ui/colors.js"; export const examples: Example[] = [ ["Sign in via browser (OAuth)", "hyperframes auth login"], + ["Sign in from SSH/headless terminal", "hyperframes auth login --device"], ["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"], @@ -31,7 +32,7 @@ Manage HeyGen credentials. Credentials live in ${c.accent("~/.heygen/credentials")} and are shared with heygen-cli. ${c.bold("SUBCOMMANDS:")} - ${c.accent("login")} ${c.dim("Sign in via browser (default) or --api-key for a long-lived key.")} + ${c.accent("login")} ${c.dim("Sign in via browser, --device for SSH, 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).")} @@ -42,6 +43,7 @@ ${c.bold("ENV VARS:")} ${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). + ${c.accent("HYPERFRAMES_OAUTH_DEVICE_URL")} Override the RFC 8628 device endpoint (for dev/test). `; export default defineCommand({ diff --git a/packages/cli/src/commands/auth/login.test.ts b/packages/cli/src/commands/auth/login.test.ts index 72c64f3c8a..726fb1f6c5 100644 --- a/packages/cli/src/commands/auth/login.test.ts +++ b/packages/cli/src/commands/auth/login.test.ts @@ -18,41 +18,84 @@ const verifyState = vi.hoisted( }, ); +// Spy on the telemetry the login flow emits, so we can assert the identity is +// attributed on success. login.ts imports these via a dynamic import of +// telemetry/index.js; the mock intercepts it. +const telemetry = vi.hoisted(() => ({ + trackAuthLoginStarted: vi.fn(), + trackAuthLoginCompleted: vi.fn(), + trackAuthLoginFailed: vi.fn(), + identifyUser: vi.fn(), +})); +vi.mock("../../telemetry/index.js", () => telemetry); + +const deviceAuth = vi.hoisted(() => ({ + start: vi.fn(async (options?: { onChallenge?: (value: unknown) => void }) => { + options?.onChallenge?.({ + verificationUri: "https://app.heygen.com/oauth/device", + userCode: "ABCD-2345", + }); + return { + access_token: "device-at", + refresh_token: "device-rt", + token_type: "Bearer", + }; + }), + persist: vi.fn(async () => {}), + revoke: vi.fn(async () => {}), +})); + vi.mock("../../auth/index.js", async (orig) => { const actual = await orig(); class MockAuthClient { async getCurrentUser(): Promise> { if (verifyState.reject) { const { ErrUnauthenticated: rej } = await import("../../auth/errors.js"); - throw rej("invalid key"); + throw rej("invalid token"); } return verifyState.user; } } - return { ...actual, AuthClient: MockAuthClient }; + return { + ...actual, + AuthClient: MockAuthClient, + startDeviceAuthorizationFlow: deviceAuth.start, + persistVerifiedOAuthSession: deviceAuth.persist, + revokeTokens: deviceAuth.revoke, + }; }); -// Spy on the telemetry the login flow emits, so we can assert the identity is -// attributed on success. login.ts imports these via a dynamic import of -// telemetry/index.js; the mock intercepts it. -const telemetry = vi.hoisted(() => ({ - trackAuthLoginStarted: vi.fn(), - trackAuthLoginCompleted: vi.fn(), - trackAuthLoginFailed: vi.fn(), - identifyUser: vi.fn(), -})); -vi.mock("../../telemetry/index.js", () => telemetry); - -describe("auth login --api-key rollback", () => { +describe("auth login", () => { let dir: string; let envFixture: EnvFixture; + let runtimeEnv: Record; + let stdinTTYDescriptor: PropertyDescriptor | undefined; + let stdoutTTYDescriptor: PropertyDescriptor | undefined; beforeEach(async () => { + runtimeEnv = Object.fromEntries( + ["CI", "SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "BROWSER", "HF_NO_BROWSER"].map((key) => [ + key, + process.env[key], + ]), + ); + for (const key of Object.keys(runtimeEnv)) delete process.env[key]; + stdinTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + stdoutTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); envFixture = await setupTempAuthEnv("hf-login-"); dir = envFixture.dir; verifyState.reject = false; verifyState.user = { email: "alice@example.com" }; for (const fn of Object.values(telemetry)) fn.mockClear(); + for (const fn of Object.values(deviceAuth)) fn.mockClear(); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); }); @@ -60,16 +103,28 @@ describe("auth login --api-key rollback", () => { afterEach(async () => { vi.restoreAllMocks(); await envFixture.restore(); + for (const [key, value] of Object.entries(runtimeEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (stdinTTYDescriptor) Object.defineProperty(process.stdin, "isTTY", stdinTTYDescriptor); + else delete (process.stdin as { isTTY?: boolean }).isTTY; + if (stdoutTTYDescriptor) Object.defineProperty(process.stdout, "isTTY", stdoutTTYDescriptor); + else delete (process.stdout as { isTTY?: boolean }).isTTY; }); - async function runLogin(apiKey: string): Promise { + async function runCommand(args: Record): Promise { const cmd = (await import("./login.js")).default; // citty command run only reads `args` here. await (cmd.run as (ctx: { args: Record }) => Promise)({ - args: { "api-key": apiKey }, + args, }); } + async function runLogin(apiKey: string): Promise { + await runCommand({ "api-key": apiKey }); + } + it("removes the rejected key on a failed FIRST login (no prior credential)", async () => { verifyState.reject = true; await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError); @@ -128,7 +183,10 @@ describe("auth login --api-key rollback", () => { }); it("rollback on a rejected key restores the previous user block too", async () => { - await writeStore({ api_key: "hg_prev", user: { email: "prev@example.com" } }); + await writeStore({ + api_key: "hg_prev", + user: { email: "prev@example.com" }, + }); verifyState.reject = true; await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError); @@ -198,4 +256,66 @@ describe("auth login --api-key rollback", () => { expect(onDisk.user).toEqual({ email: "jane@example.com" }); expect(onDisk.future_field).toEqual({ x: 1 }); }); + + it("requires the explicit --device flag in a remote terminal", async () => { + process.env["SSH_CONNECTION"] = "192.0.2.1 1234 192.0.2.2 22"; + await expect(runCommand({})).rejects.toThrow(/Invalid command usage/); + expect(deviceAuth.start).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("hyperframes auth login --device"), + ); + }); + + it("verifies the device token before persisting it", async () => { + verifyState.user = { email: "device@example.com" }; + await runCommand({ device: true }); + + expect(deviceAuth.start).toHaveBeenCalledOnce(); + expect(deviceAuth.persist).toHaveBeenCalledWith( + expect.objectContaining({ access_token: "device-at" }), + expect.objectContaining({ email: "device@example.com" }), + ); + expect(deviceAuth.revoke).not.toHaveBeenCalled(); + expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("device", "device@example.com"); + }); + + it("revokes and never persists a device token that identity verification rejects", async () => { + verifyState.reject = true; + await expect(runCommand({ device: true })).rejects.toThrow(CliRuntimeError); + + expect(deviceAuth.persist).not.toHaveBeenCalled(); + expect(deviceAuth.revoke).toHaveBeenCalledTimes(2); + expect(deviceAuth.revoke).toHaveBeenNthCalledWith( + 1, + "device-at", + expect.objectContaining({ token_type_hint: "access_token" }), + ); + expect(deviceAuth.revoke).toHaveBeenNthCalledWith( + 2, + "device-rt", + expect.objectContaining({ token_type_hint: "refresh_token" }), + ); + expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("device", "rejected"); + }); + + it("refuses device authorization in CI", async () => { + process.env["CI"] = "true"; + await expect(runCommand({ device: true })).rejects.toThrow(/Invalid command usage/); + expect(deviceAuth.start).not.toHaveBeenCalled(); + }); + + it("does not treat CI=false as an unattended environment", async () => { + process.env["CI"] = "false"; + await runCommand({ device: true }); + expect(deviceAuth.start).toHaveBeenCalledOnce(); + }); + + it("refuses device authorization when either terminal stream is not a TTY", async () => { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: undefined, + }); + await expect(runCommand({ device: true })).rejects.toThrow(/Invalid command usage/); + expect(deviceAuth.start).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 3115e4e26d..2be2b9d282 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,4 +1,4 @@ -import { failCommand } from "../../utils/commandResult.js"; +import { failCommand, failUsage } from "../../utils/commandResult.js"; /** * `hyperframes auth login` — sign in to HeyGen. * @@ -35,8 +35,11 @@ import { isUserInfoEmpty, readStore, refreshTokens, + revokeTokens, saveUserInfo, + persistVerifiedOAuthSession, startAuthorizationCodeFlow, + startDeviceAuthorizationFlow, tryResolveCredential, userDisplayName, writeStore, @@ -63,18 +66,141 @@ export default defineCommand({ type: "string", description: "API key value, or pass `--api-key` with no value to read from stdin / prompt.", }, + device: { + type: "boolean", + description: "Use an attended device code (for SSH/headless terminals; never for CI).", + }, }, // fallow-ignore-next-line complexity async run({ args }) { const inlineKey = args["api-key"]; + if (inlineKey !== undefined && args.device) { + console.error(c.error("Choose either --device or --api-key, not both.")); + failUsage(); + } if (inlineKey !== undefined) { await runApiKeyLogin(inlineKey); return; } + if (args.device) { + await runDeviceLogin(); + return; + } + if (isRemoteOrHeadless()) { + console.error( + c.error( + "Browser callback login is unavailable in this remote/headless terminal. Run `hyperframes auth login --device`.", + ), + ); + failUsage(); + } await runOAuthLogin(); }, }); +function isRemoteOrHeadless(): boolean { + return Boolean( + process.env["SSH_CONNECTION"] || + process.env["SSH_CLIENT"] || + process.env["SSH_TTY"] || + process.env["BROWSER"] === "none" || + process.env["HF_NO_BROWSER"] === "1" || + process.stdout.isTTY !== true, + ); +} + +function envFlagEnabled(name: string): boolean { + const value = process.env[name]?.trim().toLowerCase(); + return Boolean(value && value !== "0" && value !== "false" && value !== "no"); +} + +function assertAttendedDeviceFlow(): void { + if (envFlagEnabled("CI") || process.stdin.isTTY !== true || process.stdout.isTTY !== true) { + console.error( + c.error( + "`--device` requires an attended terminal and is disabled in CI. Use an API key or workload credential for automation.", + ), + ); + failUsage(); + } +} + +async function runDeviceLogin(): Promise { + assertAttendedDeviceFlow(); + assertOAuthConfiguredOrExit(); + const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } = + await import("../../telemetry/index.js"); + trackAuthLoginStarted("device"); + + let tokens; + try { + tokens = await startDeviceAuthorizationFlow({ + onChallenge: ({ verificationUri, userCode }) => { + console.log(`Open ${c.accent(verificationUri)} in a browser.`); + console.log(`Enter code ${c.bold(userCode)}.`); + console.log(c.dim("Waiting for approval…")); + }, + }); + } catch (err) { + const message = (err as Error).message || "Device authorization failed."; + trackAuthLoginFailed("device", /expired/i.test(message) ? "flow_timeout" : "flow_error"); + console.error(c.error(message)); + failCommand(); + } + + const credential = { + type: "oauth" as const, + access_token: tokens.access_token, + ...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}), + source: "file_json" as const, + refreshable: false, + }; + let user: UserInfo; + try { + user = await new AuthClient().getCurrentUser(credential); + } catch (err) { + await revokeDeviceTokens(tokens); + trackAuthLoginFailed("device", "rejected"); + console.error( + c.error( + `HeyGen could not verify the approved device session; no credential was saved. ${(err as Error).message}`, + ), + ); + failCommand(); + } + + try { + await persistVerifiedOAuthSession(tokens, toStoredUserInfo(user)); + } catch (err) { + await revokeDeviceTokens(tokens); + trackAuthLoginFailed("device", "flow_error"); + console.error( + c.error( + `Could not save the verified device session; it was revoked. ${(err as Error).message}`, + ), + ); + failCommand(); + } + + const id = identityKey(user); + if (id) identifyUser(id); + trackAuthLoginCompleted("device", id); + const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)"; + console.log(c.success(`✓ Signed in as ${identity}.`)); +} + +async function revokeDeviceTokens(tokens: { + access_token: string; + refresh_token?: string; +}): Promise { + await revokeTokens(tokens.access_token, { token_type_hint: "access_token" }); + if (tokens.refresh_token) { + await revokeTokens(tokens.refresh_token, { + token_type_hint: "refresh_token", + }); + } +} + // fallow-ignore-next-line complexity async function runOAuthLogin(): Promise { assertOAuthConfiguredOrExit(); @@ -286,7 +412,11 @@ async function rollback(previous: Credentials): Promise { async function verifyAndReport(key: string): Promise { const client = new AuthClient(); try { - const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" }); + const user = await client.getCurrentUser({ + type: "api_key", + key, + source: "file_json", + }); // Persist the friendly-display block next to the now-verified api_key // so `auth status` can show a recognizable identity. Best-effort. await persistUserInfo(user); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index ad7bd9e1bb..6b8874a78f 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -507,7 +507,8 @@ export function trackBrowserInstall(): void { // dashboards — a completed sign-in, a browser flow the user abandoned, and a // rejected key all look identical (i.e. absent). These three events close that // gap so the sign-in funnel is measurable like the render funnel already is. -// `method` is "oauth" (the default browser PKCE flow) or "api_key". No token, +// `method` is "oauth" (the default browser PKCE flow), "device" (attended +// RFC 8628 flow), or "api_key". No token, // key, identity, email, or free text is ever attached — only the method and a // low-cardinality outcome/reason. // @@ -516,7 +517,7 @@ export function trackBrowserInstall(): void { // today (events attribute to the install's anonymousId), but pre-plumbing it // makes attributing a completed sign-in to a resolved identity later a one-line // change at the callsite rather than a signature sweep. -export type AuthLoginMethod = "oauth" | "api_key"; +export type AuthLoginMethod = "oauth" | "device" | "api_key"; export type AuthLoginFailureReason = | "flow_error" // OAuth authorization/exchange threw a real error | "flow_timeout" // OAuth callback wait elapsed (user closed the tab / walked away) From b1c4b8091ebec161d1a371cf2ea430c0bc2fe24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 28 Jul 2026 10:18:54 +0000 Subject: [PATCH 2/4] refactor(auth): simplify device authorization flow --- packages/cli/src/auth/index.ts | 1 - packages/cli/src/auth/oauth.test.ts | 54 ++++----- packages/cli/src/auth/oauth.ts | 164 ++++++++++++++++++---------- 3 files changed, 124 insertions(+), 95 deletions(-) diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 8165dd2850..9d9562b1cc 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -33,7 +33,6 @@ export type { UserInfo } from "./client.js"; export { assertOAuthConfiguredOrExit, - persistFreshOAuth, persistVerifiedOAuthSession, refreshTokens, revokeTokens, diff --git a/packages/cli/src/auth/oauth.test.ts b/packages/cli/src/auth/oauth.test.ts index 305e966ccd..3bc37afc88 100644 --- a/packages/cli/src/auth/oauth.test.ts +++ b/packages/cli/src/auth/oauth.test.ts @@ -29,6 +29,19 @@ vi.mock("./browser.js", () => ({ openBrowser: vi.fn(async () => ({ opened: true })), })); +function tokenFetch(body: Record): typeof fetch { + return (async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; +} + +async function loginWithTokens(body: Record) { + await startAuthorizationCodeFlow({ fetchImpl: tokenFetch(body) }); + return (await readStore()).credentials; +} + describe("auth/oauth", () => { let fixture: Awaited>; @@ -167,11 +180,7 @@ describe("auth/oauth", () => { 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; + const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); await refreshTokens("keep_me_rt", { fetchImpl }); const { credentials } = await readStore(); expect(credentials.oauth?.access_token).toBe("new_at"); @@ -181,11 +190,7 @@ describe("auth/oauth", () => { 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; + const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 60 }); await refreshTokens("old_rt", { fetchImpl }); const { credentials } = await readStore(); expect(credentials.api_key).toBe("hg_keep"); @@ -211,11 +216,7 @@ describe("auth/oauth", () => { }), { mode: 0o600 }, ); - 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; + const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); await refreshTokens("keep_me_rt", { fetchImpl }); const onDisk = JSON.parse(await fs.readFile(path, "utf8")); @@ -310,14 +311,6 @@ describe("auth/oauth", () => { }); describe("startAuthorizationCodeFlow persistence", () => { - function tokenFetch(body: Record): 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. @@ -327,13 +320,10 @@ describe("auth/oauth", () => { refresh_token: "OLD_rt_should_not_survive", }, }); - const fetchImpl = tokenFetch({ + const credentials = await loginWithTokens({ 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(); @@ -341,13 +331,10 @@ describe("auth/oauth", () => { it("preserves a co-located api_key across fresh login", async () => { await writeStore({ api_key: "hg_keep_me" }); - const fetchImpl = tokenFetch({ + const credentials = await loginWithTokens({ 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"); @@ -367,13 +354,10 @@ describe("auth/oauth", () => { }), { mode: 0o600 }, ); - const fetchImpl = tokenFetch({ + const credentials = await loginWithTokens({ access_token: "new_at", expires_in: 3600, }); - await startAuthorizationCodeFlow({ fetchImpl }); - - const { credentials } = await readStore(); expect(credentials.oauth?.access_token).toBe("new_at"); expect(credentials.user).toEqual({ email: "jane@example.com", diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index cf4b949698..d43afceaf8 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -214,80 +214,115 @@ export async function startAuthorizationCodeFlow( export async function startDeviceAuthorizationFlow( opts: DeviceAuthorizationFlowOptions = {}, ): Promise { - const clientId = resolveClientId(); - const fetchImpl = opts.fetchImpl ?? fetch; - const sleepImpl = - opts.sleepImpl ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); - const now = opts.now ?? Date.now; - const scope = opts.scope ?? DEFAULT_SCOPES; + const runtime: DeviceFlowRuntime = { + clientId: resolveClientId(), + fetchImpl: opts.fetchImpl ?? fetch, + sleepImpl: + opts.sleepImpl ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))), + now: opts.now ?? Date.now, + }; + const issuance = await requestDeviceAuthorization(runtime, opts.scope ?? DEFAULT_SCOPES); + await opts.onChallenge?.({ + userCode: issuance.userCode, + verificationUri: issuance.verificationUri, + }); + return await pollDeviceToken(runtime, issuance); +} - let issuanceResponse: Response; +interface DeviceFlowRuntime { + clientId: string; + fetchImpl: typeof fetch; + sleepImpl: (ms: number) => Promise; + now: () => number; +} + +async function requestDeviceAuthorization( + runtime: DeviceFlowRuntime, + scope: string, +): Promise { + let response: Response; try { - issuanceResponse = await fetchImpl(deviceAuthorizationEndpoint(), { + response = await runtime.fetchImpl(deviceAuthorizationEndpoint(), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", }, - body: new URLSearchParams({ client_id: clientId, scope }).toString(), + body: new URLSearchParams({ client_id: runtime.clientId, scope }).toString(), }); } catch { throw ErrDeviceAuthFailed("could not reach the authorization server"); } - if (!issuanceResponse.ok) { - throw ErrDeviceAuthFailed(`authorization server returned HTTP ${issuanceResponse.status}`); + if (!response.ok) { + throw ErrDeviceAuthFailed(`authorization server returned HTTP ${response.status}`); } + return parseDeviceAuthorizationResponse(await readJsonOrDeviceError(response)); +} - const issuance = parseDeviceAuthorizationResponse(await readJsonOrDeviceError(issuanceResponse)); - await opts.onChallenge?.({ - userCode: issuance.userCode, - verificationUri: issuance.verificationUri, - }); - - const deadline = now() + Math.min(issuance.expiresIn, MAX_DEVICE_FLOW_SECONDS) * 1000; +async function pollDeviceToken( + runtime: DeviceFlowRuntime, + issuance: ParsedDeviceAuthorization, +): Promise { + const deadline = runtime.now() + Math.min(issuance.expiresIn, MAX_DEVICE_FLOW_SECONDS) * 1000; let intervalSeconds = issuance.interval; - while (now() < deadline) { - const remainingMs = deadline - now(); + while (runtime.now() < deadline) { + const remainingMs = deadline - runtime.now(); if (remainingMs <= 0) break; - await sleepImpl(Math.min(intervalSeconds * 1000, remainingMs)); - - let pollResponse: Response; - try { - pollResponse = await fetchImpl(tokenEndpoint(), { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: DEVICE_CODE_GRANT_TYPE, - device_code: issuance.deviceCode, - client_id: clientId, - }).toString(), - }); - } catch { - throw ErrDeviceAuthFailed("lost contact with the authorization server"); - } - - if (pollResponse.ok) { - return parseTokenResponse(await readJsonOrDeviceError(pollResponse)); - } + await runtime.sleepImpl(Math.min(intervalSeconds * 1000, remainingMs)); - const error = await readDeviceOAuthError(pollResponse); - if (error === "authorization_pending") continue; - if (error === "slow_down") { + const result = await evaluateDevicePollResponse( + await requestDeviceToken(runtime, issuance.deviceCode), + ); + if (result.tokens) return result.tokens; + if (result.slowDown) { intervalSeconds = Math.min(intervalSeconds + 5, MAX_DEVICE_POLL_SECONDS); - continue; } - if (error === "access_denied") { + } + throw ErrDeviceAuthFailed("the code expired"); +} + +async function requestDeviceToken( + runtime: DeviceFlowRuntime, + deviceCode: string, +): Promise { + try { + return await runtime.fetchImpl(tokenEndpoint(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: deviceCode, + client_id: runtime.clientId, + }).toString(), + }); + } catch { + throw ErrDeviceAuthFailed("lost contact with the authorization server"); + } +} + +async function evaluateDevicePollResponse( + response: Response, +): Promise<{ tokens?: OAuthTokens; slowDown?: boolean }> { + if (response.ok) { + return { tokens: parseTokenResponse(await readJsonOrDeviceError(response)) }; + } + + const error = await readDeviceOAuthError(response); + switch (error) { + case "authorization_pending": + return {}; + case "slow_down": + return { slowDown: true }; + case "access_denied": throw ErrDeviceAuthFailed("access was denied"); - } - if (error === "expired_token") { + case "expired_token": throw ErrDeviceAuthFailed("the code expired"); - } - throw ErrDeviceAuthFailed(`authorization server returned HTTP ${pollResponse.status}`); + default: + throw ErrDeviceAuthFailed(`authorization server returned HTTP ${response.status}`); } - throw ErrDeviceAuthFailed("the code expired"); } export async function refreshTokens( @@ -589,22 +624,22 @@ interface ParsedDeviceAuthorization { } function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthorization { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - throw ErrDeviceAuthFailed("authorization server returned an invalid response"); - } - const data = payload as Record; + const data = requireDeviceAuthorizationRecord(payload); const deviceCode = stringField(data, "device_code"); const userCode = stringField(data, "user_code"); const verificationUri = stringField(data, "verification_uri"); const expiresIn = strictNumericField(data, "expires_in"); const interval = strictNumericField(data, "interval"); - if (!deviceCode || !isHeaderSafe(deviceCode) || !userCode || !verificationUri) { + if (!deviceCode || !isHeaderSafe(deviceCode)) { + throw ErrDeviceAuthFailed("authorization server returned an invalid response"); + } + if (!userCode || !verificationUri) { throw ErrDeviceAuthFailed("authorization server returned an invalid response"); } if (!isSafeVerificationUri(verificationUri)) { throw ErrDeviceAuthFailed("authorization server returned an unsafe verification URL"); } - if (!expiresIn || expiresIn <= 0 || !interval || interval <= 0) { + if (!isPositiveNumber(expiresIn) || !isPositiveNumber(interval)) { throw ErrDeviceAuthFailed("authorization server returned invalid timing values"); } return { @@ -619,6 +654,17 @@ function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthori }; } +function requireDeviceAuthorizationRecord(payload: unknown): Record { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw ErrDeviceAuthFailed("authorization server returned an invalid response"); + } + return payload as Record; +} + +function isPositiveNumber(value: number | undefined): value is number { + return value !== undefined && value > 0; +} + function isSafeVerificationUri(value: string): boolean { try { const url = new URL(value); From f207e7db5eae67a7c06f25c45635bf46119c71b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 4 Aug 2026 02:36:08 +0000 Subject: [PATCH 3/4] fix(cli): harden device authorization flow --- packages/cli/src/auth/oauth.test.ts | 164 ++++++++++++++++ packages/cli/src/auth/oauth.ts | 188 +++++++++++++------ packages/cli/src/commands/auth/login.test.ts | 49 ++++- packages/cli/src/commands/auth/login.ts | 14 +- 4 files changed, 354 insertions(+), 61 deletions(-) diff --git a/packages/cli/src/auth/oauth.test.ts b/packages/cli/src/auth/oauth.test.ts index 3bc37afc88..626a4c55f6 100644 --- a/packages/cli/src/auth/oauth.test.ts +++ b/packages/cli/src/auth/oauth.test.ts @@ -37,6 +37,20 @@ function tokenFetch(body: Record): typeof fetch { })) as unknown as typeof fetch; } +function deviceAuthorizationResponse(overrides: Record = {}): Response { + return new Response( + JSON.stringify({ + device_code: "secret-device-code", + user_code: "ABCD-2345", + verification_uri: "https://app.heygen.com/oauth/device", + expires_in: 600, + interval: 5, + ...overrides, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + async function loginWithTokens(body: Record) { await startAuthorizationCodeFlow({ fetchImpl: tokenFetch(body) }); return (await readStore()).credentials; @@ -406,6 +420,23 @@ describe("auth/oauth", () => { expect(onDisk.future_root_field).toEqual({ keep: true }); }); + it("recovers a corrupt credential file before installing a verified session", async () => { + const path = (await import("./paths.js")).credentialPath(); + await fs.writeFile(path, "{not-json", { mode: 0o600 }); + + await persistVerifiedOAuthSession( + { access_token: "device-at", refresh_token: "device-rt" }, + { email: "new@example.com" }, + ); + + const onDisk = JSON.parse(await fs.readFile(path, "utf8")); + expect(onDisk.oauth).toMatchObject({ + access_token: "device-at", + refresh_token: "device-rt", + }); + expect(onDisk.user).toEqual({ email: "new@example.com" }); + }); + it("polls pending and slow_down responses without persisting before identity verification", async () => { const requests: Array<{ url: string; body: URLSearchParams }> = []; const responses = [ @@ -480,6 +511,138 @@ describe("auth/oauth", () => { expect((await readStore()).credentials.oauth?.access_token).toBe("device-at"); }); + it("uses the RFC default interval and presents a safe complete verification URL", async () => { + const responses = [ + deviceAuthorizationResponse({ + interval: undefined, + verification_uri_complete: "https://app.heygen.com/oauth/device?user_code=ABCD-2345", + }), + new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }), + ]; + const fetchImpl = (async () => { + const next = responses.shift(); + if (!next) throw new Error("unexpected fetch"); + return next; + }) as typeof fetch; + const sleeps: number[] = []; + const challenges: Array<{ + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + }> = []; + + await startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async (ms) => { + sleeps.push(ms); + }, + now: () => 1_000, + onChallenge: (challenge) => { + challenges.push(challenge); + }, + }); + + expect(sleeps).toEqual([5_000]); + expect(challenges).toEqual([ + { + userCode: "ABCD-2345", + verificationUri: "https://app.heygen.com/oauth/device", + verificationUriComplete: "https://app.heygen.com/oauth/device?user_code=ABCD-2345", + }, + ]); + }); + + it("treats HTTP 429 without an OAuth body as slow_down and honors Retry-After", async () => { + const responses = [ + deviceAuthorizationResponse(), + new Response("rate limited", { status: 429, headers: { "retry-after": "20" } }), + new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }), + ]; + const fetchImpl = (async () => { + const next = responses.shift(); + if (!next) throw new Error("unexpected fetch"); + return next; + }) as typeof fetch; + const sleeps: number[] = []; + + await startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async (ms) => { + sleeps.push(ms); + }, + now: () => 1_000, + }); + + expect(sleeps).toEqual([5_000, 20_000]); + }); + + it("canonicalizes an internationalized verification host before displaying it", async () => { + const unicodeUri = "https://rаypal.example/oauth/device"; + const responses = [ + deviceAuthorizationResponse({ verification_uri: unicodeUri }), + new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }), + ]; + const fetchImpl = (async () => { + const next = responses.shift(); + if (!next) throw new Error("unexpected fetch"); + return next; + }) as typeof fetch; + const challenges: Array<{ verificationUri: string }> = []; + + await startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async () => {}, + now: () => 1_000, + onChallenge: (challenge) => { + challenges.push(challenge); + }, + }); + + expect(challenges[0]?.verificationUri).toBe(new URL(unicodeUri).href); + expect(challenges[0]?.verificationUri).not.toBe(unicodeUri); + }); + + it("times out while reading a slow authorization response body", async () => { + const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { + const body = new ReadableStream({ + start(controller) { + init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted")), { + once: true, + }); + }, + }); + return new Response(body, { status: 200 }); + }) as typeof fetch; + + await expect( + startDeviceAuthorizationFlow({ fetchImpl, requestTimeoutMs: 5 }), + ).rejects.toThrow(/request timed out/); + }); + + it("times out a stalled token poll", async () => { + let call = 0; + const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { + call += 1; + if (call === 1) { + return deviceAuthorizationResponse({ interval: undefined }); + } + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }); + }) as typeof fetch; + + await expect( + startDeviceAuthorizationFlow({ + fetchImpl, + sleepImpl: async () => {}, + now: () => 1_000, + requestTimeoutMs: 5, + }), + ).rejects.toThrow(/request timed out/); + }); + it.each(["access_denied", "expired_token"])( "fails with a bounded error for %s without echoing the device code", async (oauthError) => { @@ -519,6 +682,7 @@ describe("auth/oauth", () => { it.each([ ["credential-bearing URL", "https://user:pass@app.heygen.com/oauth/device", 600, 5], + ["control-character URL", "https://app.heygen.com/oauth/\u001b[31m", 600, 5], ["prefix-parsed expiry", "https://app.heygen.com/oauth/device", "600seconds", 5], ["prefix-parsed interval", "https://app.heygen.com/oauth/device", 600, "5seconds"], ])("rejects an unsafe or malformed %s response", async (_name, uri, expiresIn, interval) => { diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index d43afceaf8..c4d561223b 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -78,6 +78,7 @@ const MAX_DEVICE_FLOW_SECONDS = 30 * 60; const MIN_DEVICE_POLL_SECONDS = 5; const MAX_DEVICE_POLL_SECONDS = 60; const MAX_DEVICE_RESPONSE_BYTES = 64 * 1024; +const DEVICE_REQUEST_TIMEOUT_MS = 15_000; function authorizeEndpoint(): string { return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL; @@ -114,6 +115,7 @@ export interface RefreshOptions { export interface DeviceAuthorizationChallenge { userCode: string; verificationUri: string; + verificationUriComplete?: string; } export interface DeviceAuthorizationFlowOptions { @@ -125,6 +127,8 @@ export interface DeviceAuthorizationFlowOptions { sleepImpl?: (ms: number) => Promise; /** Inject a monotonic-enough clock in epoch milliseconds (used by tests). */ now?: () => number; + /** Bound each authorization-server request, including its response body (default 15s). */ + requestTimeoutMs?: number; /** Present the user code and verification URI without exposing device_code. */ onChallenge?: (challenge: DeviceAuthorizationChallenge) => void | Promise; } @@ -220,11 +224,15 @@ export async function startDeviceAuthorizationFlow( sleepImpl: opts.sleepImpl ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))), now: opts.now ?? Date.now, + requestTimeoutMs: opts.requestTimeoutMs ?? DEVICE_REQUEST_TIMEOUT_MS, }; const issuance = await requestDeviceAuthorization(runtime, opts.scope ?? DEFAULT_SCOPES); await opts.onChallenge?.({ userCode: issuance.userCode, verificationUri: issuance.verificationUri, + ...(issuance.verificationUriComplete + ? { verificationUriComplete: issuance.verificationUriComplete } + : {}), }); return await pollDeviceToken(runtime, issuance); } @@ -234,29 +242,32 @@ interface DeviceFlowRuntime { fetchImpl: typeof fetch; sleepImpl: (ms: number) => Promise; now: () => number; + requestTimeoutMs: number; } async function requestDeviceAuthorization( runtime: DeviceFlowRuntime, scope: string, ): Promise { - let response: Response; - try { - response = await runtime.fetchImpl(deviceAuthorizationEndpoint(), { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - accept: "application/json", - }, - body: new URLSearchParams({ client_id: runtime.clientId, scope }).toString(), - }); - } catch { - throw ErrDeviceAuthFailed("could not reach the authorization server"); - } - if (!response.ok) { - throw ErrDeviceAuthFailed(`authorization server returned HTTP ${response.status}`); - } - return parseDeviceAuthorizationResponse(await readJsonOrDeviceError(response)); + return await withDeviceRequestTimeout( + runtime, + "could not reach the authorization server", + async (signal) => { + const response = await runtime.fetchImpl(deviceAuthorizationEndpoint(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: new URLSearchParams({ client_id: runtime.clientId, scope }).toString(), + signal, + }); + if (!response.ok) { + throw ErrDeviceAuthFailed(`authorization server returned HTTP ${response.status}`); + } + return parseDeviceAuthorizationResponse(await readJsonOrDeviceError(response)); + }, + ); } async function pollDeviceToken( @@ -270,12 +281,13 @@ async function pollDeviceToken( if (remainingMs <= 0) break; await runtime.sleepImpl(Math.min(intervalSeconds * 1000, remainingMs)); - const result = await evaluateDevicePollResponse( - await requestDeviceToken(runtime, issuance.deviceCode), - ); + const result = await requestDeviceToken(runtime, issuance.deviceCode); if (result.tokens) return result.tokens; if (result.slowDown) { - intervalSeconds = Math.min(intervalSeconds + 5, MAX_DEVICE_POLL_SECONDS); + intervalSeconds = Math.min( + Math.max(intervalSeconds + 5, result.retryAfterSeconds ?? 0), + MAX_DEVICE_POLL_SECONDS, + ); } } throw ErrDeviceAuthFailed("the code expired"); @@ -284,28 +296,39 @@ async function pollDeviceToken( async function requestDeviceToken( runtime: DeviceFlowRuntime, deviceCode: string, -): Promise { - try { - return await runtime.fetchImpl(tokenEndpoint(), { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: DEVICE_CODE_GRANT_TYPE, - device_code: deviceCode, - client_id: runtime.clientId, - }).toString(), - }); - } catch { - throw ErrDeviceAuthFailed("lost contact with the authorization server"); - } +): Promise { + return await withDeviceRequestTimeout( + runtime, + "lost contact with the authorization server", + async (signal) => { + const response = await runtime.fetchImpl(tokenEndpoint(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: deviceCode, + client_id: runtime.clientId, + }).toString(), + signal, + }); + return await evaluateDevicePollResponse(response, runtime.now()); + }, + ); +} + +interface DevicePollResult { + tokens?: OAuthTokens; + slowDown?: boolean; + retryAfterSeconds?: number; } async function evaluateDevicePollResponse( response: Response, -): Promise<{ tokens?: OAuthTokens; slowDown?: boolean }> { + nowMs: number, +): Promise { if (response.ok) { return { tokens: parseTokenResponse(await readJsonOrDeviceError(response)) }; } @@ -315,16 +338,48 @@ async function evaluateDevicePollResponse( case "authorization_pending": return {}; case "slow_down": - return { slowDown: true }; + return { slowDown: true, retryAfterSeconds: retryAfterSeconds(response, nowMs) }; case "access_denied": throw ErrDeviceAuthFailed("access was denied"); case "expired_token": throw ErrDeviceAuthFailed("the code expired"); default: + if (response.status === 429) { + return { slowDown: true, retryAfterSeconds: retryAfterSeconds(response, nowMs) }; + } throw ErrDeviceAuthFailed(`authorization server returned HTTP ${response.status}`); } } +async function withDeviceRequestTimeout( + runtime: DeviceFlowRuntime, + networkError: string, + operation: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), runtime.requestTimeoutMs); + try { + return await operation(controller.signal); + } catch (err) { + if (controller.signal.aborted) { + throw ErrDeviceAuthFailed("authorization server request timed out"); + } + if (isAuthError(err)) throw err; + throw ErrDeviceAuthFailed(networkError); + } finally { + clearTimeout(timer); + } +} + +function retryAfterSeconds(response: Response, nowMs: number): number | undefined { + const value = response.headers.get("retry-after")?.trim(); + if (!value) return undefined; + if (/^\d+$/.test(value)) return Math.min(Number(value), MAX_DEVICE_POLL_SECONDS); + const retryAt = Date.parse(value); + if (!Number.isFinite(retryAt)) return undefined; + return Math.min(Math.max(Math.ceil((retryAt - nowMs) / 1000), 0), MAX_DEVICE_POLL_SECONDS); +} + export async function refreshTokens( refresh_token: string, opts: RefreshOptions = {}, @@ -592,7 +647,14 @@ export async function persistVerifiedOAuthSession( tokens: OAuthTokens, user: StoredUserInfo, ): Promise { - const { credentials } = await readStore(); + let credentials: Credentials = {}; + try { + ({ credentials } = await readStore()); + } catch { + // Match the other fresh-login path: a corrupt prior file must not prevent + // installing a newly verified session. + credentials = {}; + } const next: Credentials = { ...credentials, oauth: { ...tokens }, @@ -619,6 +681,7 @@ interface ParsedDeviceAuthorization { deviceCode: string; userCode: string; verificationUri: string; + verificationUriComplete?: string; expiresIn: number; interval: number; } @@ -627,33 +690,49 @@ function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthori const data = requireDeviceAuthorizationRecord(payload); const deviceCode = stringField(data, "device_code"); const userCode = stringField(data, "user_code"); - const verificationUri = stringField(data, "verification_uri"); + const verificationUri = requiredSafeVerificationUri(data, "verification_uri"); + const verificationUriComplete = optionalSafeVerificationUri(data, "verification_uri_complete"); const expiresIn = strictNumericField(data, "expires_in"); const interval = strictNumericField(data, "interval"); if (!deviceCode || !isHeaderSafe(deviceCode)) { throw ErrDeviceAuthFailed("authorization server returned an invalid response"); } - if (!userCode || !verificationUri) { + if (!userCode || !isHeaderSafe(userCode)) { throw ErrDeviceAuthFailed("authorization server returned an invalid response"); } - if (!isSafeVerificationUri(verificationUri)) { - throw ErrDeviceAuthFailed("authorization server returned an unsafe verification URL"); - } - if (!isPositiveNumber(expiresIn) || !isPositiveNumber(interval)) { + if ( + !isPositiveNumber(expiresIn) || + (data["interval"] !== undefined && !isPositiveNumber(interval)) + ) { throw ErrDeviceAuthFailed("authorization server returned invalid timing values"); } return { deviceCode, userCode, verificationUri, + ...(verificationUriComplete ? { verificationUriComplete } : {}), expiresIn, interval: Math.min( - Math.max(Math.ceil(interval), MIN_DEVICE_POLL_SECONDS), + Math.max(Math.ceil(interval ?? MIN_DEVICE_POLL_SECONDS), MIN_DEVICE_POLL_SECONDS), MAX_DEVICE_POLL_SECONDS, ), }; } +function requiredSafeVerificationUri(data: Record, key: string): string { + const value = normalizeSafeVerificationUri(stringField(data, key)); + if (!value) throw ErrDeviceAuthFailed("authorization server returned an unsafe verification URL"); + return value; +} + +function optionalSafeVerificationUri( + data: Record, + key: string, +): string | undefined { + if (data[key] === undefined) return undefined; + return requiredSafeVerificationUri(data, key); +} + function requireDeviceAuthorizationRecord(payload: unknown): Record { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw ErrDeviceAuthFailed("authorization server returned an invalid response"); @@ -665,16 +744,17 @@ function isPositiveNumber(value: number | undefined): value is number { return value !== undefined && value > 0; } -function isSafeVerificationUri(value: string): boolean { +function normalizeSafeVerificationUri(value: string | undefined): string | undefined { + if (!value || !isHeaderSafe(value)) return undefined; try { const url = new URL(value); - if (url.username || url.password) return false; - return ( + if (url.username || url.password) return undefined; + const allowed = url.protocol === "https:" || - (url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname)) - ); + (url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname)); + return allowed ? url.href : undefined; } catch { - return false; + return undefined; } } diff --git a/packages/cli/src/commands/auth/login.test.ts b/packages/cli/src/commands/auth/login.test.ts index 726fb1f6c5..9a5d666deb 100644 --- a/packages/cli/src/commands/auth/login.test.ts +++ b/packages/cli/src/commands/auth/login.test.ts @@ -29,10 +29,17 @@ const telemetry = vi.hoisted(() => ({ })); vi.mock("../../telemetry/index.js", () => telemetry); +const deviceChallenge = vi.hoisted(() => ({ + verificationUriComplete: undefined as string | undefined, +})); + const deviceAuth = vi.hoisted(() => ({ start: vi.fn(async (options?: { onChallenge?: (value: unknown) => void }) => { options?.onChallenge?.({ verificationUri: "https://app.heygen.com/oauth/device", + ...(deviceChallenge.verificationUriComplete + ? { verificationUriComplete: deviceChallenge.verificationUriComplete } + : {}), userCode: "ABCD-2345", }); return { @@ -74,10 +81,19 @@ describe("auth login", () => { beforeEach(async () => { runtimeEnv = Object.fromEntries( - ["CI", "SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "BROWSER", "HF_NO_BROWSER"].map((key) => [ - key, - process.env[key], - ]), + [ + "CI", + "SSH_CONNECTION", + "SSH_CLIENT", + "SSH_TTY", + "BROWSER", + "HF_NO_BROWSER", + "CODESPACES", + "GITHUB_CODESPACES", + "REMOTE_CONTAINERS", + "GITPOD_WORKSPACE_ID", + "container", + ].map((key) => [key, process.env[key]]), ); for (const key of Object.keys(runtimeEnv)) delete process.env[key]; stdinTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); @@ -94,6 +110,7 @@ describe("auth login", () => { dir = envFixture.dir; verifyState.reject = false; verifyState.user = { email: "alice@example.com" }; + deviceChallenge.verificationUriComplete = undefined; for (const fn of Object.values(telemetry)) fn.mockClear(); for (const fn of Object.values(deviceAuth)) fn.mockClear(); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -266,6 +283,30 @@ describe("auth login", () => { ); }); + it.each([ + "CODESPACES", + "GITHUB_CODESPACES", + "REMOTE_CONTAINERS", + "GITPOD_WORKSPACE_ID", + "container", + ])("requires --device in the %s remote environment", async (name) => { + process.env[name] = "true"; + await expect(runCommand({})).rejects.toThrow(/Invalid command usage/); + expect(deviceAuth.start).not.toHaveBeenCalled(); + }); + + it("opens verification_uri_complete without asking the user to re-enter the code", async () => { + deviceChallenge.verificationUriComplete = + "https://app.heygen.com/oauth/device?user_code=ABCD-2345"; + + await runCommand({ device: true }); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining("https://app.heygen.com/oauth/device?user_code=ABCD-2345"), + ); + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining("Enter code")); + }); + it("verifies the device token before persisting it", async () => { verifyState.user = { email: "device@example.com" }; await runCommand({ device: true }); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 2be2b9d282..e116d4aedf 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -99,12 +99,20 @@ export default defineCommand({ }); function isRemoteOrHeadless(): boolean { + const remoteEnvironment = [ + "CODESPACES", + "GITHUB_CODESPACES", + "REMOTE_CONTAINERS", + "GITPOD_WORKSPACE_ID", + "container", + ].some(envFlagEnabled); return Boolean( process.env["SSH_CONNECTION"] || process.env["SSH_CLIENT"] || process.env["SSH_TTY"] || process.env["BROWSER"] === "none" || process.env["HF_NO_BROWSER"] === "1" || + remoteEnvironment || process.stdout.isTTY !== true, ); } @@ -135,9 +143,9 @@ async function runDeviceLogin(): Promise { let tokens; try { tokens = await startDeviceAuthorizationFlow({ - onChallenge: ({ verificationUri, userCode }) => { - console.log(`Open ${c.accent(verificationUri)} in a browser.`); - console.log(`Enter code ${c.bold(userCode)}.`); + onChallenge: ({ verificationUri, verificationUriComplete, userCode }) => { + console.log(`Open ${c.accent(verificationUriComplete ?? verificationUri)} in a browser.`); + if (!verificationUriComplete) console.log(`Enter code ${c.bold(userCode)}.`); console.log(c.dim("Waiting for approval…")); }, }); From 1c89e33811f2cd7240843b77be2df700fb3aaadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 4 Aug 2026 03:27:51 +0000 Subject: [PATCH 4/4] refactor(cli): simplify device auth validation tests --- packages/cli/src/auth/oauth.test.ts | 37 +++++++++++++---------------- packages/cli/src/auth/oauth.ts | 29 +++++++++++++++------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/auth/oauth.test.ts b/packages/cli/src/auth/oauth.test.ts index 626a4c55f6..fcc9ac5150 100644 --- a/packages/cli/src/auth/oauth.test.ts +++ b/packages/cli/src/auth/oauth.test.ts @@ -51,6 +51,18 @@ function deviceAuthorizationResponse(overrides: Record = {}): R ); } +function queuedFetch( + responses: Response[], + onRequest?: (url: string | URL | Request, init?: RequestInit) => void, +): typeof fetch { + return (async (url: string | URL | Request, init?: RequestInit) => { + onRequest?.(url, init); + const next = responses.shift(); + if (!next) throw new Error("unexpected fetch"); + return next; + }) as typeof fetch; +} + async function loginWithTokens(body: Record) { await startAuthorizationCodeFlow({ fetchImpl: tokenFetch(body) }); return (await readStore()).credentials; @@ -469,15 +481,12 @@ describe("auth/oauth", () => { { status: 200, headers: { "content-type": "application/json" } }, ), ]; - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + const fetchImpl = queuedFetch(responses, (url, init) => { requests.push({ url: String(url), body: new URLSearchParams(String(init?.body ?? "")), }); - const next = responses.shift(); - if (!next) throw new Error("unexpected fetch"); - return next; - }) as typeof fetch; + }); const sleeps: number[] = []; const challenges: Array<{ userCode: string; verificationUri: string }> = []; @@ -519,11 +528,7 @@ describe("auth/oauth", () => { }), new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }), ]; - const fetchImpl = (async () => { - const next = responses.shift(); - if (!next) throw new Error("unexpected fetch"); - return next; - }) as typeof fetch; + const fetchImpl = queuedFetch(responses); const sleeps: number[] = []; const challenges: Array<{ userCode: string; @@ -558,11 +563,7 @@ describe("auth/oauth", () => { new Response("rate limited", { status: 429, headers: { "retry-after": "20" } }), new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }), ]; - const fetchImpl = (async () => { - const next = responses.shift(); - if (!next) throw new Error("unexpected fetch"); - return next; - }) as typeof fetch; + const fetchImpl = queuedFetch(responses); const sleeps: number[] = []; await startDeviceAuthorizationFlow({ @@ -582,11 +583,7 @@ describe("auth/oauth", () => { deviceAuthorizationResponse({ verification_uri: unicodeUri }), new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }), ]; - const fetchImpl = (async () => { - const next = responses.shift(); - if (!next) throw new Error("unexpected fetch"); - return next; - }) as typeof fetch; + const fetchImpl = queuedFetch(responses); const challenges: Array<{ verificationUri: string }> = []; await startDeviceAuthorizationFlow({ diff --git a/packages/cli/src/auth/oauth.ts b/packages/cli/src/auth/oauth.ts index c4d561223b..a1c0a20476 100644 --- a/packages/cli/src/auth/oauth.ts +++ b/packages/cli/src/auth/oauth.ts @@ -694,12 +694,29 @@ function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthori const verificationUriComplete = optionalSafeVerificationUri(data, "verification_uri_complete"); const expiresIn = strictNumericField(data, "expires_in"); const interval = strictNumericField(data, "interval"); - if (!deviceCode || !isHeaderSafe(deviceCode)) { - throw ErrDeviceAuthFailed("authorization server returned an invalid response"); - } - if (!userCode || !isHeaderSafe(userCode)) { + requireSafeDeviceCode(deviceCode); + requireSafeDeviceCode(userCode); + const timing = normalizeDeviceAuthorizationTiming(data, expiresIn, interval); + return { + deviceCode, + userCode, + verificationUri, + ...(verificationUriComplete ? { verificationUriComplete } : {}), + ...timing, + }; +} + +function requireSafeDeviceCode(value: string | undefined): asserts value is string { + if (!value || !isHeaderSafe(value)) { throw ErrDeviceAuthFailed("authorization server returned an invalid response"); } +} + +function normalizeDeviceAuthorizationTiming( + data: Record, + expiresIn: number | undefined, + interval: number | undefined, +): Pick { if ( !isPositiveNumber(expiresIn) || (data["interval"] !== undefined && !isPositiveNumber(interval)) @@ -707,10 +724,6 @@ function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthori throw ErrDeviceAuthFailed("authorization server returned invalid timing values"); } return { - deviceCode, - userCode, - verificationUri, - ...(verificationUriComplete ? { verificationUriComplete } : {}), expiresIn, interval: Math.min( Math.max(Math.ceil(interval ?? MIN_DEVICE_POLL_SECONDS), MIN_DEVICE_POLL_SECONDS),