mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-07 07:09:15 +00:00
* fix(cli): publish with an expired login stops and asks you to log in again The server now answers 401 to a rejected credential instead of publishing anonymously, so the CLI says so and drops the warning for the old fallback. * fix(cli): publish refreshes an expired login first, and names a rejected API key An expired access token with a refresh token is refreshed (the cloud commands already do) instead of being sent and refused. Only the requests that carry the credential map a 401; an environment API key is named, since logging in would not replace it. * fix(cli): an expired login without a refresh token asks you to log in again A stored login past expiry with no refresh token now resolves as LOGIN_EXPIRED instead of "not signed in", so publish stops with the re-login message before sending anything. A signed-in publish that the server answers with an unclaimed project fails the same way and links nothing. * fix(cli): keep the resolver comment share at its base * fix(cli): an API-key publish still succeeds, and only an owned publish is linked * fix(cli): --update and --space refuse an API key instead of being ignored * fix(cli): publish uploads with the credential it checked, so --update and --space are never dropped * fix(cli): a login that expires during the bake is refreshed, not reported as expired * fix(cli): an expired login that cannot refresh stops publish before anything is sent * fix(cli): a token refresh never writes over a login that replaced it * fix(cli): a refresh that cannot read the saved login reports that, not a changed login
744 lines
27 KiB
TypeScript
744 lines
27 KiB
TypeScript
import { promises as fs } from "node:fs";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { setupTempAuthEnv } from "./_test-utils.js";
|
||
import { isAuthError } from "./errors.js";
|
||
import {
|
||
parseTokenResponse,
|
||
persistFreshOAuth,
|
||
persistVerifiedOAuthSession,
|
||
refreshTokens,
|
||
resolveClientId,
|
||
revokeTokens,
|
||
startAuthorizationCodeFlow,
|
||
startDeviceAuthorizationFlow,
|
||
} from "./oauth.js";
|
||
import { readStore, writeStore } from "./store.js";
|
||
|
||
// Mock the interactive bits so startAuthorizationCodeFlow runs headless.
|
||
vi.mock("./loopback.js", () => ({
|
||
startLoopback: vi.fn(async () => ({
|
||
result: Promise.resolve({
|
||
code: "auth_code_123",
|
||
redirectUri: "http://127.0.0.1:12345/oauth/callback",
|
||
}),
|
||
redirectUri: "http://127.0.0.1:12345/oauth/callback",
|
||
close: vi.fn(async () => {}),
|
||
})),
|
||
}));
|
||
vi.mock("./browser.js", () => ({
|
||
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", () => {
|
||
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
|
||
|
||
beforeEach(async () => {
|
||
fixture = await setupTempAuthEnv("hf-oauth-");
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await fixture.restore();
|
||
});
|
||
|
||
describe("resolveClientId", () => {
|
||
it("returns the env override when set", () => {
|
||
process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "test_client_id";
|
||
expect(resolveClientId()).toBe("test_client_id");
|
||
});
|
||
|
||
it("returns the build-time default when env is unset", () => {
|
||
expect(resolveClientId()).toMatch(/.+/);
|
||
});
|
||
});
|
||
|
||
describe("parseTokenResponse", () => {
|
||
it("parses a full token response", () => {
|
||
const tokens = parseTokenResponse({
|
||
access_token: "at_123",
|
||
refresh_token: "rt_456",
|
||
token_type: "Bearer",
|
||
expires_in: 3600,
|
||
scope: "openid profile",
|
||
});
|
||
expect(tokens.access_token).toBe("at_123");
|
||
expect(tokens.refresh_token).toBe("rt_456");
|
||
expect(tokens.token_type).toBe("Bearer");
|
||
expect(tokens.scope).toBe("openid profile");
|
||
expect(tokens.expires_at).toBeDefined();
|
||
const expiresAt = new Date(tokens.expires_at!);
|
||
// Should be approximately 1 hour in the future
|
||
const diff = expiresAt.getTime() - Date.now();
|
||
expect(diff).toBeGreaterThan(3500 * 1000);
|
||
expect(diff).toBeLessThan(3700 * 1000);
|
||
});
|
||
|
||
it("accepts expires_in as a string (some servers serialize as string)", () => {
|
||
const tokens = parseTokenResponse({
|
||
access_token: "at",
|
||
expires_in: "1800",
|
||
});
|
||
expect(tokens.expires_at).toBeDefined();
|
||
});
|
||
|
||
it("rejects responses missing access_token", () => {
|
||
expect(() => parseTokenResponse({ token_type: "Bearer" })).toThrow();
|
||
});
|
||
|
||
it("rejects array payloads", () => {
|
||
expect(() => parseTokenResponse([])).toThrow();
|
||
});
|
||
|
||
it("rejects null payloads", () => {
|
||
expect(() => parseTokenResponse(null)).toThrow();
|
||
});
|
||
|
||
it("rejects access_token containing CR/LF", () => {
|
||
expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toSatisfy(
|
||
() => true, // assertion done below via toThrow
|
||
);
|
||
expect(() => parseTokenResponse({ access_token: "at\r\nX-Evil: 1" })).toThrow(
|
||
/control characters/,
|
||
);
|
||
});
|
||
|
||
it("rejects refresh_token containing CR/LF", () => {
|
||
expect(() => parseTokenResponse({ access_token: "at", refresh_token: "rt\nbad" })).toThrow(
|
||
/control characters/,
|
||
);
|
||
});
|
||
|
||
it("clamps non-positive expires_in to avoid an immediate-refresh loop", () => {
|
||
const zero = parseTokenResponse({ access_token: "at", expires_in: 0 });
|
||
const negative = parseTokenResponse({
|
||
access_token: "at",
|
||
expires_in: -100,
|
||
});
|
||
// both should resolve to a future time
|
||
expect(new Date(zero.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
|
||
expect(new Date(negative.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
|
||
});
|
||
|
||
it("uses REFRESH_FAILED error code on shape failures (not API_ERROR)", () => {
|
||
try {
|
||
parseTokenResponse(null);
|
||
} catch (err) {
|
||
expect(isAuthError(err)).toBe(true);
|
||
if (isAuthError(err)) expect(err.code).toBe("REFRESH_FAILED");
|
||
return;
|
||
}
|
||
throw new Error("expected throw");
|
||
});
|
||
});
|
||
|
||
describe("refreshTokens", () => {
|
||
it("posts grant_type=refresh_token and persists the response", async () => {
|
||
process.env["HEYGEN_API_URL"] = "https://api.test.example";
|
||
await writeStore({ oauth: { access_token: "old_at", refresh_token: "old_rt" } });
|
||
let capturedBody: string | undefined;
|
||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||
capturedBody = init?.body as string;
|
||
return new Response(
|
||
JSON.stringify({
|
||
access_token: "new_at",
|
||
refresh_token: "new_rt",
|
||
expires_in: 3600,
|
||
token_type: "Bearer",
|
||
scope: "openid profile",
|
||
}),
|
||
{ status: 200, headers: { "content-type": "application/json" } },
|
||
);
|
||
}) as unknown as typeof fetch;
|
||
|
||
const tokens = await refreshTokens("old_rt", { fetchImpl });
|
||
expect(tokens.access_token).toBe("new_at");
|
||
expect(tokens.refresh_token).toBe("new_rt");
|
||
expect(capturedBody).toContain("grant_type=refresh_token");
|
||
expect(capturedBody).toContain("refresh_token=old_rt");
|
||
|
||
// Should have persisted.
|
||
const { credentials } = await readStore();
|
||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||
});
|
||
|
||
it("preserves the prior refresh_token when the server omits it (no rotation)", async () => {
|
||
await writeStore({
|
||
oauth: {
|
||
access_token: "old_at",
|
||
refresh_token: "keep_me_rt",
|
||
expires_at: "2026-01-01T00:00:00Z",
|
||
},
|
||
});
|
||
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
|
||
await refreshTokens("keep_me_rt", { fetchImpl });
|
||
const { credentials } = await readStore();
|
||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||
// Critical: refresh_token MUST survive a no-rotation refresh.
|
||
expect(credentials.oauth?.refresh_token).toBe("keep_me_rt");
|
||
});
|
||
|
||
it("leaves a login that replaced the refreshed one untouched", async () => {
|
||
const login = {
|
||
oauth: { access_token: "b_at", refresh_token: "b_rt" },
|
||
user: { email: "b@example.com" },
|
||
};
|
||
await writeStore(login);
|
||
const fetchImpl = tokenFetch({ access_token: "a_new_at", expires_in: 3600 });
|
||
await expect(refreshTokens("a_rt", { fetchImpl })).rejects.toSatisfy(
|
||
(err) => isAuthError(err) && err.code === "LOGIN_CHANGED",
|
||
);
|
||
const { credentials } = await readStore();
|
||
expect(credentials.oauth).toMatchObject(login.oauth);
|
||
expect(credentials.user?.email).toBe("b@example.com");
|
||
});
|
||
|
||
it("reports an unreadable credentials file on refresh instead of a changed login", async () => {
|
||
const path = (await import("./paths.js")).credentialPath();
|
||
await fs.writeFile(path, "{not json", { mode: 0o600 });
|
||
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
|
||
await expect(refreshTokens("old_rt", { fetchImpl })).rejects.toSatisfy(
|
||
(err) => isAuthError(err) && err.code === "INVALID_STORE",
|
||
);
|
||
expect(await fs.readFile(path, "utf8")).toBe("{not json");
|
||
});
|
||
|
||
it("preserves an existing api_key when persisting refreshed oauth", async () => {
|
||
await writeStore({
|
||
api_key: "hg_keep",
|
||
oauth: { access_token: "old_at", refresh_token: "old_rt" },
|
||
});
|
||
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 60 });
|
||
await refreshTokens("old_rt", { fetchImpl });
|
||
const { credentials } = await readStore();
|
||
expect(credentials.api_key).toBe("hg_keep");
|
||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||
});
|
||
|
||
it("preserves an unknown key INSIDE the oauth sub-object across a refresh", async () => {
|
||
// The refresh path is `persistOAuth({ refreshed })`, i.e.
|
||
// `{ ...existing.oauth, ...tokens }` — object spread carries the
|
||
// hidden Symbol-keyed unknown bag from `existing.oauth`, so a key
|
||
// another CLI wrote inside `oauth` (e.g. an `id_token`) must survive
|
||
// the no-rotation refresh. Refresh is the most-frequent write path,
|
||
// so a silent regression here would be the worst case.
|
||
const path = (await import("./paths.js")).credentialPath();
|
||
await fs.writeFile(
|
||
path,
|
||
JSON.stringify({
|
||
oauth: {
|
||
access_token: "old_at",
|
||
refresh_token: "keep_me_rt",
|
||
id_token: "future_id_token_value",
|
||
},
|
||
}),
|
||
{ mode: 0o600 },
|
||
);
|
||
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
|
||
await refreshTokens("keep_me_rt", { fetchImpl });
|
||
|
||
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
|
||
expect(onDisk.oauth.access_token).toBe("new_at");
|
||
// The unknown oauth sub-key rode through on the hidden slot.
|
||
expect(onDisk.oauth.id_token).toBe("future_id_token_value");
|
||
});
|
||
|
||
it("throws REFRESH_FAILED on 400/401", async () => {
|
||
const fetchImpl = (async () =>
|
||
new Response("invalid_grant", {
|
||
status: 400,
|
||
})) as unknown as typeof fetch;
|
||
await expect(refreshTokens("bad_rt", { fetchImpl })).rejects.toSatisfy((err) => {
|
||
return isAuthError(err) && (err as { code: string }).code === "REFRESH_FAILED";
|
||
});
|
||
});
|
||
|
||
it("throws API_ERROR on 5xx", async () => {
|
||
const fetchImpl = (async () =>
|
||
new Response("upstream", { status: 503 })) as unknown as typeof fetch;
|
||
await expect(refreshTokens("rt", { fetchImpl })).rejects.toSatisfy((err) => {
|
||
return isAuthError(err) && (err as { code: string }).code === "API_ERROR";
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("revokeTokens", () => {
|
||
it("never throws on network failure (best-effort)", async () => {
|
||
const fetchImpl = (async () => {
|
||
throw new Error("connection refused");
|
||
}) as unknown as typeof fetch;
|
||
await expect(revokeTokens("any_token", { fetchImpl })).resolves.toBeUndefined();
|
||
});
|
||
|
||
it("respects the timeout", async () => {
|
||
let aborted = false;
|
||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||
const signal = init?.signal;
|
||
return new Promise<Response>((_resolve, reject) => {
|
||
signal?.addEventListener("abort", () => {
|
||
aborted = true;
|
||
reject(new Error("aborted"));
|
||
});
|
||
});
|
||
}) as unknown as typeof fetch;
|
||
await revokeTokens("token", { fetchImpl, timeoutMs: 50 });
|
||
expect(aborted).toBe(true);
|
||
});
|
||
|
||
it("sends token_type_hint when provided", async () => {
|
||
let capturedBody = "";
|
||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||
capturedBody = init?.body as string;
|
||
return new Response("", { status: 200 });
|
||
}) as unknown as typeof fetch;
|
||
await revokeTokens("tok", {
|
||
fetchImpl,
|
||
token_type_hint: "refresh_token",
|
||
});
|
||
expect(capturedBody).toContain("token_type_hint=refresh_token");
|
||
});
|
||
|
||
it("returns silently when client_id is unconfigured (no throw)", async () => {
|
||
process.env["HYPERFRAMES_OAUTH_CLIENT_ID"] = "";
|
||
// With the baked-in default cleared from env, revokeTokens still has
|
||
// the build-time default. Force it to fail by setting the override to
|
||
// a value AND nulling the default isn't possible from a test — instead
|
||
// verify that the function never throws via the standard path.
|
||
const fetchImpl = (async () => new Response("", { status: 200 })) as unknown as typeof fetch;
|
||
await expect(revokeTokens("tok", { fetchImpl })).resolves.toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("error scrubbing", () => {
|
||
it("refreshTokens does not leak token-shaped secrets from the error body", async () => {
|
||
const fetchImpl = (async () =>
|
||
new Response(
|
||
'{"error":"invalid_grant","echoed":"refresh_token=rt_leak_secret&code_verifier=cv_leak"}',
|
||
{ status: 400 },
|
||
)) as unknown as typeof fetch;
|
||
try {
|
||
await refreshTokens("rt_leak_secret", { fetchImpl });
|
||
throw new Error("expected rejection");
|
||
} catch (err) {
|
||
const msg = (err as Error).message;
|
||
expect(msg).not.toContain("rt_leak_secret");
|
||
expect(msg).not.toContain("cv_leak");
|
||
expect(msg).toContain("<redacted>");
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("startAuthorizationCodeFlow persistence", () => {
|
||
it("overwrites the OAuth block on fresh login (no inherited refresh_token)", async () => {
|
||
// Pre-seed a prior session whose refresh_token must NOT leak into
|
||
// the new login when the new response omits one.
|
||
await writeStore({
|
||
oauth: {
|
||
access_token: "old_at",
|
||
refresh_token: "OLD_rt_should_not_survive",
|
||
},
|
||
});
|
||
const credentials = await loginWithTokens({
|
||
access_token: "new_at",
|
||
expires_in: 3600,
|
||
});
|
||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||
// Fresh login is a clean session — the old refresh_token is gone.
|
||
expect(credentials.oauth?.refresh_token).toBeUndefined();
|
||
});
|
||
|
||
it("preserves a co-located api_key across fresh login", async () => {
|
||
await writeStore({ api_key: "hg_keep_me" });
|
||
const credentials = await loginWithTokens({
|
||
access_token: "new_at",
|
||
refresh_token: "new_rt",
|
||
});
|
||
expect(credentials.api_key).toBe("hg_keep_me");
|
||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||
expect(credentials.oauth?.refresh_token).toBe("new_rt");
|
||
});
|
||
|
||
it("preserves the user block AND unknown/foreign keys across fresh login", async () => {
|
||
// The OAuth write path must not drop co-located data the other CLI
|
||
// (or a prior login) wrote. Seed a user block plus a future key,
|
||
// then log in fresh — both must survive the OAuth-block overwrite.
|
||
const path = (await import("./paths.js")).credentialPath();
|
||
await fs.writeFile(
|
||
path,
|
||
JSON.stringify({
|
||
oauth: { access_token: "old_at" },
|
||
user: { email: "jane@example.com", username: "jdoe" },
|
||
future_field: { keep: true },
|
||
}),
|
||
{ mode: 0o600 },
|
||
);
|
||
const credentials = await loginWithTokens({
|
||
access_token: "new_at",
|
||
expires_in: 3600,
|
||
});
|
||
expect(credentials.oauth?.access_token).toBe("new_at");
|
||
expect(credentials.user).toEqual({
|
||
email: "jane@example.com",
|
||
username: "jdoe",
|
||
});
|
||
|
||
// The unknown key is on a hidden slot — assert via the raw file.
|
||
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
|
||
expect(onDisk.future_field).toEqual({ keep: true });
|
||
});
|
||
});
|
||
|
||
describe("startDeviceAuthorizationFlow", () => {
|
||
it("atomically replaces OAuth and identity while preserving foreign fields", async () => {
|
||
const path = (await import("./paths.js")).credentialPath();
|
||
await fs.writeFile(
|
||
path,
|
||
JSON.stringify({
|
||
api_key: "hg_keep",
|
||
oauth: { access_token: "old-at", refresh_token: "old-rt" },
|
||
user: {
|
||
email: "old@example.com",
|
||
username: "old-user",
|
||
future_user_field: "keep-user",
|
||
},
|
||
future_root_field: { keep: true },
|
||
}),
|
||
{ mode: 0o600 },
|
||
);
|
||
|
||
await persistVerifiedOAuthSession(
|
||
{ access_token: "device-at", refresh_token: "device-rt" },
|
||
{ email: "new@example.com" },
|
||
);
|
||
|
||
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
|
||
expect(onDisk.api_key).toBe("hg_keep");
|
||
expect(onDisk.oauth).toMatchObject({
|
||
access_token: "device-at",
|
||
refresh_token: "device-rt",
|
||
});
|
||
expect(onDisk.user).toEqual({
|
||
email: "new@example.com",
|
||
future_user_field: "keep-user",
|
||
});
|
||
expect(onDisk.future_root_field).toEqual({ keep: true });
|
||
});
|
||
|
||
it("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);
|
||
});
|
||
});
|
||
});
|