From 19d109fa71bb384cc50f68faa90a718b111ce5a0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 13:19:49 -0700 Subject: [PATCH 1/2] 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. --- src/auth/codex/usage.ts | 2 +- src/auth/oauth-scope-check.test.ts | 83 ++++++++++++++++ src/auth/oauth-scope-check.ts | 95 ++++++++++++++++++ src/auth/xai/usage.ts | 2 +- src/tui/provider-setup-submit.test.ts | 132 +++++++++++++++++++++++++- src/tui/provider-setup-submit.ts | 23 +++-- 6 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 src/auth/oauth-scope-check.test.ts create mode 100644 src/auth/oauth-scope-check.ts diff --git a/src/auth/codex/usage.ts b/src/auth/codex/usage.ts index cff3ed3fb..b7205d86f 100644 --- a/src/auth/codex/usage.ts +++ b/src/auth/codex/usage.ts @@ -73,7 +73,7 @@ function parseUsage(payload: unknown): CodexUsage { }; } -async function codexAuthHeaders(profileName: string): Promise> { +export async function codexAuthHeaders(profileName: string): Promise> { const { access, accountId } = await getValidCodexToken(profileName); const headers: Record = { authorization: `Bearer ${access}`, diff --git a/src/auth/oauth-scope-check.test.ts b/src/auth/oauth-scope-check.test.ts new file mode 100644 index 000000000..46b3f3c53 --- /dev/null +++ b/src/auth/oauth-scope-check.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +// getValidCodexToken/getValidXaiToken hit the real home-level auth store and +// refresh endpoints; stub the session layer so this test only exercises the +// scope probe's own HTTP call and status classification. +mock.module("./codex/session.js", () => ({ + getValidCodexToken: async () => ({ access: "codex-token", accountId: "acct-1" }), +})); +mock.module("./xai/session.js", () => ({ + getValidXaiToken: async () => ({ access: "xai-token" }), + xaiUserIdFromAccessToken: () => undefined, +})); + +const { checkOAuthProviderScope } = await import("./oauth-scope-check.js"); + +const originalFetch = global.fetch; + +function stubFetch(impl: (url: string) => Response | Promise): void { + global.fetch = (async (input: RequestInfo | URL) => impl(String(input))) as typeof fetch; +} + +describe("checkOAuthProviderScope", () => { + afterEach(() => { + global.fetch = originalFetch; + }); + + test("codex: ok when the catalog call succeeds", async () => { + stubFetch(() => new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 })); + const result = await checkOAuthProviderScope("codex", "work"); + expect(result.status).toBe("ok"); + }); + + test("codex: insufficient-scope on a definitive 403", async () => { + stubFetch(() => new Response("forbidden", { status: 403 })); + const result = await checkOAuthProviderScope("codex", "work"); + expect(result.status).toBe("insufficient-scope"); + if (result.status === "insufficient-scope") { + expect(result.message).toMatch(/reconnect/i); + // Must never surface the raw response body. + expect(result.message).not.toContain("forbidden"); + } + }); + + test("codex: insufficient-scope on a definitive 401", async () => { + stubFetch(() => new Response("nope", { status: 401 })); + const result = await checkOAuthProviderScope("codex", "work"); + expect(result.status).toBe("insufficient-scope"); + }); + + test("codex: unavailable on a network failure, not blocked", async () => { + stubFetch(() => { + throw new Error("fetch failed"); + }); + const result = await checkOAuthProviderScope("codex", "work"); + expect(result.status).toBe("unavailable"); + }); + + test("codex: unavailable (not scope failure) on a 500", async () => { + stubFetch(() => new Response("boom", { status: 500 })); + const result = await checkOAuthProviderScope("codex", "work"); + expect(result.status).toBe("unavailable"); + }); + + test("xai: ok when the models call succeeds", async () => { + stubFetch(() => new Response(JSON.stringify({ data: [] }), { status: 200 })); + const result = await checkOAuthProviderScope("xai", "personal"); + expect(result.status).toBe("ok"); + }); + + test("xai: insufficient-scope on a definitive 403", async () => { + stubFetch(() => new Response("forbidden", { status: 403 })); + const result = await checkOAuthProviderScope("xai", "personal"); + expect(result.status).toBe("insufficient-scope"); + }); + + test("xai: unavailable on a timeout-style abort", async () => { + stubFetch(() => { + throw new DOMException("The operation timed out.", "TimeoutError"); + }); + const result = await checkOAuthProviderScope("xai", "personal"); + expect(result.status).toBe("unavailable"); + }); +}); diff --git a/src/auth/oauth-scope-check.ts b/src/auth/oauth-scope-check.ts new file mode 100644 index 000000000..cfba41e0b --- /dev/null +++ b/src/auth/oauth-scope-check.ts @@ -0,0 +1,95 @@ +// A completed OAuth login proves the token is real (issued by the provider's +// own authorization server via PKCE) but not that it carries usable API +// scope — e.g. a chat-only subscription without API access. Trusting the +// login result alone lets onboarding complete on a token whose first real +// inference call fails with a confusing auth error. This runs one cheap, +// authoritative call against each provider's own catalog/list endpoint +// (the same surface real inference would hit) so a scope gap is caught +// during setup instead of during the first conversation. +// +// Never logs or persists the token or any response body — only the HTTP +// status is inspected to classify the result. + +import { CODEX_BASE_URL, CODEX_MODELS_PATH, CODEX_CLIENT_VERSION } from "./codex/constants.js"; +import { codexAuthHeaders } from "./codex/usage.js"; +import { XAI_BASE_URL, XAI_TOKEN_TIMEOUT_MS } from "./xai/constants.js"; +import { xaiAuthHeaders } from "./xai/usage.js"; + +export type OAuthScopeCheckKind = "codex" | "xai"; + +export type OAuthScopeCheckResult = + | { status: "ok" } + | { status: "insufficient-scope"; message: string } + // The probe could not run to completion (network blip, timeout, rate + // limit, provider hiccup). This must never be treated the same as a + // definitive scope failure — a transient failure must not lock a + // legitimate user out of onboarding. + | { status: "unavailable"; message: string }; + +const SCOPE_CHECK_TIMEOUT_MS = 10_000; + +function insufficientScope(providerLabel: string): OAuthScopeCheckResult { + return { + status: "insufficient-scope", + message: + `Your ${providerLabel} sign-in doesn't carry API access (it looks like a chat-only plan). ` + + `Reconnect ${providerLabel} with an account/plan that includes API access, then try again.`, + }; +} + +function unavailable(providerLabel: string): OAuthScopeCheckResult { + return { + status: "unavailable", + message: `Couldn't confirm ${providerLabel} API access right now — continuing without blocking setup.`, + }; +} + +// 401/403 is the provider definitively rejecting the token for this surface — +// treated as a real scope failure. Anything else (429, 5xx, a malformed +// response) is inconclusive: it says nothing about whether the token has +// scope, only that this particular check didn't get a clean answer. +function classifyStatus(status: number, providerLabel: string): OAuthScopeCheckResult { + if (status === 401 || status === 403) return insufficientScope(providerLabel); + return unavailable(providerLabel); +} + +async function checkCodexScope(profile: string): Promise { + const providerLabel = "Codex"; + try { + const headers = await codexAuthHeaders(profile); + const url = `${CODEX_BASE_URL}${CODEX_MODELS_PATH}?client_version=${encodeURIComponent(CODEX_CLIENT_VERSION)}`; + const res = await fetch(url, { + headers, + signal: AbortSignal.timeout(SCOPE_CHECK_TIMEOUT_MS), + }); + if (res.ok) return { status: "ok" }; + return classifyStatus(res.status, providerLabel); + } catch { + return unavailable(providerLabel); + } +} + +async function checkXaiScope(profile: string): Promise { + const providerLabel = "Grok"; + try { + const headers = await xaiAuthHeaders(profile); + const res = await fetch(`${XAI_BASE_URL}/models`, { + headers, + signal: AbortSignal.timeout(XAI_TOKEN_TIMEOUT_MS), + }); + if (res.ok) return { status: "ok" }; + return classifyStatus(res.status, providerLabel); + } catch { + return unavailable(providerLabel); + } +} + +// Probe an OAuth-issued token against the provider's own catalog/list +// endpoint to prove it carries real API scope, rather than trusting the +// login result alone. +export async function checkOAuthProviderScope( + kind: OAuthScopeCheckKind, + profile: string, +): Promise { + return kind === "codex" ? checkCodexScope(profile) : checkXaiScope(profile); +} diff --git a/src/auth/xai/usage.ts b/src/auth/xai/usage.ts index d37d4e8d7..e2396a5c4 100644 --- a/src/auth/xai/usage.ts +++ b/src/auth/xai/usage.ts @@ -59,7 +59,7 @@ function parseXaiUsage(payload: unknown): XaiUsage { }; } -async function xaiAuthHeaders(profileName: string): Promise> { +export async function xaiAuthHeaders(profileName: string): Promise> { const { access } = await getValidXaiToken(profileName); const headers: Record = { authorization: `Bearer ${access}`, diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index 84a08933b..35fd6439d 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -1,10 +1,22 @@ -import { describe, test, expect } from "bun:test"; +import { describe, test, expect, afterEach, mock } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildProviderSubmitHandler } from "./provider-setup-submit.js"; -import { loadLocalSettings, loadSettings, localSettingsPath } from "../config/settings.js"; +import type { OAuthScopeCheckResult } from "../auth/oauth-scope-check.js"; + +// The oauth branch probes real provider scope over the network; stub the +// check so these tests exercise buildProviderSubmitHandler's own branching +// (ok / insufficient-scope / unavailable) without a live call. +let scopeCheckResult: OAuthScopeCheckResult = { status: "ok" }; +mock.module("../auth/oauth-scope-check.js", () => ({ + checkOAuthProviderScope: async () => scopeCheckResult, +})); + +const { buildProviderSubmitHandler } = await import("./provider-setup-submit.js"); +const { loadLocalSettings, loadSettings, localSettingsPath } = await import( + "../config/settings.js" +); import type { ProviderFormValues, SubmitPhase } from "./provider-setup.js"; const noopSetPhase = (_phase: SubmitPhase): void => {}; @@ -192,4 +204,118 @@ describe("buildProviderSubmitHandler", () => { expect(resolvedModel).toBe("claude-sonnet-4"); }); }); + + describe("OAuth-issued token scope validation (CL-5710)", () => { + afterEach(() => { + scopeCheckResult = { status: "ok" }; + }); + + test("valid scope: onboarding completes", async () => { + await withTempDir(async (dir) => { + scopeCheckResult = { status: "ok" }; + const path = join(dir, "settings.json"); + const localPath = localSettingsPath(dir); + const submit = buildProviderSubmitHandler(path, null, localPath); + + await submit( + { + name: "", + baseURL: "https://chatgpt.com/backend-api", + apiKey: "", + model: "gpt-5", + oauthProfile: "work", + }, + noopSetPhase, + { skipValidation: false, oauth: { kind: "codex", providerName: "codex/work", profile: "work" } }, + ); + + const local = await loadLocalSettings(localPath); + expect(local).toEqual({ provider: "codex/work", model: "gpt-5" }); + }); + }); + + test("definitively insufficient scope: onboarding is rejected with a setup-attributable message, not a raw adapter error", async () => { + await withTempDir(async (dir) => { + scopeCheckResult = { + status: "insufficient-scope", + message: "Your Codex sign-in doesn't carry API access. Reconnect Codex and try again.", + }; + const path = join(dir, "settings.json"); + const localPath = localSettingsPath(dir); + const submit = buildProviderSubmitHandler(path, null, localPath); + + await expect( + submit( + { + name: "", + baseURL: "https://chatgpt.com/backend-api", + apiKey: "", + model: "gpt-5", + oauthProfile: "work", + }, + noopSetPhase, + { + skipValidation: false, + oauth: { kind: "codex", providerName: "codex/work", profile: "work" }, + }, + ), + ).rejects.toThrow(/reconnect codex/i); + + // Nothing is persisted on a proven scope failure. + expect(await loadSettings(path)).toBeNull(); + expect(await loadLocalSettings(localPath)).toBeNull(); + }); + }); + + test("check-unavailable (network blip): onboarding still completes, not blocked", async () => { + await withTempDir(async (dir) => { + scopeCheckResult = { + status: "unavailable", + message: "Couldn't confirm Codex API access right now.", + }; + const path = join(dir, "settings.json"); + const localPath = localSettingsPath(dir); + const submit = buildProviderSubmitHandler(path, null, localPath); + + await submit( + { + name: "", + baseURL: "https://chatgpt.com/backend-api", + apiKey: "", + model: "gpt-5", + oauthProfile: "work", + }, + noopSetPhase, + { skipValidation: false, oauth: { kind: "codex", providerName: "codex/work", profile: "work" } }, + ); + + const local = await loadLocalSettings(localPath); + expect(local).toEqual({ provider: "codex/work", model: "gpt-5" }); + }); + }); + + test("skipValidation bypasses the scope probe entirely", async () => { + await withTempDir(async (dir) => { + scopeCheckResult = { status: "insufficient-scope", message: "should never be thrown" }; + const path = join(dir, "settings.json"); + const localPath = localSettingsPath(dir); + const submit = buildProviderSubmitHandler(path, null, localPath); + + await submit( + { + name: "", + baseURL: "https://chatgpt.com/backend-api", + apiKey: "", + model: "gpt-5", + oauthProfile: "work", + }, + noopSetPhase, + { skipValidation: true, oauth: { kind: "codex", providerName: "codex/work", profile: "work" } }, + ); + + const local = await loadLocalSettings(localPath); + expect(local).toEqual({ provider: "codex/work", model: "gpt-5" }); + }); + }); + }); }); diff --git a/src/tui/provider-setup-submit.ts b/src/tui/provider-setup-submit.ts index 27e7f2948..4d8e409b1 100644 --- a/src/tui/provider-setup-submit.ts +++ b/src/tui/provider-setup-submit.ts @@ -1,3 +1,4 @@ +import { checkOAuthProviderScope } from "../auth/oauth-scope-check.js"; import { mergeProviderIntoSettings, saveGlobalSettings, @@ -52,14 +53,22 @@ export function buildProviderSubmitHandler( // persisted here — the same two files /model writes when switching. // // Unlike a pasted key, this credential was just issued by the real - // provider's own OAuth server completing a PKCE round-trip, so the - // "unverified" concept the API-key path uses doesn't apply the same way - // — there is no separate probe step to skip. What a completed login - // does not confirm is that the resulting token actually carries API - // scope (vs. e.g. a chat-only subscription), which can still surface as - // a first-send auth error; tracked separately rather than faked here - // with a flag this path has no real signal for. + // provider's own OAuth server completing a PKCE round-trip — so the + // token is real. That still doesn't confirm it carries usable API scope + // (vs. e.g. a chat-only subscription), which would otherwise surface as + // a confusing first-send auth error with no setup-attributable hint. + // Probe the provider's own catalog endpoint with the issued token before + // treating onboarding as complete: a definitive scope rejection blocks + // the submit with an actionable message (mirrors the API-key path's + // connection test); a check that could not run at all (network blip, + // timeout, rate limit) never blocks — only a proven scope failure does. if (oauth !== undefined) { + if (!skipValidation) { + const scopeCheck = await checkOAuthProviderScope(oauth.kind, oauth.profile); + if (scopeCheck.status === "insufficient-scope") { + throw new Error(scopeCheck.message); + } + } setPhase("saving"); const base = existing ?? { providers: {} }; await saveGlobalSettings(settingsPath, { From b841bd3837a2be529145abab240c7fda68eacb5c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 13:39:36 -0700 Subject: [PATCH 2/2] Fix test-mock leakage breaking codex-session and eslint unused import mock.module for codex/session.js and xai/session.js in oauth-scope-check.test.ts, and for oauth-scope-check.js in provider-setup-submit.test.ts, replaced those modules for the whole bun test process without restoring them, breaking tests/unit/codex-session.test.ts which imports the real module directly. Capture the real module before mocking and restore it in afterAll. Also drops an unused beforeEach import that eslint flagged. --- src/auth/oauth-scope-check.test.ts | 18 ++++++++++++--- src/tui/provider-setup-submit.test.ts | 32 ++++++++++++++++++++------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/auth/oauth-scope-check.test.ts b/src/auth/oauth-scope-check.test.ts index 46b3f3c53..0dc63525e 100644 --- a/src/auth/oauth-scope-check.test.ts +++ b/src/auth/oauth-scope-check.test.ts @@ -1,16 +1,28 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; // getValidCodexToken/getValidXaiToken hit the real home-level auth store and // refresh endpoints; stub the session layer so this test only exercises the -// scope probe's own HTTP call and status classification. +// scope probe's own HTTP call and status classification. Other suites +// (tests/unit/codex-session.test.ts) import the real modules directly, so the +// mocks must be torn down after this file's tests run rather than leaking +// into the rest of the bun test process. +const realCodexSession = { ...(await import("./codex/session.js")) }; +const realXaiSession = { ...(await import("./xai/session.js")) }; + mock.module("./codex/session.js", () => ({ + ...realCodexSession, getValidCodexToken: async () => ({ access: "codex-token", accountId: "acct-1" }), })); mock.module("./xai/session.js", () => ({ + ...realXaiSession, getValidXaiToken: async () => ({ access: "xai-token" }), - xaiUserIdFromAccessToken: () => undefined, })); +afterAll(() => { + mock.module("./codex/session.js", () => realCodexSession); + mock.module("./xai/session.js", () => realXaiSession); +}); + const { checkOAuthProviderScope } = await import("./oauth-scope-check.js"); const originalFetch = global.fetch; diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index 35fd6439d..d64174105 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, afterEach, mock } from "bun:test"; +import { describe, test, expect, afterEach, afterAll, mock } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,16 +7,23 @@ import type { OAuthScopeCheckResult } from "../auth/oauth-scope-check.js"; // The oauth branch probes real provider scope over the network; stub the // check so these tests exercise buildProviderSubmitHandler's own branching -// (ok / insufficient-scope / unavailable) without a live call. +// (ok / insufficient-scope / unavailable) without a live call. Restored after +// this file's tests run so a mock never leaks into another suite that +// imports provider-setup-submit.js and expects the real probe. +const realOAuthScopeCheck = { ...(await import("../auth/oauth-scope-check.js")) }; let scopeCheckResult: OAuthScopeCheckResult = { status: "ok" }; mock.module("../auth/oauth-scope-check.js", () => ({ + ...realOAuthScopeCheck, checkOAuthProviderScope: async () => scopeCheckResult, })); +afterAll(() => { + mock.module("../auth/oauth-scope-check.js", () => realOAuthScopeCheck); +}); + const { buildProviderSubmitHandler } = await import("./provider-setup-submit.js"); -const { loadLocalSettings, loadSettings, localSettingsPath } = await import( - "../config/settings.js" -); +const { loadLocalSettings, loadSettings, localSettingsPath } = + await import("../config/settings.js"); import type { ProviderFormValues, SubmitPhase } from "./provider-setup.js"; const noopSetPhase = (_phase: SubmitPhase): void => {}; @@ -226,7 +233,10 @@ describe("buildProviderSubmitHandler", () => { oauthProfile: "work", }, noopSetPhase, - { skipValidation: false, oauth: { kind: "codex", providerName: "codex/work", profile: "work" } }, + { + skipValidation: false, + oauth: { kind: "codex", providerName: "codex/work", profile: "work" }, + }, ); const local = await loadLocalSettings(localPath); @@ -286,7 +296,10 @@ describe("buildProviderSubmitHandler", () => { oauthProfile: "work", }, noopSetPhase, - { skipValidation: false, oauth: { kind: "codex", providerName: "codex/work", profile: "work" } }, + { + skipValidation: false, + oauth: { kind: "codex", providerName: "codex/work", profile: "work" }, + }, ); const local = await loadLocalSettings(localPath); @@ -310,7 +323,10 @@ describe("buildProviderSubmitHandler", () => { oauthProfile: "work", }, noopSetPhase, - { skipValidation: true, oauth: { kind: "codex", providerName: "codex/work", profile: "work" } }, + { + skipValidation: true, + oauth: { kind: "codex", providerName: "codex/work", profile: "work" }, + }, ); const local = await loadLocalSettings(localPath);