mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user