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
+3 -1
View File
@@ -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({
+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
// attributed on success. login.ts imports these via a dynamic import of
// telemetry/index.js; the mock intercepts it.
@@ -43,16 +29,90 @@ const telemetry = vi.hoisted(() => ({
}));
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 envFixture: EnvFixture;
let runtimeEnv: Record<string, string | undefined>;
let stdinTTYDescriptor: PropertyDescriptor | undefined;
let stdoutTTYDescriptor: PropertyDescriptor | undefined;
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-");
dir = envFixture.dir;
verifyState.reject = false;
verifyState.user = { email: "alice@example.com" };
deviceChallenge.verificationUriComplete = undefined;
for (const fn of Object.values(telemetry)) fn.mockClear();
for (const fn of Object.values(deviceAuth)) fn.mockClear();
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
@@ -60,16 +120,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<void> {
async function runCommand(args: Record<string, unknown>): Promise<void> {
const cmd = (await import("./login.js")).default;
// citty command run only reads `args` here.
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 () => {
verifyState.reject = true;
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 () => {
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 +273,90 @@ 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.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.
*
@@ -35,8 +35,11 @@ import {
isUserInfoEmpty,
readStore,
refreshTokens,
revokeTokens,
saveUserInfo,
persistVerifiedOAuthSession,
startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
tryResolveCredential,
userDisplayName,
writeStore,
@@ -63,18 +66,149 @@ 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 {
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
async function runOAuthLogin(): Promise<void> {
assertOAuthConfiguredOrExit();
@@ -286,7 +420,11 @@ async function rollback(previous: Credentials): Promise<void> {
async function verifyAndReport(key: string): Promise<UserInfo | null> {
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);