Skip to content

Commit 19d109f

Browse files
committed
Validate OAuth-issued provider tokens carry API scope before onboarding completes
A completed Codex/xAI OAuth login proves the token is real but not that it carries usable API scope (e.g. a chat-only subscription). Probe each provider's own catalog endpoint with the issued token before treating onboarding as complete: a definitive 401/403 rejects the submit with an actionable message pointing back to reconnecting; a check that can't run at all (network blip, timeout, 5xx) never blocks onboarding, only a proven scope failure does. Neither the token nor any response body is logged or persisted.
1 parent 2369021 commit 19d109f

6 files changed

Lines changed: 325 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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { afterEach, beforeEach, 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.
6+
mock.module("./codex/session.js", () => ({
7+
getValidCodexToken: async () => ({ access: "codex-token", accountId: "acct-1" }),
8+
}));
9+
mock.module("./xai/session.js", () => ({
10+
getValidXaiToken: async () => ({ access: "xai-token" }),
11+
xaiUserIdFromAccessToken: () => undefined,
12+
}));
13+
14+
const { checkOAuthProviderScope } = await import("./oauth-scope-check.js");
15+
16+
const originalFetch = global.fetch;
17+
18+
function stubFetch(impl: (url: string) => Response | Promise<Response>): void {
19+
global.fetch = (async (input: RequestInfo | URL) => impl(String(input))) as typeof fetch;
20+
}
21+
22+
describe("checkOAuthProviderScope", () => {
23+
afterEach(() => {
24+
global.fetch = originalFetch;
25+
});
26+
27+
test("codex: ok when the catalog call succeeds", async () => {
28+
stubFetch(() => new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 }));
29+
const result = await checkOAuthProviderScope("codex", "work");
30+
expect(result.status).toBe("ok");
31+
});
32+
33+
test("codex: insufficient-scope on a definitive 403", async () => {
34+
stubFetch(() => new Response("forbidden", { status: 403 }));
35+
const result = await checkOAuthProviderScope("codex", "work");
36+
expect(result.status).toBe("insufficient-scope");
37+
if (result.status === "insufficient-scope") {
38+
expect(result.message).toMatch(/reconnect/i);
39+
// Must never surface the raw response body.
40+
expect(result.message).not.toContain("forbidden");
41+
}
42+
});
43+
44+
test("codex: insufficient-scope on a definitive 401", async () => {
45+
stubFetch(() => new Response("nope", { status: 401 }));
46+
const result = await checkOAuthProviderScope("codex", "work");
47+
expect(result.status).toBe("insufficient-scope");
48+
});
49+
50+
test("codex: unavailable on a network failure, not blocked", async () => {
51+
stubFetch(() => {
52+
throw new Error("fetch failed");
53+
});
54+
const result = await checkOAuthProviderScope("codex", "work");
55+
expect(result.status).toBe("unavailable");
56+
});
57+
58+
test("codex: unavailable (not scope failure) on a 500", async () => {
59+
stubFetch(() => new Response("boom", { status: 500 }));
60+
const result = await checkOAuthProviderScope("codex", "work");
61+
expect(result.status).toBe("unavailable");
62+
});
63+
64+
test("xai: ok when the models call succeeds", async () => {
65+
stubFetch(() => new Response(JSON.stringify({ data: [] }), { status: 200 }));
66+
const result = await checkOAuthProviderScope("xai", "personal");
67+
expect(result.status).toBe("ok");
68+
});
69+
70+
test("xai: insufficient-scope on a definitive 403", async () => {
71+
stubFetch(() => new Response("forbidden", { status: 403 }));
72+
const result = await checkOAuthProviderScope("xai", "personal");
73+
expect(result.status).toBe("insufficient-scope");
74+
});
75+
76+
test("xai: unavailable on a timeout-style abort", async () => {
77+
stubFetch(() => {
78+
throw new DOMException("The operation timed out.", "TimeoutError");
79+
});
80+
const result = await checkOAuthProviderScope("xai", "personal");
81+
expect(result.status).toBe("unavailable");
82+
});
83+
});

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: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1-
import { describe, test, expect } from "bun:test";
1+
import { describe, test, expect, afterEach, 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.
11+
let scopeCheckResult: OAuthScopeCheckResult = { status: "ok" };
12+
mock.module("../auth/oauth-scope-check.js", () => ({
13+
checkOAuthProviderScope: async () => scopeCheckResult,
14+
}));
15+
16+
const { buildProviderSubmitHandler } = await import("./provider-setup-submit.js");
17+
const { loadLocalSettings, loadSettings, localSettingsPath } = await import(
18+
"../config/settings.js"
19+
);
820
import type { ProviderFormValues, SubmitPhase } from "./provider-setup.js";
921

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

src/tui/provider-setup-submit.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { checkOAuthProviderScope } from "../auth/oauth-scope-check.js";
12
import {
23
mergeProviderIntoSettings,
34
saveGlobalSettings,
@@ -52,14 +53,22 @@ export function buildProviderSubmitHandler(
5253
// persisted here — the same two files /model writes when switching.
5354
//
5455
// Unlike a pasted key, this credential was just issued by the real
55-
// provider's own OAuth server completing a PKCE round-trip, so the
56-
// "unverified" concept the API-key path uses doesn't apply the same way
57-
// — there is no separate probe step to skip. What a completed login
58-
// does not confirm is that the resulting token actually carries API
59-
// scope (vs. e.g. a chat-only subscription), which can still surface as
60-
// a first-send auth error; tracked separately rather than faked here
61-
// with a flag this path has no real signal for.
56+
// provider's own OAuth server completing a PKCE round-trip — so the
57+
// token is real. That still doesn't confirm it carries usable API scope
58+
// (vs. e.g. a chat-only subscription), which would otherwise surface as
59+
// a confusing first-send auth error with no setup-attributable hint.
60+
// Probe the provider's own catalog endpoint with the issued token before
61+
// treating onboarding as complete: a definitive scope rejection blocks
62+
// the submit with an actionable message (mirrors the API-key path's
63+
// connection test); a check that could not run at all (network blip,
64+
// timeout, rate limit) never blocks — only a proven scope failure does.
6265
if (oauth !== undefined) {
66+
if (!skipValidation) {
67+
const scopeCheck = await checkOAuthProviderScope(oauth.kind, oauth.profile);
68+
if (scopeCheck.status === "insufficient-scope") {
69+
throw new Error(scopeCheck.message);
70+
}
71+
}
6372
setPhase("saving");
6473
const base = existing ?? { providers: {} };
6574
await saveGlobalSettings(settingsPath, {

0 commit comments

Comments
 (0)