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
8 changes: 8 additions & 0 deletions src/auth/callback-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
PRODUCT_SITE_URL,
} from "../branding.js";
import { callbackPageHtml, humanizeIdentifier } from "./callback-page.js";
import { authorizationDoneHtml } from "./oauth/callback-server.js";

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

test("provider authorization waits for native setup before claiming connection", () => {
const html = authorizationDoneHtml("Codex");
expect(html).toContain("Codex authorization received");
expect(html).toContain("finish setup");
expect(html).not.toContain("connected successfully");
});

test("failure names the server and the humanized reason", () => {
const html = callbackPageHtml({ subject: "granola", error: "access_denied" });
expect(html).toContain("Granola failed to connect");
Expand Down
19 changes: 14 additions & 5 deletions src/auth/callback-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ export interface CallbackPage {
readonly subject?: string;
/** Why it failed. Omit for the success page. */
readonly error?: string;
/** Authorization succeeded, but the native setup flow still has work to do. */
readonly pendingSetup?: boolean;
}

/**
Expand All @@ -276,19 +278,26 @@ export function callbackPageHtml(page: CallbackPage = {}): string {
const failed = page.error !== undefined;
const subject =
page.subject === undefined ? undefined : escapeHtml(humanizeIdentifier(page.subject));
const pendingSetup = !failed && page.pendingSetup === true;
const tone = failed ? "var(--accent)" : "var(--ok)";
const label = failed ? "not connected" : "connected";
const label = failed ? "not connected" : pendingSetup ? "authorization received" : "connected";
const heading = failed
? subject === undefined
? "Authorization did not complete"
: `${subject} failed to connect`
: subject === undefined
? "Authorization complete"
: `${subject} connected successfully`;
: pendingSetup
? subject === undefined
? "Authorization received"
: `${subject} authorization received`
: subject === undefined
? "Authorization complete"
: `${subject} connected successfully`;
const reason = escapeHtml(humanizeIdentifier(page.error ?? ""));
const body = failed
? `${reason}. Close this tab and try again from ${PRODUCT_NAME}.`
: `You can close this tab and return to ${PRODUCT_NAME}.`;
: pendingSetup
? `Return to ${PRODUCT_NAME} to finish setup.`
: `You can close this tab and return to ${PRODUCT_NAME}.`;
return [
"<!doctype html>",
'<html lang="en">',
Expand Down
4 changes: 2 additions & 2 deletions src/auth/codex/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import {
import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "./constants.js";
import { startCodexCallbackServer } from "./callback-server.js";
import { buildAuthorizeUrl, exchangeCode } from "./oauth.js";
import { saveCodexProfile } from "./store.js";
import { saveCodexProfile, type CodexTokens } from "./store.js";

export { openInBrowser };

export type CodexLoginHandle = OAuthLoginHandle;
export type CodexLoginHandle = OAuthLoginHandle<CodexTokens>;
export type StartCodexLoginOptions = StartOAuthLoginOptions;

// Drive the loopback PKCE login for a Codex profile.
Expand Down
10 changes: 10 additions & 0 deletions src/auth/codex/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,13 @@ const session = createTokenSession<CodexTokens, CodexAccess>({

export const isCodexTokenExpired = session.isExpired;
export const getValidCodexToken = session.getValidToken;

export async function refreshStagedCodexTokens(
tokens: CodexTokens,
now: number = Date.now(),
): Promise<CodexTokens> {
if (!isCodexTokenExpired(tokens, now)) return tokens;
const refreshed = await refreshTokens(tokens.refresh, now);
Object.assign(tokens, refreshed);
return tokens;
}
14 changes: 10 additions & 4 deletions src/auth/codex/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,23 @@ function parseUsage(payload: unknown): CodexUsage {
};
}

export async function codexAuthHeaders(profileName: string): Promise<Record<string, string>> {
const { access, accountId } = await getValidCodexToken(profileName);
export function codexAuthHeadersForToken(token: {
readonly access: string;
readonly accountId?: string | undefined;
}): Record<string, string> {
const headers: Record<string, string> = {
authorization: `Bearer ${access}`,
authorization: `Bearer ${token.access}`,
originator: CODEX_AUTHORIZE_EXTRA_PARAMS["originator"] ?? "codex_cli_rs",
"user-agent": `${COMMAND_NAME} (codex_cli_rs/${CODEX_CLIENT_VERSION})`,
};
if (accountId !== undefined) headers["chatgpt-account-id"] = accountId;
if (token.accountId !== undefined) headers["chatgpt-account-id"] = token.accountId;
return headers;
}

export async function codexAuthHeaders(profileName: string): Promise<Record<string, string>> {
return codexAuthHeadersForToken(await getValidCodexToken(profileName));
}

// Fetch the live usage/quota snapshot for a Codex profile.
export async function fetchCodexUsage(profileName: string): Promise<CodexUsage> {
const res = await fetch(`${CODEX_BASE_URL}${CODEX_USAGE_PATH}`, {
Expand Down
183 changes: 137 additions & 46 deletions src/auth/oauth-scope-check.test.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,185 @@
import { afterEach, describe, expect, test } from "bun:test";
import { withMockedModule } from "../../tests/helpers/mock-module.js";

// 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.
await withMockedModule(
import.meta.resolve("./codex/session.js"),
(real: typeof import("./codex/session.js")) => ({
...real,
getValidCodexToken: async () => ({ access: "codex-token", accountId: "acct-1" }),
}),
);
await withMockedModule(
import.meta.resolve("./xai/session.js"),
(real: typeof import("./xai/session.js")) => ({
...real,
getValidXaiToken: async () => ({ access: "xai-token" }),
}),
);

const { checkOAuthProviderScope } = await import("./oauth-scope-check.js");

import { checkOAuthProviderScope } from "./oauth-scope-check.js";

const originalFetch = global.fetch;
const codexTokens = {
access: "staged-codex-token",
refresh: "codex-refresh",
expiresAt: Date.now() + 3_600_000,
accountId: "acct-staged",
};
const xaiTokens = {
access: "staged-xai-token",
refresh: "xai-refresh",
expiresAt: Date.now() + 3_600_000,
};

function stubFetch(impl: (url: string) => Response | Promise<Response>): void {
global.fetch = (async (input: RequestInfo | URL) => impl(String(input))) as typeof fetch;
function stubFetch(impl: (url: string, init?: RequestInit) => Response | Promise<Response>): void {
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) =>
impl(String(input), init)) 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");
test("codex: builds the probe from staged tokens", async () => {
stubFetch((_url, init) => {
expect(init?.headers).toMatchObject({
authorization: "Bearer staged-codex-token",
"chatgpt-account-id": "acct-staged",
});
return new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 });
});
const result = await checkOAuthProviderScope("codex", codexTokens);
expect(result.status).toBe("ok");
});

test("codex: refreshes expired staged tokens before classifying the probe", async () => {
const expired = { ...codexTokens, expiresAt: 0 };
const requests: string[] = [];
stubFetch((url, init) => {
requests.push(url);
if (url.includes("/oauth/token")) {
return new Response(JSON.stringify({ access_token: "refreshed-codex", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
expect(init?.headers).toMatchObject({
authorization: "Bearer refreshed-codex",
"chatgpt-account-id": "acct-staged",
});
return new Response(JSON.stringify({ models: ["gpt-5"] }), { status: 200 });
});

const result = await checkOAuthProviderScope("codex", expired);

expect(result.status).toBe("ok");
expect(requests).toHaveLength(2);
expect(expired.access).toBe("refreshed-codex");
});

test("codex: blocks a definitive staged refresh rejection", async () => {
const expired = { ...codexTokens, expiresAt: 0 };
stubFetch(() => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }));

const result = await checkOAuthProviderScope("codex", expired);

expect(result.status).toBe("blocked");
if (result.status === "blocked") {
expect(result.message).toMatch(/expired|revoked/i);
}
});

test("codex: reports a transient staged refresh failure as unavailable", async () => {
const expired = { ...codexTokens, expiresAt: 0 };
stubFetch(() => {
throw new Error("network down");
});

const result = await checkOAuthProviderScope("codex", expired);

expect(result.status).toBe("unavailable");
});

test("codex: insufficient-scope on a definitive 403", async () => {
test("codex: blocks a definitive 403 without surfacing the raw body", async () => {
stubFetch(() => new Response("forbidden", { status: 403 }));
const result = await checkOAuthProviderScope("codex", "work");
expect(result.status).toBe("insufficient-scope");
if (result.status === "insufficient-scope") {
const result = await checkOAuthProviderScope("codex", codexTokens);
expect(result.status).toBe("blocked");
if (result.status === "blocked") {
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 () => {
test("codex: blocks a definitive 401", async () => {
stubFetch(() => new Response("nope", { status: 401 }));
const result = await checkOAuthProviderScope("codex", "work");
expect(result.status).toBe("insufficient-scope");
const result = await checkOAuthProviderScope("codex", codexTokens);
expect(result.status).toBe("blocked");
});

test("codex: unavailable on a network failure, not blocked", async () => {
stubFetch(() => {
throw new Error("fetch failed");
});
const result = await checkOAuthProviderScope("codex", "work");
const result = await checkOAuthProviderScope("codex", codexTokens);
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");
const result = await checkOAuthProviderScope("codex", codexTokens);
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");
test("xai: builds the probe from staged tokens", async () => {
stubFetch((_url, init) => {
expect(init?.headers).toMatchObject({ authorization: "Bearer staged-xai-token" });
return new Response(JSON.stringify({ data: [] }), { status: 200 });
});
const result = await checkOAuthProviderScope("xai", xaiTokens);
expect(result.status).toBe("ok");
});

test("xai: refreshes expired staged tokens before classifying the probe", async () => {
const expired = { ...xaiTokens, expiresAt: 0 };
const requests: string[] = [];
stubFetch((url, init) => {
requests.push(url);
if (url.includes("/oauth2/token")) {
return new Response(JSON.stringify({ access_token: "refreshed-xai", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
expect(init?.headers).toMatchObject({ authorization: "Bearer refreshed-xai" });
return new Response(JSON.stringify({ data: [] }), { status: 200 });
});

const result = await checkOAuthProviderScope("xai", expired);

expect(result.status).toBe("ok");
expect(requests).toHaveLength(2);
expect(expired.access).toBe("refreshed-xai");
});

test("xai: blocks a definitive staged refresh rejection", async () => {
const expired = { ...xaiTokens, expiresAt: 0 };
stubFetch(() => new Response(JSON.stringify({ error: "revoked" }), { status: 401 }));

const result = await checkOAuthProviderScope("xai", expired);

expect(result.status).toBe("blocked");
if (result.status === "blocked") {
expect(result.message).toMatch(/expired|revoked/i);
}
});

test("xai: reports a transient staged refresh failure as unavailable", async () => {
const expired = { ...xaiTokens, expiresAt: 0 };
stubFetch(() => {
throw new DOMException("The operation timed out.", "TimeoutError");
});

const result = await checkOAuthProviderScope("xai", expired);

expect(result.status).toBe("unavailable");
});

test("xai: insufficient-scope on a definitive 403", async () => {
test("xai: blocks a definitive 403", async () => {
stubFetch(() => new Response("forbidden", { status: 403 }));
const result = await checkOAuthProviderScope("xai", "personal");
expect(result.status).toBe("insufficient-scope");
const result = await checkOAuthProviderScope("xai", xaiTokens);
expect(result.status).toBe("blocked");
});

test("xai: unavailable on a timeout-style abort", async () => {
stubFetch(() => {
throw new DOMException("The operation timed out.", "TimeoutError");
});
const result = await checkOAuthProviderScope("xai", "personal");
const result = await checkOAuthProviderScope("xai", xaiTokens);
expect(result.status).toBe("unavailable");
});
});
Loading
Loading