feat(cli): add device authorization login (#2836)

* feat(cli): add device authorization login

* refactor(auth): simplify device authorization flow

* fix(cli): harden device authorization flow

* refactor(cli): simplify device auth validation tests
This commit is contained in:
Miguel Ángel
2026-08-04 20:36:42 -07:00
committed by GitHub
parent a4eb602ee2
commit b9233525b2
9 changed files with 1143 additions and 66 deletions
+7 -1
View File
@@ -24,7 +24,13 @@ hyperframes cloud render . \
--output renders/intro.mp4 --output renders/intro.mp4
``` ```
For CI or another headless environment, save a long-lived API key instead: From an attended SSH or headless terminal, use the device flow. Open the displayed URL in any browser and enter the one-time code:
```bash
hyperframes auth login --device
```
Device login requires a TTY and is refused in CI. For unattended agents and CI, save a long-lived API key instead:
```bash ```bash
echo "$HEYGEN_API_KEY" | hyperframes auth login --api-key echo "$HEYGEN_API_KEY" | hyperframes auth login --api-key
+8
View File
@@ -9,6 +9,7 @@ export type AuthErrorCode =
| "API_ERROR" | "API_ERROR"
| "UNAUTHENTICATED" | "UNAUTHENTICATED"
| "OAUTH_NOT_CONFIGURED" | "OAUTH_NOT_CONFIGURED"
| "DEVICE_AUTH_FAILED"
| "REFRESH_FAILED"; | "REFRESH_FAILED";
export class AuthError extends Error { export class AuthError extends Error {
@@ -61,6 +62,13 @@ export const ErrRefreshFailed = (detail?: string) =>
"Run `hyperframes auth login` to re-authenticate.", "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 { export function isAuthError(err: unknown): err is AuthError {
return err instanceof AuthError; return err instanceof AuthError;
} }
+2
View File
@@ -33,7 +33,9 @@ export type { UserInfo } from "./client.js";
export { export {
assertOAuthConfiguredOrExit, assertOAuthConfiguredOrExit,
persistVerifiedOAuthSession,
refreshTokens, refreshTokens,
revokeTokens, revokeTokens,
startAuthorizationCodeFlow, startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
} from "./oauth.js"; } from "./oauth.js";
+396 -41
View File
@@ -4,10 +4,13 @@ import { setupTempAuthEnv } from "./_test-utils.js";
import { isAuthError } from "./errors.js"; import { isAuthError } from "./errors.js";
import { import {
parseTokenResponse, parseTokenResponse,
persistFreshOAuth,
persistVerifiedOAuthSession,
refreshTokens, refreshTokens,
resolveClientId, resolveClientId,
revokeTokens, revokeTokens,
startAuthorizationCodeFlow, startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
} from "./oauth.js"; } from "./oauth.js";
import { readStore, writeStore } from "./store.js"; import { readStore, writeStore } from "./store.js";
@@ -26,6 +29,45 @@ vi.mock("./browser.js", () => ({
openBrowser: vi.fn(async () => ({ opened: true })), openBrowser: vi.fn(async () => ({ opened: true })),
})); }));
function tokenFetch(body: Record<string, unknown>): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
}
function deviceAuthorizationResponse(overrides: Record<string, unknown> = {}): 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" } },
);
}
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<string, unknown>) {
await startAuthorizationCodeFlow({ fetchImpl: tokenFetch(body) });
return (await readStore()).credentials;
}
describe("auth/oauth", () => { describe("auth/oauth", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>; let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
@@ -70,7 +112,10 @@ describe("auth/oauth", () => {
}); });
it("accepts expires_in as a string (some servers serialize as string)", () => { 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(); expect(tokens.expires_at).toBeDefined();
}); });
@@ -103,7 +148,10 @@ describe("auth/oauth", () => {
it("clamps non-positive expires_in to avoid an immediate-refresh loop", () => { it("clamps non-positive expires_in to avoid an immediate-refresh loop", () => {
const zero = parseTokenResponse({ access_token: "at", expires_in: 0 }); 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 // both should resolve to a future time
expect(new Date(zero.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000); expect(new Date(zero.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
expect(new Date(negative.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000); expect(new Date(negative.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
@@ -158,11 +206,7 @@ describe("auth/oauth", () => {
expires_at: "2026-01-01T00:00:00Z", expires_at: "2026-01-01T00:00:00Z",
}, },
}); });
const fetchImpl = (async () => const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
await refreshTokens("keep_me_rt", { fetchImpl }); await refreshTokens("keep_me_rt", { fetchImpl });
const { credentials } = await readStore(); const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at"); expect(credentials.oauth?.access_token).toBe("new_at");
@@ -172,11 +216,7 @@ describe("auth/oauth", () => {
it("preserves an existing api_key when persisting refreshed oauth", async () => { it("preserves an existing api_key when persisting refreshed oauth", async () => {
await writeStore({ api_key: "hg_keep" }); await writeStore({ api_key: "hg_keep" });
const fetchImpl = (async () => const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 60 });
new Response(JSON.stringify({ access_token: "new_at", expires_in: 60 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
await refreshTokens("old_rt", { fetchImpl }); await refreshTokens("old_rt", { fetchImpl });
const { credentials } = await readStore(); const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_keep"); expect(credentials.api_key).toBe("hg_keep");
@@ -202,11 +242,7 @@ describe("auth/oauth", () => {
}), }),
{ mode: 0o600 }, { mode: 0o600 },
); );
const fetchImpl = (async () => const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
await refreshTokens("keep_me_rt", { fetchImpl }); await refreshTokens("keep_me_rt", { fetchImpl });
const onDisk = JSON.parse(await fs.readFile(path, "utf8")); const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
@@ -217,7 +253,9 @@ describe("auth/oauth", () => {
it("throws REFRESH_FAILED on 400/401", async () => { it("throws REFRESH_FAILED on 400/401", async () => {
const fetchImpl = (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) => { await expect(refreshTokens("bad_rt", { fetchImpl })).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "REFRESH_FAILED"; return isAuthError(err) && (err as { code: string }).code === "REFRESH_FAILED";
}); });
@@ -261,7 +299,10 @@ describe("auth/oauth", () => {
capturedBody = init?.body as string; capturedBody = init?.body as string;
return new Response("", { status: 200 }); return new Response("", { status: 200 });
}) as unknown as typeof fetch; }) 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"); expect(capturedBody).toContain("token_type_hint=refresh_token");
}); });
@@ -296,24 +337,19 @@ describe("auth/oauth", () => {
}); });
describe("startAuthorizationCodeFlow persistence", () => { describe("startAuthorizationCodeFlow persistence", () => {
function tokenFetch(body: Record<string, unknown>): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
}
it("overwrites the OAuth block on fresh login (no inherited refresh_token)", async () => { 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 // Pre-seed a prior session whose refresh_token must NOT leak into
// the new login when the new response omits one. // the new login when the new response omits one.
await writeStore({ 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 credentials = await loginWithTokens({
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.oauth?.access_token).toBe("new_at");
// Fresh login is a clean session — the old refresh_token is gone. // Fresh login is a clean session — the old refresh_token is gone.
expect(credentials.oauth?.refresh_token).toBeUndefined(); expect(credentials.oauth?.refresh_token).toBeUndefined();
@@ -321,10 +357,10 @@ describe("auth/oauth", () => {
it("preserves a co-located api_key across fresh login", async () => { it("preserves a co-located api_key across fresh login", async () => {
await writeStore({ api_key: "hg_keep_me" }); await writeStore({ api_key: "hg_keep_me" });
const fetchImpl = tokenFetch({ access_token: "new_at", refresh_token: "new_rt" }); const credentials = await loginWithTokens({
await startAuthorizationCodeFlow({ fetchImpl }); access_token: "new_at",
refresh_token: "new_rt",
const { credentials } = await readStore(); });
expect(credentials.api_key).toBe("hg_keep_me"); expect(credentials.api_key).toBe("hg_keep_me");
expect(credentials.oauth?.access_token).toBe("new_at"); expect(credentials.oauth?.access_token).toBe("new_at");
expect(credentials.oauth?.refresh_token).toBe("new_rt"); expect(credentials.oauth?.refresh_token).toBe("new_rt");
@@ -344,16 +380,335 @@ describe("auth/oauth", () => {
}), }),
{ mode: 0o600 }, { mode: 0o600 },
); );
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 }); const credentials = await loginWithTokens({
await startAuthorizationCodeFlow({ fetchImpl }); access_token: "new_at",
expires_in: 3600,
const { credentials } = await readStore(); });
expect(credentials.oauth?.access_token).toBe("new_at"); 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. // The unknown key is on a hidden slot — assert via the raw file.
const onDisk = JSON.parse(await fs.readFile(path, "utf8")); const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.future_field).toEqual({ keep: true }); 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("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 = [
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 = queuedFetch(responses, (url, init) => {
requests.push({
url: String(url),
body: new URLSearchParams(String(init?.body ?? "")),
});
});
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("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 = queuedFetch(responses);
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 = queuedFetch(responses);
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 = queuedFetch(responses);
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<Uint8Array>({
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<Response>((_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) => {
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],
["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) => {
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);
});
});
}); });
+405 -1
View File
@@ -34,7 +34,13 @@ import { failCommand } from "../utils/commandResult.js";
* Public client — no `client_secret`. * 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 { generatePkcePair, generateState } from "./pkce.js";
import { startLoopback } from "./loopback.js"; import { startLoopback } from "./loopback.js";
import { openBrowser } from "./browser.js"; import { openBrowser } from "./browser.js";
@@ -45,6 +51,7 @@ import {
writeStore, writeStore,
type Credentials, type Credentials,
type OAuthTokens, type OAuthTokens,
type StoredUserInfo,
} from "./store.js"; } from "./store.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
@@ -65,6 +72,13 @@ const DEFAULT_SCOPES = "openid profile email";
const DEFAULT_AUTHORIZE_URL = "https://app.heygen.com/oauth/authorize"; const DEFAULT_AUTHORIZE_URL = "https://app.heygen.com/oauth/authorize";
const DEFAULT_TOKEN_URL = "https://api2.heygen.com/v1/oauth/token"; const DEFAULT_TOKEN_URL = "https://api2.heygen.com/v1/oauth/token";
const DEFAULT_REVOKE_URL = "https://api2.heygen.com/v1/oauth/revoke"; 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;
const DEVICE_REQUEST_TIMEOUT_MS = 15_000;
function authorizeEndpoint(): string { function authorizeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL; return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL;
@@ -75,6 +89,9 @@ function tokenEndpoint(): string {
function revokeEndpoint(): string { function revokeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL; 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 { export interface AuthorizeFlowOptions {
/** Override scopes (default `openid profile email`). */ /** Override scopes (default `openid profile email`). */
@@ -95,6 +112,27 @@ export interface RefreshOptions {
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
} }
export interface DeviceAuthorizationChallenge {
userCode: string;
verificationUri: string;
verificationUriComplete?: 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<void>;
/** 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<void>;
}
/** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */ /** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */
export function resolveClientId(): string { export function resolveClientId(): string {
const override = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"]; const override = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"];
@@ -171,6 +209,177 @@ export async function startAuthorizationCodeFlow(
return { tokens }; 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<OAuthTokens> {
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,
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);
}
interface DeviceFlowRuntime {
clientId: string;
fetchImpl: typeof fetch;
sleepImpl: (ms: number) => Promise<void>;
now: () => number;
requestTimeoutMs: number;
}
async function requestDeviceAuthorization(
runtime: DeviceFlowRuntime,
scope: string,
): Promise<ParsedDeviceAuthorization> {
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(
runtime: DeviceFlowRuntime,
issuance: ParsedDeviceAuthorization,
): Promise<OAuthTokens> {
const deadline = runtime.now() + Math.min(issuance.expiresIn, MAX_DEVICE_FLOW_SECONDS) * 1000;
let intervalSeconds = issuance.interval;
while (runtime.now() < deadline) {
const remainingMs = deadline - runtime.now();
if (remainingMs <= 0) break;
await runtime.sleepImpl(Math.min(intervalSeconds * 1000, remainingMs));
const result = await requestDeviceToken(runtime, issuance.deviceCode);
if (result.tokens) return result.tokens;
if (result.slowDown) {
intervalSeconds = Math.min(
Math.max(intervalSeconds + 5, result.retryAfterSeconds ?? 0),
MAX_DEVICE_POLL_SECONDS,
);
}
}
throw ErrDeviceAuthFailed("the code expired");
}
async function requestDeviceToken(
runtime: DeviceFlowRuntime,
deviceCode: string,
): Promise<DevicePollResult> {
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,
nowMs: number,
): Promise<DevicePollResult> {
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, 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<T>(
runtime: DeviceFlowRuntime,
networkError: string,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
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( export async function refreshTokens(
refresh_token: string, refresh_token: string,
opts: RefreshOptions = {}, opts: RefreshOptions = {},
@@ -422,6 +631,201 @@ async function persistOAuth(
await writeStore({ ...existing, oauth }); await writeStore({ ...existing, oauth });
} }
/** Persist a verified fresh OAuth login while preserving cross-CLI fields. */
export async function persistFreshOAuth(tokens: OAuthTokens): Promise<void> {
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<void> {
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 },
};
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;
verificationUriComplete?: string;
expiresIn: number;
interval: number;
}
function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthorization {
const data = requireDeviceAuthorizationRecord(payload);
const deviceCode = stringField(data, "device_code");
const userCode = stringField(data, "user_code");
const verificationUri = requiredSafeVerificationUri(data, "verification_uri");
const verificationUriComplete = optionalSafeVerificationUri(data, "verification_uri_complete");
const expiresIn = strictNumericField(data, "expires_in");
const interval = strictNumericField(data, "interval");
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<string, unknown>,
expiresIn: number | undefined,
interval: number | undefined,
): Pick<ParsedDeviceAuthorization, "expiresIn" | "interval"> {
if (
!isPositiveNumber(expiresIn) ||
(data["interval"] !== undefined && !isPositiveNumber(interval))
) {
throw ErrDeviceAuthFailed("authorization server returned invalid timing values");
}
return {
expiresIn,
interval: Math.min(
Math.max(Math.ceil(interval ?? MIN_DEVICE_POLL_SECONDS), MIN_DEVICE_POLL_SECONDS),
MAX_DEVICE_POLL_SECONDS,
),
};
}
function requiredSafeVerificationUri(data: Record<string, unknown>, 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<string, unknown>,
key: string,
): string | undefined {
if (data[key] === undefined) return undefined;
return requiredSafeVerificationUri(data, key);
}
function requireDeviceAuthorizationRecord(payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw ErrDeviceAuthFailed("authorization server returned an invalid response");
}
return payload as Record<string, unknown>;
}
function isPositiveNumber(value: number | undefined): value is number {
return value !== undefined && value > 0;
}
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 undefined;
const allowed =
url.protocol === "https:" ||
(url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname));
return allowed ? url.href : undefined;
} catch {
return undefined;
}
}
async function readJsonOrDeviceError(res: Response): Promise<unknown> {
return await readBoundedDeviceJson(res);
}
async function readDeviceOAuthError(res: Response): Promise<string | undefined> {
try {
const payload = await readBoundedDeviceJson(res);
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
const error = (payload as Record<string, unknown>)["error"];
return typeof error === "string" ? error : undefined;
} catch {
return undefined;
}
}
function strictNumericField(obj: Record<string, unknown>, 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<unknown> {
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<unknown> { async function readJsonOrThrow(res: Response): Promise<unknown> {
try { try {
return await res.json(); return await res.json();
+3 -1
View File
@@ -17,6 +17,7 @@ import { c } from "../ui/colors.js";
export const examples: Example[] = [ export const examples: Example[] = [
["Sign in via browser (OAuth)", "hyperframes auth login"], ["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 (interactive)", "hyperframes auth login --api-key"],
["Save an API key from stdin", "echo $HEYGEN_API_KEY | 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"], ["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.accent("~/.heygen/credentials")} and are shared with heygen-cli.
${c.bold("SUBCOMMANDS:")} ${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("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("refresh")} ${c.dim("Force-refresh the OAuth access token.")}
${c.accent("logout")} ${c.dim("Remove the stored credential (--keep-api-key for OAuth-only).")} ${c.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_API_URL")} Override the API base URL (default https://api.heygen.com).
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen). ${c.accent("HEYGEN_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_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({ export default defineCommand({
+179 -18
View File
@@ -18,20 +18,6 @@ const verifyState = vi.hoisted(
}, },
); );
vi.mock("../../auth/index.js", async (orig) => {
const actual = await orig<typeof import("../../auth/index.js")>();
class MockAuthClient {
async getCurrentUser(): Promise<Record<string, unknown>> {
if (verifyState.reject) {
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
throw rej("invalid key");
}
return verifyState.user;
}
}
return { ...actual, AuthClient: MockAuthClient };
});
// Spy on the telemetry the login flow emits, so we can assert the identity is // 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 // attributed on success. login.ts imports these via a dynamic import of
// telemetry/index.js; the mock intercepts it. // telemetry/index.js; the mock intercepts it.
@@ -43,16 +29,90 @@ const telemetry = vi.hoisted(() => ({
})); }));
vi.mock("../../telemetry/index.js", () => telemetry); vi.mock("../../telemetry/index.js", () => telemetry);
describe("auth login --api-key rollback", () => { 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 {
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<typeof import("../../auth/index.js")>();
class MockAuthClient {
async getCurrentUser(): Promise<Record<string, unknown>> {
if (verifyState.reject) {
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
throw rej("invalid token");
}
return verifyState.user;
}
}
return {
...actual,
AuthClient: MockAuthClient,
startDeviceAuthorizationFlow: deviceAuth.start,
persistVerifiedOAuthSession: deviceAuth.persist,
revokeTokens: deviceAuth.revoke,
};
});
describe("auth login", () => {
let dir: string; let dir: string;
let envFixture: EnvFixture; let envFixture: EnvFixture;
let runtimeEnv: Record<string, string | undefined>;
let stdinTTYDescriptor: PropertyDescriptor | undefined;
let stdoutTTYDescriptor: PropertyDescriptor | undefined;
beforeEach(async () => { beforeEach(async () => {
runtimeEnv = Object.fromEntries(
[
"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");
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-"); envFixture = await setupTempAuthEnv("hf-login-");
dir = envFixture.dir; dir = envFixture.dir;
verifyState.reject = false; verifyState.reject = false;
verifyState.user = { email: "alice@example.com" }; verifyState.user = { email: "alice@example.com" };
deviceChallenge.verificationUriComplete = undefined;
for (const fn of Object.values(telemetry)) fn.mockClear(); 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, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
}); });
@@ -60,16 +120,28 @@ describe("auth login --api-key rollback", () => {
afterEach(async () => { afterEach(async () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
await envFixture.restore(); 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<void> { async function runCommand(args: Record<string, unknown>): Promise<void> {
const cmd = (await import("./login.js")).default; const cmd = (await import("./login.js")).default;
// citty command run only reads `args` here. // citty command run only reads `args` here.
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({ await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
args: { "api-key": apiKey }, args,
}); });
} }
async function runLogin(apiKey: string): Promise<void> {
await runCommand({ "api-key": apiKey });
}
it("removes the rejected key on a failed FIRST login (no prior credential)", async () => { it("removes the rejected key on a failed FIRST login (no prior credential)", async () => {
verifyState.reject = true; verifyState.reject = true;
await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError); await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError);
@@ -128,7 +200,10 @@ describe("auth login --api-key rollback", () => {
}); });
it("rollback on a rejected key restores the previous user block too", async () => { 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; verifyState.reject = true;
await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError); await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError);
@@ -198,4 +273,90 @@ describe("auth login --api-key rollback", () => {
expect(onDisk.user).toEqual({ email: "jane@example.com" }); expect(onDisk.user).toEqual({ email: "jane@example.com" });
expect(onDisk.future_field).toEqual({ x: 1 }); 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.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 });
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();
});
}); });
+140 -2
View File
@@ -1,4 +1,4 @@
import { failCommand } from "../../utils/commandResult.js"; import { failCommand, failUsage } from "../../utils/commandResult.js";
/** /**
* `hyperframes auth login` — sign in to HeyGen. * `hyperframes auth login` — sign in to HeyGen.
* *
@@ -35,8 +35,11 @@ import {
isUserInfoEmpty, isUserInfoEmpty,
readStore, readStore,
refreshTokens, refreshTokens,
revokeTokens,
saveUserInfo, saveUserInfo,
persistVerifiedOAuthSession,
startAuthorizationCodeFlow, startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
tryResolveCredential, tryResolveCredential,
userDisplayName, userDisplayName,
writeStore, writeStore,
@@ -63,18 +66,149 @@ export default defineCommand({
type: "string", type: "string",
description: "API key value, or pass `--api-key` with no value to read from stdin / prompt.", 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 // fallow-ignore-next-line complexity
async run({ args }) { async run({ args }) {
const inlineKey = args["api-key"]; 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) { if (inlineKey !== undefined) {
await runApiKeyLogin(inlineKey); await runApiKeyLogin(inlineKey);
return; 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(); await runOAuthLogin();
}, },
}); });
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,
);
}
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<void> {
assertAttendedDeviceFlow();
assertOAuthConfiguredOrExit();
const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
await import("../../telemetry/index.js");
trackAuthLoginStarted("device");
let tokens;
try {
tokens = await startDeviceAuthorizationFlow({
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…"));
},
});
} 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<void> {
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 // fallow-ignore-next-line complexity
async function runOAuthLogin(): Promise<void> { async function runOAuthLogin(): Promise<void> {
assertOAuthConfiguredOrExit(); assertOAuthConfiguredOrExit();
@@ -286,7 +420,11 @@ async function rollback(previous: Credentials): Promise<void> {
async function verifyAndReport(key: string): Promise<UserInfo | null> { async function verifyAndReport(key: string): Promise<UserInfo | null> {
const client = new AuthClient(); const client = new AuthClient();
try { 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 // Persist the friendly-display block next to the now-verified api_key
// so `auth status` can show a recognizable identity. Best-effort. // so `auth status` can show a recognizable identity. Best-effort.
await persistUserInfo(user); await persistUserInfo(user);
+3 -2
View File
@@ -507,7 +507,8 @@ export function trackBrowserInstall(): void {
// dashboards — a completed sign-in, a browser flow the user abandoned, and a // 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 // 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. // 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 // key, identity, email, or free text is ever attached — only the method and a
// low-cardinality outcome/reason. // low-cardinality outcome/reason.
// //
@@ -516,7 +517,7 @@ export function trackBrowserInstall(): void {
// today (events attribute to the install's anonymousId), but pre-plumbing it // 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 // makes attributing a completed sign-in to a resolved identity later a one-line
// change at the callsite rather than a signature sweep. // 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 = export type AuthLoginFailureReason =
| "flow_error" // OAuth authorization/exchange threw a real error | "flow_error" // OAuth authorization/exchange threw a real error
| "flow_timeout" // OAuth callback wait elapsed (user closed the tab / walked away) | "flow_timeout" // OAuth callback wait elapsed (user closed the tab / walked away)