From 20d0fd25f4c3f3dbbbb26272bf98d128ddfbbdc6 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/2] 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 8c67b88a4d..e3351ff799 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -460,7 +460,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. // @@ -469,7 +470,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 c924fffdf167cf61bb18778e1be54b4ec7ec2f26 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/2] 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);