Skip to content

Commit 555867f

Browse files
Merge pull request #574 from corbitsdev/cl-5710-validate-oauth-issued-provider-tokens-carry-real-api-scope
Validate OAuth-issued provider tokens carry API scope before onboarding completes
2 parents 1675c8c + b841bd3 commit 555867f

6 files changed

Lines changed: 353 additions & 12 deletions

File tree

src/auth/codex/usage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ function parseUsage(payload: unknown): CodexUsage {
7373
};
7474
}
7575

76-
async function codexAuthHeaders(profileName: string): Promise<Record<string, string>> {
76+
export async function codexAuthHeaders(profileName: string): Promise<Record<string, string>> {
7777
const { access, accountId } = await getValidCodexToken(profileName);
7878
const headers: Record<string, string> = {
7979
authorization: `Bearer ${access}`,

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
2+
3+
// getValidCodexToken/getValidXaiToken hit the real home-level auth store and
4+
// refresh endpoints; stub the session layer so this test only exercises the
5+
// scope probe's own HTTP call and status classification. Other suites
6+
// (tests/unit/codex-session.test.ts) import the real modules directly, so the
7+
// mocks must be torn down after this file's tests run rather than leaking
8+
// into the rest of the bun test process.
9+
const realCodexSession = { ...(await import("./codex/session.js")) };
10+
const realXaiSession = { ...(await import("./xai/session.js")) };
11+
12+
mock.module("./codex/session.js", () => ({
13+
...realCodexSession,
14+
getValidCodexToken: async () => ({ access: "codex-token", accountId: "acct-1" }),
15+
}));
16+
mock.module("./xai/session.js", () => ({
17+
...realXaiSession,
18+
getValidXaiToken: async () => ({ access: "xai-token" }),
19+
}));
20+
21+
afterAll(() => {
22+
mock.module("./codex/session.js", () => realCodexSession);
23+
mock.module("./xai/session.js", () => realXaiSession);
24+
});
25+
26+
const { checkOAuthProviderScope } = await import("./oauth-scope-check.js");
27+
28+
const originalFetch = global.fetch;
29+
30+
function stubFetch(impl: (url: string) => Response | Promise<Response>): void {
31+
global.fetch = (async (input: RequestInfo | URL) => impl(String(input))) as typeof fetch;
32+
}
33+
34+
describe("checkOAuthProviderScope", () => {
35+
afterEach(() => {
36+
global.fetch = originalFetch;
37+
});
38+
39+
test("codex: ok when the catalog call succeeds", async () => {
40+
stubFetch(() => new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 }));
41+
const result = await checkOAuthProviderScope("codex", "work");
42+
expect(result.status).toBe("ok");
43+
});
44+
45+
test("codex: insufficient-scope on a definitive 403", async () => {
46+
stubFetch(() => new Response("forbidden", { status: 403 }));
47+
const result = await checkOAuthProviderScope("codex", "work");
48+
expect(result.status).toBe("insufficient-scope");
49+
if (result.status === "insufficient-scope") {
50+
expect(result.message).toMatch(/reconnect/i);
51+
// Must never surface the raw response body.
52+
expect(result.message).not.toContain("forbidden");
53+
}
54+
});
55+
56+
test("codex: insufficient-scope on a definitive 401", async () => {
57+
stubFetch(() => new Response("nope", { status: 401 }));
58+
const result = await checkOAuthProviderScope("codex", "work");
59+
expect(result.status).toBe("insufficient-scope");
60+
});
61+
62+
test("codex: unavailable on a network failure, not blocked", async () => {
63+
stubFetch(() => {
64+
throw new Error("fetch failed");
65+
});
66+
const result = await checkOAuthProviderScope("codex", "work");
67+
expect(result.status).toBe("unavailable");
68+
});
69+
70+
test("codex: unavailable (not scope failure) on a 500", async () => {
71+
stubFetch(() => new Response("boom", { status: 500 }));
72+
const result = await checkOAuthProviderScope("codex", "work");
73+
expect(result.status).toBe("unavailable");
74+
});
75+
76+
test("xai: ok when the models call succeeds", async () => {
77+
stubFetch(() => new Response(JSON.stringify({ data: [] }), { status: 200 }));
78+
const result = await checkOAuthProviderScope("xai", "personal");
79+
expect(result.status).toBe("ok");
80+
});
81+
82+
test("xai: insufficient-scope on a definitive 403", async () => {
83+
stubFetch(() => new Response("forbidden", { status: 403 }));
84+
const result = await checkOAuthProviderScope("xai", "personal");
85+
expect(result.status).toBe("insufficient-scope");
86+
});
87+
88+
test("xai: unavailable on a timeout-style abort", async () => {
89+
stubFetch(() => {
90+
throw new DOMException("The operation timed out.", "TimeoutError");
91+
});
92+
const result = await checkOAuthProviderScope("xai", "personal");
93+
expect(result.status).toBe("unavailable");
94+
});
95+
});

src/auth/oauth-scope-check.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// A completed OAuth login proves the token is real (issued by the provider's
2+
// own authorization server via PKCE) but not that it carries usable API
3+
// scope — e.g. a chat-only subscription without API access. Trusting the
4+
// login result alone lets onboarding complete on a token whose first real
5+
// inference call fails with a confusing auth error. This runs one cheap,
6+
// authoritative call against each provider's own catalog/list endpoint
7+
// (the same surface real inference would hit) so a scope gap is caught
8+
// during setup instead of during the first conversation.
9+
//
10+
// Never logs or persists the token or any response body — only the HTTP
11+
// status is inspected to classify the result.
12+
13+
import { CODEX_BASE_URL, CODEX_MODELS_PATH, CODEX_CLIENT_VERSION } from "./codex/constants.js";
14+
import { codexAuthHeaders } from "./codex/usage.js";
15+
import { XAI_BASE_URL, XAI_TOKEN_TIMEOUT_MS } from "./xai/constants.js";
16+
import { xaiAuthHeaders } from "./xai/usage.js";
17+
18+
export type OAuthScopeCheckKind = "codex" | "xai";
19+
20+
export type OAuthScopeCheckResult =
21+
| { status: "ok" }
22+
| { status: "insufficient-scope"; message: string }
23+
// The probe could not run to completion (network blip, timeout, rate
24+
// limit, provider hiccup). This must never be treated the same as a
25+
// definitive scope failure — a transient failure must not lock a
26+
// legitimate user out of onboarding.
27+
| { status: "unavailable"; message: string };
28+
29+
const SCOPE_CHECK_TIMEOUT_MS = 10_000;
30+
31+
function insufficientScope(providerLabel: string): OAuthScopeCheckResult {
32+
return {
33+
status: "insufficient-scope",
34+
message:
35+
`Your ${providerLabel} sign-in doesn't carry API access (it looks like a chat-only plan). ` +
36+
`Reconnect ${providerLabel} with an account/plan that includes API access, then try again.`,
37+
};
38+
}
39+
40+
function unavailable(providerLabel: string): OAuthScopeCheckResult {
41+
return {
42+
status: "unavailable",
43+
message: `Couldn't confirm ${providerLabel} API access right now — continuing without blocking setup.`,
44+
};
45+
}
46+
47+
// 401/403 is the provider definitively rejecting the token for this surface —
48+
// treated as a real scope failure. Anything else (429, 5xx, a malformed
49+
// response) is inconclusive: it says nothing about whether the token has
50+
// scope, only that this particular check didn't get a clean answer.
51+
function classifyStatus(status: number, providerLabel: string): OAuthScopeCheckResult {
52+
if (status === 401 || status === 403) return insufficientScope(providerLabel);
53+
return unavailable(providerLabel);
54+
}
55+
56+
async function checkCodexScope(profile: string): Promise<OAuthScopeCheckResult> {
57+
const providerLabel = "Codex";
58+
try {
59+
const headers = await codexAuthHeaders(profile);
60+
const url = `${CODEX_BASE_URL}${CODEX_MODELS_PATH}?client_version=${encodeURIComponent(CODEX_CLIENT_VERSION)}`;
61+
const res = await fetch(url, {
62+
headers,
63+
signal: AbortSignal.timeout(SCOPE_CHECK_TIMEOUT_MS),
64+
});
65+
if (res.ok) return { status: "ok" };
66+
return classifyStatus(res.status, providerLabel);
67+
} catch {
68+
return unavailable(providerLabel);
69+
}
70+
}
71+
72+
async function checkXaiScope(profile: string): Promise<OAuthScopeCheckResult> {
73+
const providerLabel = "Grok";
74+
try {
75+
const headers = await xaiAuthHeaders(profile);
76+
const res = await fetch(`${XAI_BASE_URL}/models`, {
77+
headers,
78+
signal: AbortSignal.timeout(XAI_TOKEN_TIMEOUT_MS),
79+
});
80+
if (res.ok) return { status: "ok" };
81+
return classifyStatus(res.status, providerLabel);
82+
} catch {
83+
return unavailable(providerLabel);
84+
}
85+
}
86+
87+
// Probe an OAuth-issued token against the provider's own catalog/list
88+
// endpoint to prove it carries real API scope, rather than trusting the
89+
// login result alone.
90+
export async function checkOAuthProviderScope(
91+
kind: OAuthScopeCheckKind,
92+
profile: string,
93+
): Promise<OAuthScopeCheckResult> {
94+
return kind === "codex" ? checkCodexScope(profile) : checkXaiScope(profile);
95+
}

src/auth/xai/usage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function parseXaiUsage(payload: unknown): XaiUsage {
5959
};
6060
}
6161

62-
async function xaiAuthHeaders(profileName: string): Promise<Record<string, string>> {
62+
export async function xaiAuthHeaders(profileName: string): Promise<Record<string, string>> {
6363
const { access } = await getValidXaiToken(profileName);
6464
const headers: Record<string, string> = {
6565
authorization: `Bearer ${access}`,

src/tui/provider-setup-submit.test.ts

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,29 @@
1-
import { describe, test, expect } from "bun:test";
1+
import { describe, test, expect, afterEach, afterAll, mock } from "bun:test";
22
import { mkdtemp, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

6-
import { buildProviderSubmitHandler } from "./provider-setup-submit.js";
7-
import { loadLocalSettings, loadSettings, localSettingsPath } from "../config/settings.js";
6+
import type { OAuthScopeCheckResult } from "../auth/oauth-scope-check.js";
7+
8+
// The oauth branch probes real provider scope over the network; stub the
9+
// check so these tests exercise buildProviderSubmitHandler's own branching
10+
// (ok / insufficient-scope / unavailable) without a live call. Restored after
11+
// this file's tests run so a mock never leaks into another suite that
12+
// imports provider-setup-submit.js and expects the real probe.
13+
const realOAuthScopeCheck = { ...(await import("../auth/oauth-scope-check.js")) };
14+
let scopeCheckResult: OAuthScopeCheckResult = { status: "ok" };
15+
mock.module("../auth/oauth-scope-check.js", () => ({
16+
...realOAuthScopeCheck,
17+
checkOAuthProviderScope: async () => scopeCheckResult,
18+
}));
19+
20+
afterAll(() => {
21+
mock.module("../auth/oauth-scope-check.js", () => realOAuthScopeCheck);
22+
});
23+
24+
const { buildProviderSubmitHandler } = await import("./provider-setup-submit.js");
25+
const { loadLocalSettings, loadSettings, localSettingsPath } =
26+
await import("../config/settings.js");
827
import type { ProviderFormValues, SubmitPhase } from "./provider-setup.js";
928

1029
const noopSetPhase = (_phase: SubmitPhase): void => {};
@@ -192,4 +211,127 @@ describe("buildProviderSubmitHandler", () => {
192211
expect(resolvedModel).toBe("claude-sonnet-4");
193212
});
194213
});
214+
215+
describe("OAuth-issued token scope validation (CL-5710)", () => {
216+
afterEach(() => {
217+
scopeCheckResult = { status: "ok" };
218+
});
219+
220+
test("valid scope: onboarding completes", async () => {
221+
await withTempDir(async (dir) => {
222+
scopeCheckResult = { status: "ok" };
223+
const path = join(dir, "settings.json");
224+
const localPath = localSettingsPath(dir);
225+
const submit = buildProviderSubmitHandler(path, null, localPath);
226+
227+
await submit(
228+
{
229+
name: "",
230+
baseURL: "https://chatgpt.com/backend-api",
231+
apiKey: "",
232+
model: "gpt-5",
233+
oauthProfile: "work",
234+
},
235+
noopSetPhase,
236+
{
237+
skipValidation: false,
238+
oauth: { kind: "codex", providerName: "codex/work", profile: "work" },
239+
},
240+
);
241+
242+
const local = await loadLocalSettings(localPath);
243+
expect(local).toEqual({ provider: "codex/work", model: "gpt-5" });
244+
});
245+
});
246+
247+
test("definitively insufficient scope: onboarding is rejected with a setup-attributable message, not a raw adapter error", async () => {
248+
await withTempDir(async (dir) => {
249+
scopeCheckResult = {
250+
status: "insufficient-scope",
251+
message: "Your Codex sign-in doesn't carry API access. Reconnect Codex and try again.",
252+
};
253+
const path = join(dir, "settings.json");
254+
const localPath = localSettingsPath(dir);
255+
const submit = buildProviderSubmitHandler(path, null, localPath);
256+
257+
await expect(
258+
submit(
259+
{
260+
name: "",
261+
baseURL: "https://chatgpt.com/backend-api",
262+
apiKey: "",
263+
model: "gpt-5",
264+
oauthProfile: "work",
265+
},
266+
noopSetPhase,
267+
{
268+
skipValidation: false,
269+
oauth: { kind: "codex", providerName: "codex/work", profile: "work" },
270+
},
271+
),
272+
).rejects.toThrow(/reconnect codex/i);
273+
274+
// Nothing is persisted on a proven scope failure.
275+
expect(await loadSettings(path)).toBeNull();
276+
expect(await loadLocalSettings(localPath)).toBeNull();
277+
});
278+
});
279+
280+
test("check-unavailable (network blip): onboarding still completes, not blocked", async () => {
281+
await withTempDir(async (dir) => {
282+
scopeCheckResult = {
283+
status: "unavailable",
284+
message: "Couldn't confirm Codex API access right now.",
285+
};
286+
const path = join(dir, "settings.json");
287+
const localPath = localSettingsPath(dir);
288+
const submit = buildProviderSubmitHandler(path, null, localPath);
289+
290+
await submit(
291+
{
292+
name: "",
293+
baseURL: "https://chatgpt.com/backend-api",
294+
apiKey: "",
295+
model: "gpt-5",
296+
oauthProfile: "work",
297+
},
298+
noopSetPhase,
299+
{
300+
skipValidation: false,
301+
oauth: { kind: "codex", providerName: "codex/work", profile: "work" },
302+
},
303+
);
304+
305+
const local = await loadLocalSettings(localPath);
306+
expect(local).toEqual({ provider: "codex/work", model: "gpt-5" });
307+
});
308+
});
309+
310+
test("skipValidation bypasses the scope probe entirely", async () => {
311+
await withTempDir(async (dir) => {
312+
scopeCheckResult = { status: "insufficient-scope", message: "should never be thrown" };
313+
const path = join(dir, "settings.json");
314+
const localPath = localSettingsPath(dir);
315+
const submit = buildProviderSubmitHandler(path, null, localPath);
316+
317+
await submit(
318+
{
319+
name: "",
320+
baseURL: "https://chatgpt.com/backend-api",
321+
apiKey: "",
322+
model: "gpt-5",
323+
oauthProfile: "work",
324+
},
325+
noopSetPhase,
326+
{
327+
skipValidation: true,
328+
oauth: { kind: "codex", providerName: "codex/work", profile: "work" },
329+
},
330+
);
331+
332+
const local = await loadLocalSettings(localPath);
333+
expect(local).toEqual({ provider: "codex/work", model: "gpt-5" });
334+
});
335+
});
336+
});
195337
});

0 commit comments

Comments
 (0)