Skip to content

Commit eae0170

Browse files
Merge pull request #707 from corbitsdev/cl-7115-stage-openai-oauth-credentials-until-setup-validation
Stage OpenAI OAuth credentials until setup validation
2 parents 8b115bf + 3efe315 commit eae0170

30 files changed

Lines changed: 1707 additions & 246 deletions

src/auth/callback-page.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
PRODUCT_SITE_URL,
88
} from "../branding.js";
99
import { callbackPageHtml, humanizeIdentifier } from "./callback-page.js";
10+
import { authorizationDoneHtml } from "./oauth/callback-server.js";
1011

1112
describe("humanizeIdentifier", () => {
1213
test("machine identifiers lose their separators and lead with a capital", () => {
@@ -28,6 +29,13 @@ describe("callbackPageHtml", () => {
2829
expect(html).not.toContain("access_denied");
2930
});
3031

32+
test("provider authorization waits for native setup before claiming connection", () => {
33+
const html = authorizationDoneHtml("Codex");
34+
expect(html).toContain("Codex authorization received");
35+
expect(html).toContain("finish setup");
36+
expect(html).not.toContain("connected successfully");
37+
});
38+
3139
test("failure names the server and the humanized reason", () => {
3240
const html = callbackPageHtml({ subject: "granola", error: "access_denied" });
3341
expect(html).toContain("Granola failed to connect");

src/auth/callback-page.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@ export interface CallbackPage {
262262
readonly subject?: string;
263263
/** Why it failed. Omit for the success page. */
264264
readonly error?: string;
265+
/** Authorization succeeded, but the native setup flow still has work to do. */
266+
readonly pendingSetup?: boolean;
265267
}
266268

267269
/**
@@ -276,19 +278,26 @@ export function callbackPageHtml(page: CallbackPage = {}): string {
276278
const failed = page.error !== undefined;
277279
const subject =
278280
page.subject === undefined ? undefined : escapeHtml(humanizeIdentifier(page.subject));
281+
const pendingSetup = !failed && page.pendingSetup === true;
279282
const tone = failed ? "var(--accent)" : "var(--ok)";
280-
const label = failed ? "not connected" : "connected";
283+
const label = failed ? "not connected" : pendingSetup ? "authorization received" : "connected";
281284
const heading = failed
282285
? subject === undefined
283286
? "Authorization did not complete"
284287
: `${subject} failed to connect`
285-
: subject === undefined
286-
? "Authorization complete"
287-
: `${subject} connected successfully`;
288+
: pendingSetup
289+
? subject === undefined
290+
? "Authorization received"
291+
: `${subject} authorization received`
292+
: subject === undefined
293+
? "Authorization complete"
294+
: `${subject} connected successfully`;
288295
const reason = escapeHtml(humanizeIdentifier(page.error ?? ""));
289296
const body = failed
290297
? `${reason}. Close this tab and try again from ${PRODUCT_NAME}.`
291-
: `You can close this tab and return to ${PRODUCT_NAME}.`;
298+
: pendingSetup
299+
? `Return to ${PRODUCT_NAME} to finish setup.`
300+
: `You can close this tab and return to ${PRODUCT_NAME}.`;
292301
return [
293302
"<!doctype html>",
294303
'<html lang="en">',

src/auth/codex/login.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import {
77
import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "./constants.js";
88
import { startCodexCallbackServer } from "./callback-server.js";
99
import { buildAuthorizeUrl, exchangeCode } from "./oauth.js";
10-
import { saveCodexProfile } from "./store.js";
10+
import { saveCodexProfile, type CodexTokens } from "./store.js";
1111

1212
export { openInBrowser };
1313

14-
export type CodexLoginHandle = OAuthLoginHandle;
14+
export type CodexLoginHandle = OAuthLoginHandle<CodexTokens>;
1515
export type StartCodexLoginOptions = StartOAuthLoginOptions;
1616

1717
// Drive the loopback PKCE login for a Codex profile.

src/auth/codex/session.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,13 @@ const session = createTokenSession<CodexTokens, CodexAccess>({
5252

5353
export const isCodexTokenExpired = session.isExpired;
5454
export const getValidCodexToken = session.getValidToken;
55+
56+
export async function refreshStagedCodexTokens(
57+
tokens: CodexTokens,
58+
now: number = Date.now(),
59+
): Promise<CodexTokens> {
60+
if (!isCodexTokenExpired(tokens, now)) return tokens;
61+
const refreshed = await refreshTokens(tokens.refresh, now);
62+
Object.assign(tokens, refreshed);
63+
return tokens;
64+
}

src/auth/codex/usage.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,23 @@ function parseUsage(payload: unknown): CodexUsage {
7373
};
7474
}
7575

76-
export async function codexAuthHeaders(profileName: string): Promise<Record<string, string>> {
77-
const { access, accountId } = await getValidCodexToken(profileName);
76+
export function codexAuthHeadersForToken(token: {
77+
readonly access: string;
78+
readonly accountId?: string | undefined;
79+
}): Record<string, string> {
7880
const headers: Record<string, string> = {
79-
authorization: `Bearer ${access}`,
81+
authorization: `Bearer ${token.access}`,
8082
originator: CODEX_AUTHORIZE_EXTRA_PARAMS["originator"] ?? "codex_cli_rs",
8183
"user-agent": `${COMMAND_NAME} (codex_cli_rs/${CODEX_CLIENT_VERSION})`,
8284
};
83-
if (accountId !== undefined) headers["chatgpt-account-id"] = accountId;
85+
if (token.accountId !== undefined) headers["chatgpt-account-id"] = token.accountId;
8486
return headers;
8587
}
8688

89+
export async function codexAuthHeaders(profileName: string): Promise<Record<string, string>> {
90+
return codexAuthHeadersForToken(await getValidCodexToken(profileName));
91+
}
92+
8793
// Fetch the live usage/quota snapshot for a Codex profile.
8894
export async function fetchCodexUsage(profileName: string): Promise<CodexUsage> {
8995
const res = await fetch(`${CODEX_BASE_URL}${CODEX_USAGE_PATH}`, {

src/auth/oauth-scope-check.test.ts

Lines changed: 137 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,185 @@
11
import { afterEach, describe, expect, test } from "bun:test";
2-
import { withMockedModule } from "../../tests/helpers/mock-module.js";
3-
4-
// getValidCodexToken/getValidXaiToken hit the real home-level auth store and
5-
// refresh endpoints; stub the session layer so this test only exercises the
6-
// scope probe's own HTTP call and status classification. Other suites
7-
// (tests/unit/codex-session.test.ts) import the real modules directly, so the
8-
// mocks must be torn down after this file's tests run rather than leaking
9-
// into the rest of the bun test process.
10-
await withMockedModule(
11-
import.meta.resolve("./codex/session.js"),
12-
(real: typeof import("./codex/session.js")) => ({
13-
...real,
14-
getValidCodexToken: async () => ({ access: "codex-token", accountId: "acct-1" }),
15-
}),
16-
);
17-
await withMockedModule(
18-
import.meta.resolve("./xai/session.js"),
19-
(real: typeof import("./xai/session.js")) => ({
20-
...real,
21-
getValidXaiToken: async () => ({ access: "xai-token" }),
22-
}),
23-
);
24-
25-
const { checkOAuthProviderScope } = await import("./oauth-scope-check.js");
2+
3+
import { checkOAuthProviderScope } from "./oauth-scope-check.js";
264

275
const originalFetch = global.fetch;
6+
const codexTokens = {
7+
access: "staged-codex-token",
8+
refresh: "codex-refresh",
9+
expiresAt: Date.now() + 3_600_000,
10+
accountId: "acct-staged",
11+
};
12+
const xaiTokens = {
13+
access: "staged-xai-token",
14+
refresh: "xai-refresh",
15+
expiresAt: Date.now() + 3_600_000,
16+
};
2817

29-
function stubFetch(impl: (url: string) => Response | Promise<Response>): void {
30-
global.fetch = (async (input: RequestInfo | URL) => impl(String(input))) as typeof fetch;
18+
function stubFetch(impl: (url: string, init?: RequestInit) => Response | Promise<Response>): void {
19+
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) =>
20+
impl(String(input), init)) as typeof fetch;
3121
}
3222

3323
describe("checkOAuthProviderScope", () => {
3424
afterEach(() => {
3525
global.fetch = originalFetch;
3626
});
3727

38-
test("codex: ok when the catalog call succeeds", async () => {
39-
stubFetch(() => new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 }));
40-
const result = await checkOAuthProviderScope("codex", "work");
28+
test("codex: builds the probe from staged tokens", async () => {
29+
stubFetch((_url, init) => {
30+
expect(init?.headers).toMatchObject({
31+
authorization: "Bearer staged-codex-token",
32+
"chatgpt-account-id": "acct-staged",
33+
});
34+
return new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 });
35+
});
36+
const result = await checkOAuthProviderScope("codex", codexTokens);
37+
expect(result.status).toBe("ok");
38+
});
39+
40+
test("codex: refreshes expired staged tokens before classifying the probe", async () => {
41+
const expired = { ...codexTokens, expiresAt: 0 };
42+
const requests: string[] = [];
43+
stubFetch((url, init) => {
44+
requests.push(url);
45+
if (url.includes("/oauth/token")) {
46+
return new Response(JSON.stringify({ access_token: "refreshed-codex", expires_in: 3600 }), {
47+
status: 200,
48+
headers: { "content-type": "application/json" },
49+
});
50+
}
51+
expect(init?.headers).toMatchObject({
52+
authorization: "Bearer refreshed-codex",
53+
"chatgpt-account-id": "acct-staged",
54+
});
55+
return new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 });
56+
});
57+
58+
const result = await checkOAuthProviderScope("codex", expired);
59+
4160
expect(result.status).toBe("ok");
61+
expect(requests).toHaveLength(2);
62+
expect(expired.access).toBe("refreshed-codex");
63+
});
64+
65+
test("codex: blocks a definitive staged refresh rejection", async () => {
66+
const expired = { ...codexTokens, expiresAt: 0 };
67+
stubFetch(() => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }));
68+
69+
const result = await checkOAuthProviderScope("codex", expired);
70+
71+
expect(result.status).toBe("blocked");
72+
if (result.status === "blocked") {
73+
expect(result.message).toMatch(/expired|revoked/i);
74+
}
75+
});
76+
77+
test("codex: reports a transient staged refresh failure as unavailable", async () => {
78+
const expired = { ...codexTokens, expiresAt: 0 };
79+
stubFetch(() => {
80+
throw new Error("network down");
81+
});
82+
83+
const result = await checkOAuthProviderScope("codex", expired);
84+
85+
expect(result.status).toBe("unavailable");
4286
});
4387

44-
test("codex: insufficient-scope on a definitive 403", async () => {
88+
test("codex: blocks a definitive 403 without surfacing the raw body", async () => {
4589
stubFetch(() => new Response("forbidden", { status: 403 }));
46-
const result = await checkOAuthProviderScope("codex", "work");
47-
expect(result.status).toBe("insufficient-scope");
48-
if (result.status === "insufficient-scope") {
90+
const result = await checkOAuthProviderScope("codex", codexTokens);
91+
expect(result.status).toBe("blocked");
92+
if (result.status === "blocked") {
4993
expect(result.message).toMatch(/reconnect/i);
50-
// Must never surface the raw response body.
5194
expect(result.message).not.toContain("forbidden");
5295
}
5396
});
5497

55-
test("codex: insufficient-scope on a definitive 401", async () => {
98+
test("codex: blocks a definitive 401", async () => {
5699
stubFetch(() => new Response("nope", { status: 401 }));
57-
const result = await checkOAuthProviderScope("codex", "work");
58-
expect(result.status).toBe("insufficient-scope");
100+
const result = await checkOAuthProviderScope("codex", codexTokens);
101+
expect(result.status).toBe("blocked");
59102
});
60103

61104
test("codex: unavailable on a network failure, not blocked", async () => {
62105
stubFetch(() => {
63106
throw new Error("fetch failed");
64107
});
65-
const result = await checkOAuthProviderScope("codex", "work");
108+
const result = await checkOAuthProviderScope("codex", codexTokens);
66109
expect(result.status).toBe("unavailable");
67110
});
68111

69112
test("codex: unavailable (not scope failure) on a 500", async () => {
70113
stubFetch(() => new Response("boom", { status: 500 }));
71-
const result = await checkOAuthProviderScope("codex", "work");
114+
const result = await checkOAuthProviderScope("codex", codexTokens);
72115
expect(result.status).toBe("unavailable");
73116
});
74117

75-
test("xai: ok when the models call succeeds", async () => {
76-
stubFetch(() => new Response(JSON.stringify({ data: [] }), { status: 200 }));
77-
const result = await checkOAuthProviderScope("xai", "personal");
118+
test("xai: builds the probe from staged tokens", async () => {
119+
stubFetch((_url, init) => {
120+
expect(init?.headers).toMatchObject({ authorization: "Bearer staged-xai-token" });
121+
return new Response(JSON.stringify({ data: [] }), { status: 200 });
122+
});
123+
const result = await checkOAuthProviderScope("xai", xaiTokens);
124+
expect(result.status).toBe("ok");
125+
});
126+
127+
test("xai: refreshes expired staged tokens before classifying the probe", async () => {
128+
const expired = { ...xaiTokens, expiresAt: 0 };
129+
const requests: string[] = [];
130+
stubFetch((url, init) => {
131+
requests.push(url);
132+
if (url.includes("/oauth2/token")) {
133+
return new Response(JSON.stringify({ access_token: "refreshed-xai", expires_in: 3600 }), {
134+
status: 200,
135+
headers: { "content-type": "application/json" },
136+
});
137+
}
138+
expect(init?.headers).toMatchObject({ authorization: "Bearer refreshed-xai" });
139+
return new Response(JSON.stringify({ data: [] }), { status: 200 });
140+
});
141+
142+
const result = await checkOAuthProviderScope("xai", expired);
143+
78144
expect(result.status).toBe("ok");
145+
expect(requests).toHaveLength(2);
146+
expect(expired.access).toBe("refreshed-xai");
147+
});
148+
149+
test("xai: blocks a definitive staged refresh rejection", async () => {
150+
const expired = { ...xaiTokens, expiresAt: 0 };
151+
stubFetch(() => new Response(JSON.stringify({ error: "revoked" }), { status: 401 }));
152+
153+
const result = await checkOAuthProviderScope("xai", expired);
154+
155+
expect(result.status).toBe("blocked");
156+
if (result.status === "blocked") {
157+
expect(result.message).toMatch(/expired|revoked/i);
158+
}
159+
});
160+
161+
test("xai: reports a transient staged refresh failure as unavailable", async () => {
162+
const expired = { ...xaiTokens, expiresAt: 0 };
163+
stubFetch(() => {
164+
throw new DOMException("The operation timed out.", "TimeoutError");
165+
});
166+
167+
const result = await checkOAuthProviderScope("xai", expired);
168+
169+
expect(result.status).toBe("unavailable");
79170
});
80171

81-
test("xai: insufficient-scope on a definitive 403", async () => {
172+
test("xai: blocks a definitive 403", async () => {
82173
stubFetch(() => new Response("forbidden", { status: 403 }));
83-
const result = await checkOAuthProviderScope("xai", "personal");
84-
expect(result.status).toBe("insufficient-scope");
174+
const result = await checkOAuthProviderScope("xai", xaiTokens);
175+
expect(result.status).toBe("blocked");
85176
});
86177

87178
test("xai: unavailable on a timeout-style abort", async () => {
88179
stubFetch(() => {
89180
throw new DOMException("The operation timed out.", "TimeoutError");
90181
});
91-
const result = await checkOAuthProviderScope("xai", "personal");
182+
const result = await checkOAuthProviderScope("xai", xaiTokens);
92183
expect(result.status).toBe("unavailable");
93184
});
94185
});

0 commit comments

Comments
 (0)