mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user