Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/auth/codex/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function parseUsage(payload: unknown): CodexUsage {
};
}

async function codexAuthHeaders(profileName: string): Promise<Record<string, string>> {
export async function codexAuthHeaders(profileName: string): Promise<Record<string, string>> {
const { access, accountId } = await getValidCodexToken(profileName);
const headers: Record<string, string> = {
authorization: `Bearer ${access}`,
Expand Down
95 changes: 95 additions & 0 deletions src/auth/oauth-scope-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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. 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" }),
}));

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;

function stubFetch(impl: (url: string) => Response | Promise<Response>): 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");
});
});
95 changes: 95 additions & 0 deletions src/auth/oauth-scope-check.ts
Original file line number Diff line number Diff line change
@@ -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<OAuthScopeCheckResult> {
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<OAuthScopeCheckResult> {
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<OAuthScopeCheckResult> {
return kind === "codex" ? checkCodexScope(profile) : checkXaiScope(profile);
}
2 changes: 1 addition & 1 deletion src/auth/xai/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function parseXaiUsage(payload: unknown): XaiUsage {
};
}

async function xaiAuthHeaders(profileName: string): Promise<Record<string, string>> {
export async function xaiAuthHeaders(profileName: string): Promise<Record<string, string>> {
const { access } = await getValidXaiToken(profileName);
const headers: Record<string, string> = {
authorization: `Bearer ${access}`,
Expand Down
148 changes: 145 additions & 3 deletions src/tui/provider-setup-submit.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
import { describe, test, expect } 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";

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. 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");
import type { ProviderFormValues, SubmitPhase } from "./provider-setup.js";

const noopSetPhase = (_phase: SubmitPhase): void => {};
Expand Down Expand Up @@ -192,4 +211,127 @@ 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" });
});
});
});
});
Loading
Loading