From 5ae452ab45acaef12837c99b466b68410831b880 Mon Sep 17 00:00:00 2001 From: Cita Date: Sat, 28 Mar 2026 16:38:39 +0800 Subject: [PATCH] [codex] inherit site proxy settings for oauth (#296) * test: define oauth proxy inheritance behavior * feat: apply site proxy settings to oauth requests * feat: backfill oauth provider sites at startup * test: remove oauth site registry temp dir --- src/server/index.ts | 3 + src/server/routes/api/oauth.test.ts | 85 +++++++++++++++++++ .../services/oauth/antigravityProvider.ts | 47 ++++++---- src/server/services/oauth/claudeProvider.ts | 18 ++-- src/server/services/oauth/codexProvider.ts | 27 ++++-- .../services/oauth/geminiCliProvider.ts | 75 +++++++++------- .../services/oauth/oauthSiteRegistry.test.ts | 51 +++++++++++ .../services/oauth/oauthSiteRegistry.ts | 55 ++++++++++++ src/server/services/oauth/providers.ts | 2 + src/server/services/oauth/requestProxy.ts | 19 +++++ src/server/services/oauth/service.ts | 29 ++----- 11 files changed, 326 insertions(+), 85 deletions(-) create mode 100644 src/server/services/oauth/oauthSiteRegistry.test.ts create mode 100644 src/server/services/oauth/oauthSiteRegistry.ts create mode 100644 src/server/services/oauth/requestProxy.ts diff --git a/src/server/index.ts b/src/server/index.ts index 70c4a77..8dfd6c8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -30,6 +30,7 @@ import { repairStoredCreatedAtValues } from './services/storedTimestampRepairSer import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js'; +import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js'; import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js'; import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js'; import { reloadBackupWebdavScheduler } from './services/backupService.js'; @@ -369,6 +370,8 @@ try { console.warn(`Failed to load runtime settings overrides: ${(error as Error)?.message || 'unknown error'}`); } +await ensureOauthProviderSitesExist(); + const app = Fastify(buildFastifyOptions(config)); await app.register(cors); diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index c6d6350..b784ce1 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -6,9 +6,21 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; const fetchMock = vi.fn(); +const undiciAgentCtorMock = vi.fn(); +const undiciProxyAgentCtorMock = vi.fn(); vi.mock('undici', () => ({ fetch: (...args: unknown[]) => fetchMock(...args), + Agent: class MockUndiciAgent { + constructor(...args: unknown[]) { + undiciAgentCtorMock(...args); + } + }, + ProxyAgent: class MockUndiciProxyAgent { + constructor(...args: unknown[]) { + undiciProxyAgentCtorMock(...args); + } + }, })); type DbModule = typeof import('../../db/index.js'); @@ -51,13 +63,18 @@ describe('oauth routes', { timeout: 15_000 }, () => { beforeEach(async () => { fetchMock.mockReset(); + undiciAgentCtorMock.mockReset(); + undiciProxyAgentCtorMock.mockReset(); await db.delete(schema.routeChannels).run(); await db.delete(schema.tokenRoutes).run(); await db.delete(schema.tokenModelAvailability).run(); await db.delete(schema.modelAvailability).run(); await db.delete(schema.accountTokens).run(); await db.delete(schema.accounts).run(); + await db.delete(schema.settings).run(); await db.delete(schema.sites).run(); + const { invalidateSiteProxyCache } = await import('../../services/siteProxy.js'); + invalidateSiteProxyCache(); }); afterAll(async () => { @@ -434,6 +451,74 @@ describe('oauth routes', { timeout: 15_000 }, () => { expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']); }); + it('includes dispatcher for codex token exchange when oauth site enables the system proxy', async () => { + const jwt = buildJwt({ + email: 'codex-proxy@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-proxy', + chatgpt_plan_type: 'plus', + }, + }); + await db.insert(schema.settings).values({ + key: 'system_proxy_url', + value: JSON.stringify('http://127.0.0.1:7890'), + }).run(); + await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + useSystemProxy: true, + }).run(); + + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: [{ id: 'gpt-5.4' }], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-proxy`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + const codexTokenCall = fetchMock.mock.calls.find((call) => String(call[0] || '') === 'https://auth.openai.com/oauth/token'); + const codexTokenFetchInit = codexTokenCall?.[1] as Record | undefined; + expect(codexTokenFetchInit).toEqual(expect.objectContaining({ + dispatcher: expect.anything(), + })); + }); + it('marks oauth session as error and avoids creating a connection when manual codex callback model discovery fails', async () => { const jwt = buildJwt({ email: 'codex-fail@example.com', diff --git a/src/server/services/oauth/antigravityProvider.ts b/src/server/services/oauth/antigravityProvider.ts index 5553a31..32a8834 100644 --- a/src/server/services/oauth/antigravityProvider.ts +++ b/src/server/services/oauth/antigravityProvider.ts @@ -1,4 +1,5 @@ import { fetch } from 'undici'; +import { withExplicitProxyRequestInit } from '../siteProxy.js'; import type { OAuthProviderDefinition } from './providers.js'; export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity'; @@ -110,10 +111,11 @@ async function callAntigravityInternalApi( accessToken: string, method: 'loadCodeAssist' | 'onboardUser', body: Record, + proxyUrl?: string | null, ): Promise { const response = await fetch( `${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`, - { + withExplicitProxyRequestInit(proxyUrl, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, @@ -124,21 +126,24 @@ async function callAntigravityInternalApi( 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, }, body: JSON.stringify(body), - }, + }), ); if (!response.ok) return undefined; return response.json() as Promise; } -async function postAntigravityToken(body: URLSearchParams) { - const response = await fetch(ANTIGRAVITY_TOKEN_URL, { +async function postAntigravityToken( + body: URLSearchParams, + proxyUrl?: string | null, +) { + const response = await fetch(ANTIGRAVITY_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: body.toString(), - }); + })); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `antigravity token exchange failed with status ${response.status}`); @@ -159,24 +164,31 @@ async function postAntigravityToken(body: URLSearchParams) { }; } -async function fetchAntigravityUserEmail(accessToken: string): Promise { - const response = await fetch(ANTIGRAVITY_USERINFO_URL, { +async function fetchAntigravityUserEmail( + accessToken: string, + proxyUrl?: string | null, +): Promise { + const response = await fetch(ANTIGRAVITY_USERINFO_URL, withExplicitProxyRequestInit(proxyUrl, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, - }); + })); if (!response.ok) return undefined; const payload = await response.json() as { email?: unknown }; return asTrimmedString(payload.email); } -async function fetchAntigravityProjectId(accessToken: string): Promise { +async function fetchAntigravityProjectId( + accessToken: string, + proxyUrl?: string | null, +): Promise { const metadata = buildAntigravityMetadata(); const payload = await callAntigravityInternalApi( accessToken, 'loadCodeAssist', { metadata }, + proxyUrl, ); if (!payload) return undefined; @@ -194,6 +206,7 @@ async function fetchAntigravityProjectId(accessToken: string): Promise { + exchangeAuthorizationCode: async ({ code, redirectUri, proxyUrl }) => { const token = await postAntigravityToken(new URLSearchParams({ code, client_id: ANTIGRAVITY_CLIENT_ID, client_secret: ANTIGRAVITY_CLIENT_SECRET, redirect_uri: redirectUri, grant_type: 'authorization_code', - })); - const email = await fetchAntigravityUserEmail(token.accessToken); - const projectId = await fetchAntigravityProjectId(token.accessToken); + }), proxyUrl); + const email = await fetchAntigravityUserEmail(token.accessToken, proxyUrl); + const projectId = await fetchAntigravityProjectId(token.accessToken, proxyUrl); return { ...token, email, @@ -267,20 +280,20 @@ export const antigravityOauthProvider: OAuthProviderDefinition = { projectId, }; }, - refreshAccessToken: async ({ refreshToken, oauth }) => { + refreshAccessToken: async ({ refreshToken, oauth, proxyUrl }) => { const token = await postAntigravityToken(new URLSearchParams({ client_id: ANTIGRAVITY_CLIENT_ID, client_secret: ANTIGRAVITY_CLIENT_SECRET, grant_type: 'refresh_token', refresh_token: refreshToken, - })); - const email = await fetchAntigravityUserEmail(token.accessToken); + }), proxyUrl); + const email = await fetchAntigravityUserEmail(token.accessToken, proxyUrl); return { ...token, email, accountKey: email, accountId: email, - projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken), + projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken, proxyUrl), }; }, buildProxyHeaders: () => ({ diff --git a/src/server/services/oauth/claudeProvider.ts b/src/server/services/oauth/claudeProvider.ts index 61e7028..c2cbdf2 100644 --- a/src/server/services/oauth/claudeProvider.ts +++ b/src/server/services/oauth/claudeProvider.ts @@ -1,5 +1,6 @@ import { fetch } from 'undici'; import { config } from '../../config.js'; +import { withExplicitProxyRequestInit } from '../siteProxy.js'; import { createPkceChallenge } from './sessionStore.js'; import type { OAuthProviderDefinition } from './providers.js'; @@ -77,15 +78,18 @@ function parseClaudeTokenPayload(payload: unknown) { }; } -async function postClaudeToken(body: Record) { - const response = await fetch(CLAUDE_TOKEN_URL, { +async function postClaudeToken( + body: Record, + proxyUrl?: string | null, +) { + const response = await fetch(CLAUDE_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(body), - }); + })); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `claude token exchange failed with status ${response.status}`); @@ -129,7 +133,7 @@ export const claudeOauthProvider: OAuthProviderDefinition = { }); return `${CLAUDE_AUTH_URL}?${params.toString()}`; }, - exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier }) => { + exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier, proxyUrl }) => { return postClaudeToken({ code, state, @@ -137,14 +141,14 @@ export const claudeOauthProvider: OAuthProviderDefinition = { client_id: requireClaudeClientId(), redirect_uri: redirectUri, code_verifier: codeVerifier, - }); + }, proxyUrl); }, - refreshAccessToken: async ({ refreshToken }) => { + refreshAccessToken: async ({ refreshToken, proxyUrl }) => { return postClaudeToken({ client_id: requireClaudeClientId(), grant_type: 'refresh_token', refresh_token: refreshToken, - }); + }, proxyUrl); }, buildProxyHeaders: () => ({ 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, diff --git a/src/server/services/oauth/codexProvider.ts b/src/server/services/oauth/codexProvider.ts index b57a323..a135044 100644 --- a/src/server/services/oauth/codexProvider.ts +++ b/src/server/services/oauth/codexProvider.ts @@ -1,5 +1,6 @@ import { fetch } from 'undici'; import { config } from '../../config.js'; +import { withExplicitProxyRequestInit } from '../siteProxy.js'; import { createPkceChallenge } from './sessionStore.js'; import type { OAuthProviderDefinition } from './providers.js'; @@ -106,15 +107,18 @@ function parseTokenResponsePayload(payload: unknown): CodexTokenExchangeResult { }; } -async function exchangeCodexToken(form: URLSearchParams): Promise { - const response = await fetch(CODEX_TOKEN_URL, { +async function exchangeCodexToken( + form: URLSearchParams, + proxyUrl?: string | null, +): Promise { + const response = await fetch(CODEX_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: form.toString(), - }); + })); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `codex token exchange failed with status ${response.status}`); @@ -126,6 +130,7 @@ export async function exchangeCodexAuthorizationCode(input: { code: string; codeVerifier: string; redirectUri: string; + proxyUrl?: string | null; }): Promise { return exchangeCodexToken(new URLSearchParams({ grant_type: 'authorization_code', @@ -133,16 +138,19 @@ export async function exchangeCodexAuthorizationCode(input: { code: input.code, redirect_uri: input.redirectUri, code_verifier: input.codeVerifier, - })); + }), input.proxyUrl); } -export async function refreshCodexTokens(refreshToken: string): Promise { +export async function refreshCodexTokens( + refreshToken: string, + proxyUrl?: string | null, +): Promise { return exchangeCodexToken(new URLSearchParams({ client_id: requireCodexClientId(), grant_type: 'refresh_token', refresh_token: refreshToken, scope: 'openid profile email', - })); + }), proxyUrl); } function getHeaderValue(headers: Record | undefined, key: string): string | undefined { @@ -193,11 +201,12 @@ export const codexOauthProvider: OAuthProviderDefinition = { redirectUri, codeVerifier, }), - exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier }) => { + exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier, proxyUrl }) => { const exchange = await exchangeCodexAuthorizationCode({ code, redirectUri, codeVerifier, + proxyUrl, }); return { accessToken: exchange.accessToken, @@ -210,8 +219,8 @@ export const codexOauthProvider: OAuthProviderDefinition = { idToken: exchange.idToken, }; }, - refreshAccessToken: async ({ refreshToken }) => { - const refreshed = await refreshCodexTokens(refreshToken); + refreshAccessToken: async ({ refreshToken, proxyUrl }) => { + const refreshed = await refreshCodexTokens(refreshToken, proxyUrl); return { accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken, diff --git a/src/server/services/oauth/geminiCliProvider.ts b/src/server/services/oauth/geminiCliProvider.ts index a56d82c..5a335ca 100644 --- a/src/server/services/oauth/geminiCliProvider.ts +++ b/src/server/services/oauth/geminiCliProvider.ts @@ -1,5 +1,6 @@ import { fetch } from 'undici'; import { config } from '../../config.js'; +import { withExplicitProxyRequestInit } from '../siteProxy.js'; import type { OAuthProviderDefinition } from './providers.js'; export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli'; @@ -147,15 +148,18 @@ async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } -async function postGeminiToken(body: URLSearchParams) { - const response = await fetch(GEMINI_CLI_TOKEN_URL, { +async function postGeminiToken( + body: URLSearchParams, + proxyUrl?: string | null, +) { + const response = await fetch(GEMINI_CLI_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: body.toString(), - }); + })); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `gemini token exchange failed with status ${response.status}`); @@ -176,13 +180,16 @@ async function postGeminiToken(body: URLSearchParams) { }; } -async function fetchGeminiUserEmail(accessToken: string): Promise { - const response = await fetch(GEMINI_CLI_USERINFO_URL, { +async function fetchGeminiUserEmail( + accessToken: string, + proxyUrl?: string | null, +): Promise { + const response = await fetch(GEMINI_CLI_USERINFO_URL, withExplicitProxyRequestInit(proxyUrl, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, - }); + })); if (!response.ok) return undefined; const payload = await response.json() as { email?: unknown }; return asTrimmedString(payload.email); @@ -192,10 +199,11 @@ async function callGeminiCliInternalApi( accessToken: string, method: 'loadCodeAssist' | 'onboardUser', body: Record, + proxyUrl?: string | null, ): Promise { const response = await fetch( `${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`, - { + withExplicitProxyRequestInit(proxyUrl, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, @@ -205,7 +213,7 @@ async function callGeminiCliInternalApi( 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, }, body: JSON.stringify(body), - }, + }), ); if (!response.ok) { const text = await response.text().catch(() => ''); @@ -214,13 +222,13 @@ async function callGeminiCliInternalApi( return response.json() as Promise; } -async function fetchGcpProjects(accessToken: string): Promise { - const response = await fetch(GEMINI_CLI_PROJECTS_URL, { +async function fetchGcpProjects(accessToken: string, proxyUrl?: string | null): Promise { + const response = await fetch(GEMINI_CLI_PROJECTS_URL, withExplicitProxyRequestInit(proxyUrl, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, - }); + })); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `project list request failed with status ${response.status}`); @@ -231,17 +239,21 @@ async function fetchGcpProjects(accessToken: string): Promise { .filter((projectId): projectId is string => !!projectId); } -async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise { +async function checkCloudAIAPIEnabled( + accessToken: string, + projectId: string, + proxyUrl?: string | null, +): Promise { const checkResponse = await fetch( `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, - { + withExplicitProxyRequestInit(proxyUrl, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'User-Agent': GEMINI_CLI_USER_AGENT, 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, }, - }, + }), ); if (checkResponse.ok) { const payload = await checkResponse.json() as { state?: unknown }; @@ -253,7 +265,7 @@ async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): P const response = await fetch( `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`, - { + withExplicitProxyRequestInit(proxyUrl, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, @@ -263,7 +275,7 @@ async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): P 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, }, body: '{}', - }, + }), ); const text = await response.text().catch(() => ''); if (response.ok) { @@ -278,6 +290,7 @@ async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): P async function performGeminiCliSetup( accessToken: string, requestedProjectId: string, + proxyUrl?: string | null, ): Promise { const trimmedRequest = requestedProjectId.trim(); const explicitProject = !!trimmedRequest; @@ -291,6 +304,7 @@ async function performGeminiCliSetup( cloudaicompanionProject: trimmedRequest, } : { metadata }, + proxyUrl, ); const tierId = extractGeminiDefaultTierId(loadResponse); @@ -305,6 +319,7 @@ async function performGeminiCliSetup( tierId, metadata, }, + proxyUrl, ); if (onboardResponse.done === true) { const response = ( @@ -337,6 +352,7 @@ async function performGeminiCliSetup( metadata, cloudaicompanionProject: projectId, }, + proxyUrl, ); if (onboardResponse.done === true) { const response = ( @@ -372,18 +388,19 @@ async function performGeminiCliSetup( async function ensureGeminiProjectAndOnboard( accessToken: string, requestedProjectId?: string, + proxyUrl?: string | null, ): Promise { const explicitProject = asTrimmedString(requestedProjectId); if (explicitProject) { - return performGeminiCliSetup(accessToken, explicitProject); + return performGeminiCliSetup(accessToken, explicitProject, proxyUrl); } - const projects = await fetchGcpProjects(accessToken); + const projects = await fetchGcpProjects(accessToken, proxyUrl); const firstProject = projects[0]; if (!firstProject) { throw new Error('no Google Cloud projects available for this account'); } - return performGeminiCliSetup(accessToken, firstProject); + return performGeminiCliSetup(accessToken, firstProject, proxyUrl); } export const geminiCliOauthProvider: OAuthProviderDefinition = { @@ -422,7 +439,7 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = { }); return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`; }, - exchangeAuthorizationCode: async ({ code, redirectUri, projectId }) => { + exchangeAuthorizationCode: async ({ code, redirectUri, projectId, proxyUrl }) => { const oauthConfig = requireGeminiCliOAuthConfig(); const token = await postGeminiToken(new URLSearchParams({ code, @@ -430,10 +447,10 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = { client_secret: oauthConfig.clientSecret, redirect_uri: redirectUri, grant_type: 'authorization_code', - })); - const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId); - await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId); - const email = await fetchGeminiUserEmail(token.accessToken); + }), proxyUrl); + const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId, proxyUrl); + await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId, proxyUrl); + const email = await fetchGeminiUserEmail(token.accessToken, proxyUrl); return { ...token, email, @@ -442,19 +459,19 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = { projectId: resolvedProjectId, }; }, - refreshAccessToken: async ({ refreshToken, oauth }) => { + refreshAccessToken: async ({ refreshToken, oauth, proxyUrl }) => { const oauthConfig = requireGeminiCliOAuthConfig(); const token = await postGeminiToken(new URLSearchParams({ client_id: oauthConfig.clientId, client_secret: oauthConfig.clientSecret, grant_type: 'refresh_token', refresh_token: refreshToken, - })); + }), proxyUrl); const nextProjectId = oauth?.projectId ? oauth.projectId - : await ensureGeminiProjectAndOnboard(token.accessToken); - await checkCloudAIAPIEnabled(token.accessToken, nextProjectId); - const email = await fetchGeminiUserEmail(token.accessToken); + : await ensureGeminiProjectAndOnboard(token.accessToken, undefined, proxyUrl); + await checkCloudAIAPIEnabled(token.accessToken, nextProjectId, proxyUrl); + const email = await fetchGeminiUserEmail(token.accessToken, proxyUrl); return { ...token, email, diff --git a/src/server/services/oauth/oauthSiteRegistry.test.ts b/src/server/services/oauth/oauthSiteRegistry.test.ts new file mode 100644 index 0000000..35d0bf2 --- /dev/null +++ b/src/server/services/oauth/oauthSiteRegistry.test.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type DbModule = typeof import('../../db/index.js'); + +describe('oauth site registry', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-site-registry-')); + process.env.DATA_DIR = dataDir; + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + db = dbModule.db; + schema = dbModule.schema; + }); + + beforeEach(async () => { + await db.delete(schema.sites).run(); + }); + + afterAll(() => { + delete process.env.DATA_DIR; + if (dataDir) { + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it('creates missing oauth provider sites without duplicating existing rows', async () => { + await db.insert(schema.sites).values({ + name: 'Anthropic Claude OAuth', + url: 'https://api.anthropic.com', + platform: 'claude', + status: 'active', + useSystemProxy: true, + }).run(); + + const { ensureOauthProviderSitesExist } = await import('./oauthSiteRegistry.js'); + await ensureOauthProviderSitesExist(); + + const rows = await db.select().from(schema.sites).all(); + expect(rows.filter((row) => row.platform === 'codex')).toHaveLength(1); + expect(rows.filter((row) => row.platform === 'gemini-cli')).toHaveLength(1); + expect(rows.filter((row) => row.platform === 'antigravity')).toHaveLength(1); + expect(rows.filter((row) => row.platform === 'claude')).toHaveLength(1); + }); +}); diff --git a/src/server/services/oauth/oauthSiteRegistry.ts b/src/server/services/oauth/oauthSiteRegistry.ts new file mode 100644 index 0000000..5ab26c7 --- /dev/null +++ b/src/server/services/oauth/oauthSiteRegistry.ts @@ -0,0 +1,55 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { listOAuthProviderDefinitions, type OAuthProviderDefinition } from './providers.js'; + +function isUniqueConstraintError(error: unknown): boolean { + if (!error) return false; + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + return normalized.includes('unique') + || normalized.includes('duplicate') + || normalized.includes('constraint failed'); +} + +async function getNextSiteSortOrder(): Promise { + const row = await db.select({ + maxSortOrder: sql`COALESCE(MAX(${schema.sites.sortOrder}), -1)`, + }).from(schema.sites).get(); + return (row?.maxSortOrder ?? -1) + 1; +} + +export async function ensureOauthProviderSite(definition: OAuthProviderDefinition) { + const existing = await db.select().from(schema.sites).where(and( + eq(schema.sites.platform, definition.site.platform), + eq(schema.sites.url, definition.site.url), + )).get(); + if (existing) return existing; + + try { + return await db.insert(schema.sites).values({ + name: definition.site.name, + url: definition.site.url, + platform: definition.site.platform, + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: await getNextSiteSortOrder(), + }).returning().get(); + } catch (error) { + if (!isUniqueConstraintError(error)) throw error; + const recovered = await db.select().from(schema.sites).where(and( + eq(schema.sites.platform, definition.site.platform), + eq(schema.sites.url, definition.site.url), + )).get(); + if (recovered) return recovered; + throw error; + } +} + +export async function ensureOauthProviderSitesExist(): Promise { + const definitions = listOAuthProviderDefinitions(); + for (const definition of definitions) { + await ensureOauthProviderSite(definition); + } +} diff --git a/src/server/services/oauth/providers.ts b/src/server/services/oauth/providers.ts index b04d866..895ccf4 100644 --- a/src/server/services/oauth/providers.ts +++ b/src/server/services/oauth/providers.ts @@ -71,6 +71,7 @@ export interface OAuthProviderDefinition { redirectUri: string; codeVerifier: string; projectId?: string; + proxyUrl?: string | null; }): Promise; refreshAccessToken(input: { refreshToken: string; @@ -78,6 +79,7 @@ export interface OAuthProviderDefinition { projectId?: string; providerData?: Record; }; + proxyUrl?: string | null; }): Promise; buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record; } diff --git a/src/server/services/oauth/requestProxy.ts b/src/server/services/oauth/requestProxy.ts new file mode 100644 index 0000000..9c7075b --- /dev/null +++ b/src/server/services/oauth/requestProxy.ts @@ -0,0 +1,19 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { getOAuthProviderDefinition } from './providers.js'; +import { resolveSiteProxyUrlByRequestUrl } from '../siteProxy.js'; + +export async function resolveOauthProviderProxyUrl(provider: string): Promise { + const definition = getOAuthProviderDefinition(provider); + if (!definition) return null; + return resolveSiteProxyUrlByRequestUrl(definition.site.url); +} + +export async function resolveOauthAccountProxyUrl(siteId: number | null | undefined): Promise { + if (!siteId || siteId <= 0) return null; + const site = await db.select({ + url: schema.sites.url, + }).from(schema.sites).where(eq(schema.sites.id, siteId)).get(); + if (!site?.url) return null; + return resolveSiteProxyUrlByRequestUrl(site.url); +} diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts index fd138e5..529bce1 100644 --- a/src/server/services/oauth/service.ts +++ b/src/server/services/oauth/service.ts @@ -15,6 +15,7 @@ import { listOAuthProviderDefinitions, type OAuthProviderDefinition, } from './providers.js'; +import { ensureOauthProviderSite } from './oauthSiteRegistry.js'; import { buildOauthInfo, buildOauthInfoFromAccount, @@ -28,6 +29,7 @@ import { type OauthExtraConfigInput, type OauthIdentityCarrierLike, } from './codexAccount.js'; +import { resolveOauthAccountProxyUrl, resolveOauthProviderProxyUrl } from './requestProxy.js'; import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js'; import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js'; @@ -140,30 +142,8 @@ async function getNextAccountSortOrder(): Promise { return (row?.maxSortOrder ?? -1) + 1; } -async function getNextSiteSortOrder(): Promise { - const row = await db.select({ - maxSortOrder: sql`COALESCE(MAX(${schema.sites.sortOrder}), -1)`, - }).from(schema.sites).get(); - return (row?.maxSortOrder ?? -1) + 1; -} - async function ensureOauthSite(definition: OAuthProviderDefinition) { - const existing = await db.select().from(schema.sites).where(and( - eq(schema.sites.platform, definition.site.platform), - eq(schema.sites.url, definition.site.url), - )).get(); - if (existing) return existing; - - return db.insert(schema.sites).values({ - name: definition.site.name, - url: definition.site.url, - platform: definition.site.platform, - status: 'active', - useSystemProxy: false, - isPinned: false, - globalWeight: 1, - sortOrder: await getNextSiteSortOrder(), - }).returning().get(); + return ensureOauthProviderSite(definition); } async function findExistingOauthAccount(input: { @@ -375,12 +355,14 @@ export async function handleOauthCallback(input: { } try { + const proxyUrl = await resolveOauthProviderProxyUrl(input.provider); const exchange = await definition.exchangeAuthorizationCode({ code, state: input.state, redirectUri: session.redirectUri, codeVerifier: session.codeVerifier, projectId: session.projectId, + proxyUrl, }); const { account, site, created, previousAccount } = await upsertOauthAccount({ definition, @@ -655,6 +637,7 @@ export async function refreshOauthAccessToken(accountId: number) { projectId: oauth.projectId, providerData: oauth.providerData, }, + proxyUrl: await resolveOauthAccountProxyUrl(account.siteId), }); const nextOauth = buildOauthInfoFromAccount(account, { provider: oauth.provider,