diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index c996aab96f4..59833b1e7f5 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -33,7 +33,6 @@ export { } from "./auth-api/pool-mode-gate"; export { createResetCreditWhamClient } from "./auth-api/reset-credit-service"; export type { CodexAuthCatalogConvergence } from "./auth-api/login-flow"; -export { handleCodexAuthAPI } from "./auth-api/routes"; import { getEffectiveActiveCodexAccountId } from "./routing"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import type { OcxConfig } from "../types"; diff --git a/src/oauth/chatgpt-device.ts b/src/oauth/chatgpt-device.ts index 8151a123548..88c5fc2b514 100644 --- a/src/oauth/chatgpt-device.ts +++ b/src/oauth/chatgpt-device.ts @@ -1,5 +1,5 @@ import type { OAuthController, OAuthCredentials } from "./types"; -import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt"; +import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt-shared"; /** * OpenAI deviceauth (device-code) grant for the ChatGPT/Codex provider. diff --git a/src/oauth/chatgpt-shared.ts b/src/oauth/chatgpt-shared.ts new file mode 100644 index 00000000000..be43bd30be6 --- /dev/null +++ b/src/oauth/chatgpt-shared.ts @@ -0,0 +1,83 @@ +import type { OAuthCredentials } from "./types"; + +/** + * What the browser callback grant and the deviceauth grant share: the public PKCE client and the + * way a token response becomes credentials. + * + * This is a leaf on purpose. `./chatgpt-device` needs these, and `./chatgpt` needs the device + * grant for `flow: "device"`; importing them straight from `./chatgpt` made the pair a cycle. + */ + +const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const TOKEN_URL = "https://auth.openai.com/oauth/token"; + +/** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */ +export const CHATGPT_CLIENT_ID = CLIENT_ID; +export const CHATGPT_TOKEN_URL = TOKEN_URL; + +export function decodeJwtPayload(token: string): Record | undefined { + const parts = token.split("."); + if (parts.length !== 3 || !parts[1]) return undefined; + try { + return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record; + } catch { + return undefined; + } +} + +export function extractAccountId(idToken?: string, accessToken?: string): string | undefined { + for (const token of [idToken, accessToken]) { + if (!token) continue; + const payload = decodeJwtPayload(token); + if (!payload) continue; + if (typeof payload.chatgpt_account_id === "string") return payload.chatgpt_account_id; + const ns = payload["https://api.openai.com/auth"]; + if (ns && typeof ns === "object" && typeof (ns as Record).chatgpt_account_id === "string") { + return (ns as Record).chatgpt_account_id as string; + } + const orgs = payload.organizations; + if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string") return orgs[0].id as string; + } + return undefined; +} + +export function extractEmail(idToken?: string, accessToken?: string): string | undefined { + for (const token of [idToken, accessToken]) { + if (!token) continue; + const payload = decodeJwtPayload(token); + if (!payload) continue; + if (typeof payload.email === "string") return payload.email.toLowerCase(); + } + return undefined; +} + +export function credsFromToken(data: Record): OAuthCredentials { + const idToken = typeof data.id_token === "string" ? data.id_token : undefined; + // This parses a response from an external boundary, so the access token is + // validated rather than cast. A 200 carrying no access_token would otherwise + // resolve a login as successful with an undefined credential, which then gets + // silently declined at persistence — a success message and no account. + const accessToken = typeof data.access_token === "string" && data.access_token.length > 0 + ? data.access_token + : undefined; + if (!accessToken) throw new Error("ChatGPT token response missing access token"); + const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : ""; + // ?? only guards null/undefined; NaN or a string expires_in would otherwise + // produce a NaN expiry that never compares as expired, and a negative duration + // would stamp an already-past expiry — both block refresh semantics. + const expiresIn = + typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0 + ? data.expires_in + : 3600; + // The computed timestamp itself must stay finite: Number.MAX_VALUE passes + // Number.isFinite but overflows to Infinity once multiplied by 1000. + const computedExpires = Date.now() + expiresIn * 1000; + const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000; + return { + access: accessToken, + refresh: refreshToken, + expires, + accountId: extractAccountId(idToken, accessToken), + email: extractEmail(idToken, accessToken), + }; +} diff --git a/src/oauth/chatgpt.ts b/src/oauth/chatgpt.ts index 2b745f25ae5..cfe8c908d4a 100644 --- a/src/oauth/chatgpt.ts +++ b/src/oauth/chatgpt.ts @@ -1,4 +1,5 @@ import { OAuthCallbackFlow } from "./callback-server"; +import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken, decodeJwtPayload, extractAccountId, extractEmail } from "./chatgpt-shared"; import type { OAuthController, OAuthCredentials } from "./types"; import { generatePKCE } from "./pkce"; import { classifyChatgptRefreshFailure } from "../codex/chatgpt-refresh-failure"; @@ -102,44 +103,20 @@ function chatGptTokenError(status: number, body: string, label: string): ChatGpt ); } -const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +/** + * The client and token decoding live in a leaf shared with the device grant. + * Re-exported so existing import sites keep their public helper surface. + */ +export { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken, decodeJwtPayload, extractAccountId, extractEmail }; +const CLIENT_ID = CHATGPT_CLIENT_ID; const AUTH_URL = "https://auth.openai.com/oauth/authorize"; -const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const TOKEN_URL = CHATGPT_TOKEN_URL; -/** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */ -export const CHATGPT_CLIENT_ID = CLIENT_ID; -export const CHATGPT_TOKEN_URL = TOKEN_URL; const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke"; const CALLBACK_PORT = 1455; const CALLBACK_PATH = "/auth/callback"; const ORIGINATOR = "opencodex"; -export function decodeJwtPayload(token: string): Record | undefined { - const parts = token.split("."); - if (parts.length !== 3 || !parts[1]) return undefined; - try { - return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record; - } catch { - return undefined; - } -} - -export function extractAccountId(idToken?: string, accessToken?: string): string | undefined { - for (const token of [idToken, accessToken]) { - if (!token) continue; - const payload = decodeJwtPayload(token); - if (!payload) continue; - if (typeof payload.chatgpt_account_id === "string") return payload.chatgpt_account_id; - const ns = payload["https://api.openai.com/auth"]; - if (ns && typeof ns === "object" && typeof (ns as Record).chatgpt_account_id === "string") { - return (ns as Record).chatgpt_account_id as string; - } - const orgs = payload.organizations; - if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string") return orgs[0].id as string; - } - return undefined; -} - /** * Three-way answer to "is this token marked as belonging to the ChatGPT account domain". * Only ChatGPT-specific claims count as markers: a top-level chatgpt_account_id or the @@ -196,16 +173,6 @@ export function inspectChatGptDomainClaim(token: string): ChatGptDomainClaim { return accountId ? { kind: "valid", accountId } : { kind: "invalid" }; } -export function extractEmail(idToken?: string, accessToken?: string): string | undefined { - for (const token of [idToken, accessToken]) { - if (!token) continue; - const payload = decodeJwtPayload(token); - if (!payload) continue; - if (typeof payload.email === "string") return payload.email.toLowerCase(); - } - return undefined; -} - /** * Identity-agreement view of one token for security-sensitive bindings. `accountId` follows the * existing extractAccountId precedence (top-level, then namespaced, then organizations[0]). @@ -233,37 +200,6 @@ export function extractAccountIdClaims(token?: string): { accountId: string | un }; } -export function credsFromToken(data: Record): OAuthCredentials { - const idToken = typeof data.id_token === "string" ? data.id_token : undefined; - // This parses a response from an external boundary, so the access token is - // validated rather than cast. A 200 carrying no access_token would otherwise - // resolve a login as successful with an undefined credential, which then gets - // silently declined at persistence — a success message and no account. - const accessToken = typeof data.access_token === "string" && data.access_token.length > 0 - ? data.access_token - : undefined; - if (!accessToken) throw new Error("ChatGPT token response missing access token"); - const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : ""; - // ?? only guards null/undefined; NaN or a string expires_in would otherwise - // produce a NaN expiry that never compares as expired, and a negative duration - // would stamp an already-past expiry — both block refresh semantics. - const expiresIn = - typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0 - ? data.expires_in - : 3600; - // The computed timestamp itself must stay finite: Number.MAX_VALUE passes - // Number.isFinite but overflows to Infinity once multiplied by 1000. - const computedExpires = Date.now() + expiresIn * 1000; - const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000; - return { - access: accessToken, - refresh: refreshToken, - expires, - accountId: extractAccountId(idToken, accessToken), - email: extractEmail(idToken, accessToken), - }; -} - export class ChatGPTOAuthFlow extends OAuthCallbackFlow { #verifier = ""; forceLogin = false; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 63809facab0..8309e1a30fc 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1786,7 +1786,7 @@ export async function runLogin( if (provider !== "chatgpt") { try { const { clearModelCache } = await import("../codex/model-cache"); - const { clearGatherRoutedModelsInflight } = await import("../codex/catalog"); + const { clearGatherRoutedModelsInflight } = await import("../codex/catalog/routed-gather"); clearModelCache(provider); clearGatherRoutedModelsInflight(); const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota"); diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 976f327062d..f6076920a48 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -521,7 +521,7 @@ export async function handleManagementAPI( const { handleMainDeviceReauthAPI } = await import("../codex/main-device-reauth-api"); return handleMainDeviceReauthAPI(req, url, config); } - const { handleCodexAuthAPI } = await import("../codex/auth-api"); + const { handleCodexAuthAPI } = await import("../codex/auth-api/routes"); const { ConfigMutationLockError } = await import("../config"); const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store"); try { diff --git a/structure/runtime.md b/structure/runtime.md index fdbbf039411..16131ceedae 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -139,7 +139,7 @@ there. Feature code is grouped by responsibility: | Group | Directories | | --- | --- | | Data plane | `src/adapters/`, `src/responses/`, `src/chat/`, `src/claude/`, `src/grok/`, `src/images/`, `src/vision/`, `src/web-search/` | -| Codex integration | `src/codex/`, `src/combos/`, `src/providers/`, `src/oauth/` | +| Codex integration | `src/codex/`, `src/combos/`, `src/providers/`, `src/oauth/`. Browser/device grants share unchanged credential helpers in the type-only-dependent `src/oauth/chatgpt-shared.ts` leaf; `src/oauth/chatgpt.ts` retains their exports and the bounded token-request logic. Management dispatch imports `src/codex/auth-api/routes.ts` directly, without an auth-api handler re-export; OAuth cache invalidation imports `src/codex/catalog/routed-gather.ts` directly. | | Surfaces | `src/server/`, `src/cli/`, `src/tray/`, `src/github/` | | Evidence and contracts | `src/compatibility/`, `src/lab/` | | Support | `src/lib/`, `src/storage/` (Worker policy fixtures synchronize after loading the initial policy; `src/storage/policy.ts` bounds the test barrier at 10 seconds), `src/usage/`, `src/update/` (compiled executables report standalone update guidance rather than a source checkout; [package refresh](ops/service-and-sidecars.md#package-cache-refresh); `desktop-badge.ts` holds bounded process-local display state, never install authority), `src/generated/` | diff --git a/tests/ci-workflows/import-cycle-ratchet.test.ts b/tests/ci-workflows/import-cycle-ratchet.test.ts index f189708b445..64aeb402dce 100644 --- a/tests/ci-workflows/import-cycle-ratchet.test.ts +++ b/tests/ci-workflows/import-cycle-ratchet.test.ts @@ -27,6 +27,7 @@ import { repoRoot } from "../helpers/repo-root"; */ const CORE_OFF_CYCLE = [ "src/config.ts", + "src/router.ts", "src/server/lifecycle.ts", "src/server/responses/core.ts", "src/server/management-api.ts", diff --git a/tests/codex-integration/codex-auth-release.test.ts b/tests/codex-integration/codex-auth-release.test.ts index c19f4fbfc30..1b03fe4b596 100644 --- a/tests/codex-integration/codex-auth-release.test.ts +++ b/tests/codex-integration/codex-auth-release.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { getConfigPath } from "../../src/config"; import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; import * as mutationLock from "../../src/config/mutation-lock"; -import { handleCodexAuthAPI } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { clearCodexUpstreamHealth, diff --git a/tests/codex-integration/codex-linked-account-pause.test.ts b/tests/codex-integration/codex-linked-account-pause.test.ts index 9996a224ae6..3bed76d4f71 100644 --- a/tests/codex-integration/codex-linked-account-pause.test.ts +++ b/tests/codex-integration/codex-linked-account-pause.test.ts @@ -5,7 +5,8 @@ import { join } from "node:path"; import type { OcxConfig } from "../../src/types"; import { loadConfig } from "../../src/config"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; -import { handleCodexAuthAPI, clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import { clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account"; import { clearCodexUpstreamHealth, clearThreadAccountMap, resolveCodexAccountForThread } from "../../src/codex/routing"; diff --git a/tests/codex-integration/codex-main-token-invalidated.test.ts b/tests/codex-integration/codex-main-token-invalidated.test.ts index 2554a0500ec..bb57d586343 100644 --- a/tests/codex-integration/codex-main-token-invalidated.test.ts +++ b/tests/codex-integration/codex-main-token-invalidated.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { - clearAccountNeedsReauth, clearMainAccountInfoCache, handleCodexAuthAPI, isAccountNeedsReauth, markAccountNeedsReauth, + clearAccountNeedsReauth, clearMainAccountInfoCache, isAccountNeedsReauth, markAccountNeedsReauth, type CodexAuthAccountDto, } from "../../src/codex/auth-api"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account"; diff --git a/tests/codex-integration/codex-preserve-signed-in.test.ts b/tests/codex-integration/codex-preserve-signed-in.test.ts index 5a658ee3e05..d454b031440 100644 --- a/tests/codex-integration/codex-preserve-signed-in.test.ts +++ b/tests/codex-integration/codex-preserve-signed-in.test.ts @@ -7,7 +7,8 @@ import { getDefaultConfig } from "../../src/config/proxy-env"; import { loadConfig } from "../../src/config"; import { configSchema } from "../../src/config/schema/config-schema"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; -import { handleCodexAuthAPI, clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import * as runtimeConfig from "../../src/codex/auth-api/runtime-config"; import * as authRead from "../../src/codex/auth-collision"; diff --git a/tests/codex-integration/codex-use-remaining-quota.test.ts b/tests/codex-integration/codex-use-remaining-quota.test.ts index 2d4debd0074..b6958348922 100644 --- a/tests/codex-integration/codex-use-remaining-quota.test.ts +++ b/tests/codex-integration/codex-use-remaining-quota.test.ts @@ -12,7 +12,7 @@ import { observeCodexLowQuota } from "../../src/codex/low-quota-observer"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; import { clearPoolRotationState } from "../../src/codex/pool-rotation"; import { deleteCodexAccount } from "../../src/codex/account-lifecycle"; -import { handleCodexAuthAPI } from "../../src/codex/auth-api"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; import { configSchema } from "../../src/config/schema/config-schema"; import { validateConfigCandidate } from "../../src/config/diagnostics"; import { requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth"; diff --git a/tests/fixtures/import-cycle-baseline.json b/tests/fixtures/import-cycle-baseline.json index bd453aed1ec..48711f0273f 100644 --- a/tests/fixtures/import-cycle-baseline.json +++ b/tests/fixtures/import-cycle-baseline.json @@ -5,21 +5,9 @@ "src/codex/account-lifecycle.ts", "src/codex/auth-api.ts", "src/codex/auth-api/account-list.ts", - "src/codex/auth-api/login-flow.ts", - "src/codex/auth-api/routes.ts", - "src/codex/catalog/aggregation.ts", "src/codex/catalog/bundled.ts", - "src/codex/catalog/combo-member.ts", - "src/codex/catalog/configured-tiers.ts", - "src/codex/catalog/effort.ts", - "src/codex/catalog/gather-capture.ts", "src/codex/catalog/metadata.ts", - "src/codex/catalog/model-hints.ts", - "src/codex/catalog/model-visibility.ts", "src/codex/catalog/parsing.ts", - "src/codex/catalog/provider-fetch.ts", - "src/codex/catalog/provider-models.ts", - "src/codex/catalog/routed-gather.ts", "src/codex/inject.ts", "src/codex/inject/config-write-section.ts", "src/codex/inject/restore.ts", @@ -27,19 +15,14 @@ "src/codex/main-account.ts", "src/codex/native-profile-manager.ts", "src/codex/native-profile-startup.ts", - "src/codex/subagent-model-fallback.ts", "src/grok/inject.ts", "src/integrations/native/ownership-preflight.ts", - "src/lib/state-store-registrations.ts", "src/oauth/anthropic-model-quota.ts", "src/oauth/anthropic-routing.ts", "src/oauth/devin.ts", "src/oauth/health.ts", "src/oauth/index.ts", - "src/oauth/token-guardian.ts", - "src/providers/key-failover.ts", "src/providers/kiro-account-state-disk.ts", - "src/providers/kiro-model-catalog.ts", "src/providers/kiro-usage.ts", "src/providers/quota.ts", "src/providers/quota/account-cache.ts", @@ -50,14 +33,7 @@ "src/providers/quota/report-cache.ts", "src/providers/quota/vendor-probes-key.ts", "src/providers/quota/vendor-probes-oauth.ts", - "src/router.ts", - "src/routing/capability.ts", - "src/routing/compatibility/assemble.ts", - "src/routing/evaluator.ts", - "src/routing/quota.ts", - "src/server/effort-policy.ts", "src/service.ts", - "src/service/cli.ts", - "src/vision/eligibility.ts" + "src/service/cli.ts" ] } diff --git a/tests/oauth/chatgpt-oauth.test.ts b/tests/oauth/chatgpt-oauth.test.ts index 29f35f808d6..1763a512739 100644 --- a/tests/oauth/chatgpt-oauth.test.ts +++ b/tests/oauth/chatgpt-oauth.test.ts @@ -115,14 +115,16 @@ describe("ChatGPT OAuth JWT helpers", () => { describe("ChatGPT OAuth constants", () => { test("uses auth.openai.com endpoints (not auth0.openai.com)", async () => { - const source = await Bun.file("src/oauth/chatgpt.ts").text(); + const source = await Bun.file("src/oauth/chatgpt.ts").text() + + await Bun.file("src/oauth/chatgpt-shared.ts").text(); expect(source).toContain('auth.openai.com/oauth/authorize'); expect(source).toContain('auth.openai.com/oauth/token'); expect(source).not.toContain("auth0.openai.com"); }); test("uses official Codex client_id", async () => { - const source = await Bun.file("src/oauth/chatgpt.ts").text(); + const source = await Bun.file("src/oauth/chatgpt.ts").text() + + await Bun.file("src/oauth/chatgpt-shared.ts").text(); expect(source).toContain("app_EMoamEEZ73f0CkXaXp7hrann"); expect(source).not.toContain("DRivsnm2Mu42T3KOpqdtwB3NYviHYzwD"); });