Skip to content

Commit fa489bf

Browse files
committed
fix(cli): address code-review findings on OAuth PR
1 parent 5c81d96 commit fa489bf

18 files changed

Lines changed: 1397 additions & 101 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Shared fixtures for auth-module tests. Centralises the env-snapshot
3+
* + tmp-config-dir pattern so resolver.test.ts and oauth.test.ts don't
4+
* each maintain a copy of the same beforeEach/afterEach plumbing.
5+
*
6+
* Only loaded by `*.test.ts` — runtime code doesn't depend on it.
7+
*/
8+
9+
import { promises as fs } from "node:fs";
10+
import { tmpdir } from "node:os";
11+
import { join } from "node:path";
12+
13+
const ENV_KEYS = [
14+
"HEYGEN_API_KEY",
15+
"HYPERFRAMES_API_KEY",
16+
"HEYGEN_CONFIG_DIR",
17+
"HEYGEN_API_URL",
18+
"HYPERFRAMES_OAUTH_CLIENT_ID",
19+
] as const;
20+
21+
type EnvKey = (typeof ENV_KEYS)[number];
22+
23+
export interface EnvFixture {
24+
/** Tmp config dir; deleted on `restore()`. */
25+
dir: string;
26+
/** Restore env + delete tmp dir. Idempotent. */
27+
restore: () => Promise<void>;
28+
}
29+
30+
/**
31+
* Take a snapshot of the auth-related env, clear them, make a tmp
32+
* `HEYGEN_CONFIG_DIR`, and return a `restore()` that undoes all of
33+
* the above.
34+
*/
35+
export async function setupTempAuthEnv(prefix = "hf-auth-test-"): Promise<EnvFixture> {
36+
const dir = await fs.mkdtemp(join(tmpdir(), prefix));
37+
const saved: Partial<Record<EnvKey, string | undefined>> = {};
38+
for (const k of ENV_KEYS) {
39+
saved[k] = process.env[k];
40+
delete process.env[k];
41+
}
42+
process.env["HEYGEN_CONFIG_DIR"] = dir;
43+
44+
const restore = async (): Promise<void> => {
45+
for (const k of ENV_KEYS) {
46+
const v = saved[k];
47+
if (v === undefined) delete process.env[k];
48+
else process.env[k] = v;
49+
}
50+
await fs.rm(dir, { recursive: true, force: true });
51+
};
52+
53+
return { dir, restore };
54+
}

packages/cli/src/auth/browser.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Open a URL in the user's default browser. Falls back to printing the
3+
* URL when no browser is openable (SSH session, CI, `BROWSER=none`,
4+
* or `open` rejects).
5+
*/
6+
7+
import { c } from "../ui/colors.js";
8+
9+
export interface OpenBrowserResult {
10+
/** True when we successfully invoked the platform "open" command. */
11+
opened: boolean;
12+
}
13+
14+
export async function openBrowser(url: string): Promise<OpenBrowserResult> {
15+
if (process.env["BROWSER"] === "none" || process.env["HF_NO_BROWSER"] === "1") {
16+
printManualInstructions(url);
17+
return { opened: false };
18+
}
19+
try {
20+
const open = (await import("open")).default;
21+
await open(url);
22+
return { opened: true };
23+
} catch (err) {
24+
printManualInstructions(url, err instanceof Error ? err.message : String(err));
25+
return { opened: false };
26+
}
27+
}
28+
29+
function printManualInstructions(url: string, detail?: string): void {
30+
if (detail) {
31+
console.error(c.warn(`Could not open browser automatically (${detail}).`));
32+
} else {
33+
console.error(c.warn("Browser auto-open is disabled."));
34+
}
35+
console.error(`Open this URL manually to continue:\n ${c.accent(url)}`);
36+
}

packages/cli/src/auth/client.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,79 @@ describe("auth/client", () => {
133133
throw new Error("expected rejection");
134134
});
135135

136+
it("getCurrentUser retries once on 401 when refresh hook is configured for OAuth", async () => {
137+
let callCount = 0;
138+
const observed: string[] = [];
139+
const fetchImpl = (async (_url: string, init?: RequestInit) => {
140+
callCount++;
141+
const headers = (init?.headers as Record<string, string>) ?? {};
142+
observed.push(headers["authorization"] ?? "");
143+
if (callCount === 1) return new Response("expired", { status: 401 });
144+
return new Response(JSON.stringify({ email: "a@b" }), {
145+
status: 200,
146+
headers: { "content-type": "application/json" },
147+
});
148+
}) as unknown as typeof fetch;
149+
150+
const client = new AuthClient({
151+
baseUrl: "https://api.test.example",
152+
fetchImpl,
153+
onUnauthenticatedRefresh: async () => "fresh_at",
154+
});
155+
const user = await client.getCurrentUser({
156+
type: "oauth",
157+
access_token: "stale_at",
158+
refresh_token: "rt",
159+
source: "file_json",
160+
refreshable: true,
161+
});
162+
expect(user.email).toBe("a@b");
163+
expect(observed[0]).toBe("Bearer stale_at");
164+
expect(observed[1]).toBe("Bearer fresh_at");
165+
expect(callCount).toBe(2);
166+
});
167+
168+
it("getCurrentUser does NOT retry on 401 for api_key credentials", async () => {
169+
let callCount = 0;
170+
const fetchImpl = (async () => {
171+
callCount++;
172+
return new Response("invalid", { status: 401 });
173+
}) as unknown as typeof fetch;
174+
const client = new AuthClient({
175+
baseUrl: "https://api.test.example",
176+
fetchImpl,
177+
onUnauthenticatedRefresh: async () => "fresh",
178+
});
179+
await expect(client.getCurrentUser(apiKeyCred())).rejects.toSatisfy((err) => {
180+
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
181+
});
182+
expect(callCount).toBe(1);
183+
});
184+
185+
it("getCurrentUser surfaces 401 when refresh hook returns null (refresh failed)", async () => {
186+
const fetchImpl = (async () =>
187+
new Response("nope", { status: 401 })) as unknown as typeof fetch;
188+
const { ErrRefreshFailed } = await import("./errors.js");
189+
const client = new AuthClient({
190+
baseUrl: "https://api.test.example",
191+
fetchImpl,
192+
onUnauthenticatedRefresh: async () => {
193+
throw ErrRefreshFailed("invalid_grant");
194+
},
195+
});
196+
await expect(
197+
client.getCurrentUser({
198+
type: "oauth",
199+
access_token: "stale",
200+
refresh_token: "rt",
201+
source: "file_json",
202+
refreshable: true,
203+
}),
204+
).rejects.toSatisfy((err) => {
205+
return isAuthError(err) && (err as { code: string }).code === "UNAUTHENTICATED";
206+
});
207+
});
208+
136209
it("getCurrentUser sends the right header for oauth credentials", async () => {
137210
let captured: Record<string, string> = {};
138211
const fetchImpl = (async (_url: string, init?: RequestInit) => {

packages/cli/src/auth/client.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* `movio/api_service/app/controller/user_v3.py`.
1616
*/
1717

18-
import { ErrApi, ErrUnauthenticated } from "./errors.js";
18+
import { ErrApi, ErrUnauthenticated, isAuthError } from "./errors.js";
1919
import type { ResolvedCredential } from "./resolver.js";
2020

2121
const DEFAULT_BASE_URL = "https://api.heygen.com";
@@ -63,27 +63,63 @@ export interface AuthClientOptions {
6363
baseUrl?: string;
6464
/** Inject a custom fetch (used by tests). */
6565
fetchImpl?: typeof fetch;
66+
/**
67+
* Hook for refreshing an OAuth credential on 401. The hook should
68+
* exchange the supplied refresh_token for new tokens, persist them,
69+
* and return the new bearer to retry with. Wired in by the auth
70+
* commands; injectable for tests.
71+
*/
72+
onUnauthenticatedRefresh?: (refresh_token: string) => Promise<string>;
6673
}
6774

6875
export class AuthClient {
6976
private readonly base: string;
7077
private readonly fetchImpl: typeof fetch;
78+
private readonly onRefresh?: (refresh_token: string) => Promise<string>;
7179

7280
constructor(opts: AuthClientOptions = {}) {
7381
this.base = (opts.baseUrl ?? apiBaseUrl()).replace(/\/+$/, "");
7482
this.fetchImpl = opts.fetchImpl ?? fetch;
83+
this.onRefresh = opts.onUnauthenticatedRefresh;
7584
}
7685

7786
/**
7887
* `GET /v3/users/me`. Throws `ErrUnauthenticated` on 401, `ErrApi`
7988
* on any other non-2xx or non-JSON body.
89+
*
90+
* On OAuth 401 with a refresh hook configured, the request is
91+
* retried once after refreshing the access token. The retry's
92+
* outcome is what the caller sees — if the refresh itself fails
93+
* (REFRESH_FAILED) or the retry still 401s, the user lands on a
94+
* "please log in again" path upstream.
8095
*/
8196
async getCurrentUser(credential: ResolvedCredential): Promise<UserInfo> {
8297
const url = `${this.base}/v3/users/me`;
98+
return await this.fetchUser(url, credential, true);
99+
}
100+
101+
// fallow-ignore-next-line complexity
102+
private async fetchUser(
103+
url: string,
104+
credential: ResolvedCredential,
105+
allowRefresh: boolean,
106+
): Promise<UserInfo> {
83107
const headers = buildAuthHeaders(credential);
84108
const res = await this.fetchImpl(url, { method: "GET", headers });
85109

86110
if (res.status === 401) {
111+
if (
112+
allowRefresh &&
113+
credential.type === "oauth" &&
114+
credential.refresh_token &&
115+
this.onRefresh
116+
) {
117+
const refreshed = await this.tryRefresh(credential.refresh_token);
118+
if (refreshed) {
119+
const next: ResolvedCredential = { ...credential, access_token: refreshed };
120+
return await this.fetchUser(url, next, false);
121+
}
122+
}
87123
const detail = await safeText(res);
88124
throw ErrUnauthenticated(detail || `${res.status} ${res.statusText}`);
89125
}
@@ -99,6 +135,19 @@ export class AuthClient {
99135
}
100136
return extractUserInfo(payload);
101137
}
138+
139+
private async tryRefresh(refresh_token: string): Promise<string | null> {
140+
if (!this.onRefresh) return null;
141+
try {
142+
return await this.onRefresh(refresh_token);
143+
} catch (err) {
144+
// Refresh failure should be surfaced upstream by the caller via
145+
// the retry's 401, not by throwing here — so callers consistently
146+
// see "please log in again" rather than mixed error types.
147+
if (isAuthError(err) && err.code === "REFRESH_FAILED") return null;
148+
throw err;
149+
}
150+
}
102151
}
103152

104153
export function buildAuthHeaders(credential: ResolvedCredential): Record<string, string> {

packages/cli/src/auth/errors.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
* can map specific failures to friendly UX without parsing messages.
44
*/
55

6-
export type AuthErrorCode = "NOT_CONFIGURED" | "INVALID_STORE" | "API_ERROR" | "UNAUTHENTICATED";
6+
export type AuthErrorCode =
7+
| "NOT_CONFIGURED"
8+
| "INVALID_STORE"
9+
| "API_ERROR"
10+
| "UNAUTHENTICATED"
11+
| "OAUTH_NOT_CONFIGURED"
12+
| "REFRESH_FAILED";
713

814
export class AuthError extends Error {
915
readonly code: AuthErrorCode;
@@ -41,6 +47,20 @@ export const ErrUnauthenticated = (detail?: string) =>
4147
export const ErrApi = (status: number, detail: string) =>
4248
new AuthError("API_ERROR", `HeyGen API error (${status}): ${detail}`);
4349

50+
export const ErrOAuthNotConfigured = () =>
51+
new AuthError(
52+
"OAUTH_NOT_CONFIGURED",
53+
"OAuth client is not configured",
54+
"Set HYPERFRAMES_OAUTH_CLIENT_ID, or run `hyperframes auth login --api-key`.",
55+
);
56+
57+
export const ErrRefreshFailed = (detail?: string) =>
58+
new AuthError(
59+
"REFRESH_FAILED",
60+
detail ? `Failed to refresh OAuth tokens: ${detail}` : "Failed to refresh OAuth tokens",
61+
"Run `hyperframes auth login` to re-authenticate.",
62+
);
63+
4464
export function isAuthError(err: unknown): err is AuthError {
4565
return err instanceof AuthError;
4666
}

packages/cli/src/auth/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,10 @@ export type { ResolvedCredential } from "./resolver.js";
1515

1616
export { AuthClient } from "./client.js";
1717
export type { UserInfo } from "./client.js";
18+
19+
export {
20+
assertOAuthConfiguredOrExit,
21+
refreshTokens,
22+
revokeTokens,
23+
startAuthorizationCodeFlow,
24+
} from "./oauth.js";
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { startLoopback, type LoopbackHandle } from "./loopback.js";
3+
4+
describe("auth/loopback", () => {
5+
let active: LoopbackHandle | null = null;
6+
7+
afterEach(async () => {
8+
if (active) {
9+
await active.close().catch(() => {});
10+
active = null;
11+
}
12+
});
13+
14+
it("captures `code` when state matches", async () => {
15+
const handle = await startLoopback({ state: "expected_state", timeoutMs: 5_000 });
16+
active = handle;
17+
const redirect = new URL(handle.redirectUri);
18+
const callback = new URL(`${handle.redirectUri}?code=abc123&state=expected_state`);
19+
20+
const fetchPromise = fetch(callback.toString());
21+
const result = await handle.result;
22+
const res = await fetchPromise;
23+
24+
expect(result.code).toBe("abc123");
25+
expect(result.redirectUri).toContain(redirect.host);
26+
expect(res.status).toBe(200);
27+
const body = await res.text();
28+
expect(body).toContain("Signed in");
29+
});
30+
31+
async function expectRejection(args: {
32+
expectedState: string;
33+
query: string;
34+
pattern: RegExp;
35+
}): Promise<void> {
36+
const handle = await startLoopback({ state: args.expectedState, timeoutMs: 5_000 });
37+
active = handle;
38+
await fetch(`${handle.redirectUri}?${args.query}`).catch(() => {});
39+
await expect(handle.result).rejects.toThrow(args.pattern);
40+
}
41+
42+
it("rejects when state does not match", async () => {
43+
await expectRejection({
44+
expectedState: "expected",
45+
query: "code=abc&state=wrong",
46+
pattern: /state mismatch/i,
47+
});
48+
});
49+
50+
it("rejects when the IdP returns an error", async () => {
51+
await expectRejection({
52+
expectedState: "s",
53+
query: "error=access_denied&error_description=user+denied&state=s",
54+
pattern: /access_denied/,
55+
});
56+
});
57+
58+
it("rejects when code is missing from the callback", async () => {
59+
await expectRejection({ expectedState: "s", query: "state=s", pattern: /code/ });
60+
});
61+
62+
it("times out when no callback arrives", async () => {
63+
const handle = await startLoopback({ state: "s", timeoutMs: 200 });
64+
active = handle;
65+
await expect(handle.result).rejects.toThrow(/timed out/i);
66+
});
67+
68+
it("404s non-callback paths and does not resolve the flow", async () => {
69+
const handle = await startLoopback({ state: "s", timeoutMs: 1_000 });
70+
active = handle;
71+
const res = await fetch(`${handle.redirectUri.replace("/oauth/callback", "/other")}`);
72+
expect(res.status).toBe(404);
73+
// Flow is still waiting — kill it via timeout.
74+
await expect(handle.result).rejects.toThrow(/timed out/i);
75+
});
76+
});

0 commit comments

Comments
 (0)