Files
hyperframes/packages/cli/src/auth/oauth.test.ts
Miguel Ángel de9bf487d4 fix(cli): publish with a refused login refreshes or asks you to log in again (#4964)
* 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
2026-10-04 04:56:23 +00:00

744 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
});