1
0
forked from boos/BoosAPI

[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
This commit is contained in:
Cita
2026-03-28 16:38:39 +08:00
committed by GitHub
parent 40b8eddef8
commit 5ae452ab45
11 changed files with 326 additions and 85 deletions
+3
View File
@@ -30,6 +30,7 @@ import { repairStoredCreatedAtValues } from './services/storedTimestampRepairSer
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js'; import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js';
import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js';
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js'; import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js'; import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
import { reloadBackupWebdavScheduler } from './services/backupService.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'}`); console.warn(`Failed to load runtime settings overrides: ${(error as Error)?.message || 'unknown error'}`);
} }
await ensureOauthProviderSitesExist();
const app = Fastify(buildFastifyOptions(config)); const app = Fastify(buildFastifyOptions(config));
await app.register(cors); await app.register(cors);
+85
View File
@@ -6,9 +6,21 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
const fetchMock = vi.fn(); const fetchMock = vi.fn();
const undiciAgentCtorMock = vi.fn();
const undiciProxyAgentCtorMock = vi.fn();
vi.mock('undici', () => ({ vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args), 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'); type DbModule = typeof import('../../db/index.js');
@@ -51,13 +63,18 @@ describe('oauth routes', { timeout: 15_000 }, () => {
beforeEach(async () => { beforeEach(async () => {
fetchMock.mockReset(); fetchMock.mockReset();
undiciAgentCtorMock.mockReset();
undiciProxyAgentCtorMock.mockReset();
await db.delete(schema.routeChannels).run(); await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run(); await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run(); await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run(); await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run(); await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run(); await db.delete(schema.accounts).run();
await db.delete(schema.settings).run();
await db.delete(schema.sites).run(); await db.delete(schema.sites).run();
const { invalidateSiteProxyCache } = await import('../../services/siteProxy.js');
invalidateSiteProxyCache();
}); });
afterAll(async () => { 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']); 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<string, unknown> | 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 () => { it('marks oauth session as error and avoids creating a connection when manual codex callback model discovery fails', async () => {
const jwt = buildJwt({ const jwt = buildJwt({
email: 'codex-fail@example.com', email: 'codex-fail@example.com',
@@ -1,4 +1,5 @@
import { fetch } from 'undici'; import { fetch } from 'undici';
import { withExplicitProxyRequestInit } from '../siteProxy.js';
import type { OAuthProviderDefinition } from './providers.js'; import type { OAuthProviderDefinition } from './providers.js';
export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity'; export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity';
@@ -110,10 +111,11 @@ async function callAntigravityInternalApi<T>(
accessToken: string, accessToken: string,
method: 'loadCodeAssist' | 'onboardUser', method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>, body: Record<string, unknown>,
proxyUrl?: string | null,
): Promise<T | undefined> { ): Promise<T | undefined> {
const response = await fetch( const response = await fetch(
`${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`, `${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`,
{ withExplicitProxyRequestInit(proxyUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
@@ -124,21 +126,24 @@ async function callAntigravityInternalApi<T>(
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}, }),
); );
if (!response.ok) return undefined; if (!response.ok) return undefined;
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
async function postAntigravityToken(body: URLSearchParams) { async function postAntigravityToken(
const response = await fetch(ANTIGRAVITY_TOKEN_URL, { body: URLSearchParams,
proxyUrl?: string | null,
) {
const response = await fetch(ANTIGRAVITY_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json', Accept: 'application/json',
}, },
body: body.toString(), body: body.toString(),
}); }));
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error(text || `antigravity token exchange failed with status ${response.status}`); 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<string | undefined> { async function fetchAntigravityUserEmail(
const response = await fetch(ANTIGRAVITY_USERINFO_URL, { accessToken: string,
proxyUrl?: string | null,
): Promise<string | undefined> {
const response = await fetch(ANTIGRAVITY_USERINFO_URL, withExplicitProxyRequestInit(proxyUrl, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
Accept: 'application/json', Accept: 'application/json',
}, },
}); }));
if (!response.ok) return undefined; if (!response.ok) return undefined;
const payload = await response.json() as { email?: unknown }; const payload = await response.json() as { email?: unknown };
return asTrimmedString(payload.email); return asTrimmedString(payload.email);
} }
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> { async function fetchAntigravityProjectId(
accessToken: string,
proxyUrl?: string | null,
): Promise<string | undefined> {
const metadata = buildAntigravityMetadata(); const metadata = buildAntigravityMetadata();
const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>( const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>(
accessToken, accessToken,
'loadCodeAssist', 'loadCodeAssist',
{ metadata }, { metadata },
proxyUrl,
); );
if (!payload) return undefined; if (!payload) return undefined;
@@ -194,6 +206,7 @@ async function fetchAntigravityProjectId(accessToken: string): Promise<string |
tierId, tierId,
metadata, metadata,
}, },
proxyUrl,
); );
if (!onboardPayload) return undefined; if (!onboardPayload) return undefined;
if (onboardPayload.done === true) { if (onboardPayload.done === true) {
@@ -249,16 +262,16 @@ export const antigravityOauthProvider: OAuthProviderDefinition = {
}); });
return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`; return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`;
}, },
exchangeAuthorizationCode: async ({ code, redirectUri }) => { exchangeAuthorizationCode: async ({ code, redirectUri, proxyUrl }) => {
const token = await postAntigravityToken(new URLSearchParams({ const token = await postAntigravityToken(new URLSearchParams({
code, code,
client_id: ANTIGRAVITY_CLIENT_ID, client_id: ANTIGRAVITY_CLIENT_ID,
client_secret: ANTIGRAVITY_CLIENT_SECRET, client_secret: ANTIGRAVITY_CLIENT_SECRET,
redirect_uri: redirectUri, redirect_uri: redirectUri,
grant_type: 'authorization_code', grant_type: 'authorization_code',
})); }), proxyUrl);
const email = await fetchAntigravityUserEmail(token.accessToken); const email = await fetchAntigravityUserEmail(token.accessToken, proxyUrl);
const projectId = await fetchAntigravityProjectId(token.accessToken); const projectId = await fetchAntigravityProjectId(token.accessToken, proxyUrl);
return { return {
...token, ...token,
email, email,
@@ -267,20 +280,20 @@ export const antigravityOauthProvider: OAuthProviderDefinition = {
projectId, projectId,
}; };
}, },
refreshAccessToken: async ({ refreshToken, oauth }) => { refreshAccessToken: async ({ refreshToken, oauth, proxyUrl }) => {
const token = await postAntigravityToken(new URLSearchParams({ const token = await postAntigravityToken(new URLSearchParams({
client_id: ANTIGRAVITY_CLIENT_ID, client_id: ANTIGRAVITY_CLIENT_ID,
client_secret: ANTIGRAVITY_CLIENT_SECRET, client_secret: ANTIGRAVITY_CLIENT_SECRET,
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: refreshToken, refresh_token: refreshToken,
})); }), proxyUrl);
const email = await fetchAntigravityUserEmail(token.accessToken); const email = await fetchAntigravityUserEmail(token.accessToken, proxyUrl);
return { return {
...token, ...token,
email, email,
accountKey: email, accountKey: email,
accountId: email, accountId: email,
projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken), projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken, proxyUrl),
}; };
}, },
buildProxyHeaders: () => ({ buildProxyHeaders: () => ({
+11 -7
View File
@@ -1,5 +1,6 @@
import { fetch } from 'undici'; import { fetch } from 'undici';
import { config } from '../../config.js'; import { config } from '../../config.js';
import { withExplicitProxyRequestInit } from '../siteProxy.js';
import { createPkceChallenge } from './sessionStore.js'; import { createPkceChallenge } from './sessionStore.js';
import type { OAuthProviderDefinition } from './providers.js'; import type { OAuthProviderDefinition } from './providers.js';
@@ -77,15 +78,18 @@ function parseClaudeTokenPayload(payload: unknown) {
}; };
} }
async function postClaudeToken(body: Record<string, unknown>) { async function postClaudeToken(
const response = await fetch(CLAUDE_TOKEN_URL, { body: Record<string, unknown>,
proxyUrl?: string | null,
) {
const response = await fetch(CLAUDE_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); }));
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error(text || `claude token exchange failed with status ${response.status}`); 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()}`; return `${CLAUDE_AUTH_URL}?${params.toString()}`;
}, },
exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier }) => { exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier, proxyUrl }) => {
return postClaudeToken({ return postClaudeToken({
code, code,
state, state,
@@ -137,14 +141,14 @@ export const claudeOauthProvider: OAuthProviderDefinition = {
client_id: requireClaudeClientId(), client_id: requireClaudeClientId(),
redirect_uri: redirectUri, redirect_uri: redirectUri,
code_verifier: codeVerifier, code_verifier: codeVerifier,
}); }, proxyUrl);
}, },
refreshAccessToken: async ({ refreshToken }) => { refreshAccessToken: async ({ refreshToken, proxyUrl }) => {
return postClaudeToken({ return postClaudeToken({
client_id: requireClaudeClientId(), client_id: requireClaudeClientId(),
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: refreshToken, refresh_token: refreshToken,
}); }, proxyUrl);
}, },
buildProxyHeaders: () => ({ buildProxyHeaders: () => ({
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
+18 -9
View File
@@ -1,5 +1,6 @@
import { fetch } from 'undici'; import { fetch } from 'undici';
import { config } from '../../config.js'; import { config } from '../../config.js';
import { withExplicitProxyRequestInit } from '../siteProxy.js';
import { createPkceChallenge } from './sessionStore.js'; import { createPkceChallenge } from './sessionStore.js';
import type { OAuthProviderDefinition } from './providers.js'; import type { OAuthProviderDefinition } from './providers.js';
@@ -106,15 +107,18 @@ function parseTokenResponsePayload(payload: unknown): CodexTokenExchangeResult {
}; };
} }
async function exchangeCodexToken(form: URLSearchParams): Promise<CodexTokenExchangeResult> { async function exchangeCodexToken(
const response = await fetch(CODEX_TOKEN_URL, { form: URLSearchParams,
proxyUrl?: string | null,
): Promise<CodexTokenExchangeResult> {
const response = await fetch(CODEX_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json', Accept: 'application/json',
}, },
body: form.toString(), body: form.toString(),
}); }));
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error(text || `codex token exchange failed with status ${response.status}`); throw new Error(text || `codex token exchange failed with status ${response.status}`);
@@ -126,6 +130,7 @@ export async function exchangeCodexAuthorizationCode(input: {
code: string; code: string;
codeVerifier: string; codeVerifier: string;
redirectUri: string; redirectUri: string;
proxyUrl?: string | null;
}): Promise<CodexTokenExchangeResult> { }): Promise<CodexTokenExchangeResult> {
return exchangeCodexToken(new URLSearchParams({ return exchangeCodexToken(new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
@@ -133,16 +138,19 @@ export async function exchangeCodexAuthorizationCode(input: {
code: input.code, code: input.code,
redirect_uri: input.redirectUri, redirect_uri: input.redirectUri,
code_verifier: input.codeVerifier, code_verifier: input.codeVerifier,
})); }), input.proxyUrl);
} }
export async function refreshCodexTokens(refreshToken: string): Promise<CodexTokenExchangeResult> { export async function refreshCodexTokens(
refreshToken: string,
proxyUrl?: string | null,
): Promise<CodexTokenExchangeResult> {
return exchangeCodexToken(new URLSearchParams({ return exchangeCodexToken(new URLSearchParams({
client_id: requireCodexClientId(), client_id: requireCodexClientId(),
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: refreshToken, refresh_token: refreshToken,
scope: 'openid profile email', scope: 'openid profile email',
})); }), proxyUrl);
} }
function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined { function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined {
@@ -193,11 +201,12 @@ export const codexOauthProvider: OAuthProviderDefinition = {
redirectUri, redirectUri,
codeVerifier, codeVerifier,
}), }),
exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier }) => { exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier, proxyUrl }) => {
const exchange = await exchangeCodexAuthorizationCode({ const exchange = await exchangeCodexAuthorizationCode({
code, code,
redirectUri, redirectUri,
codeVerifier, codeVerifier,
proxyUrl,
}); });
return { return {
accessToken: exchange.accessToken, accessToken: exchange.accessToken,
@@ -210,8 +219,8 @@ export const codexOauthProvider: OAuthProviderDefinition = {
idToken: exchange.idToken, idToken: exchange.idToken,
}; };
}, },
refreshAccessToken: async ({ refreshToken }) => { refreshAccessToken: async ({ refreshToken, proxyUrl }) => {
const refreshed = await refreshCodexTokens(refreshToken); const refreshed = await refreshCodexTokens(refreshToken, proxyUrl);
return { return {
accessToken: refreshed.accessToken, accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken, refreshToken: refreshed.refreshToken,
+46 -29
View File
@@ -1,5 +1,6 @@
import { fetch } from 'undici'; import { fetch } from 'undici';
import { config } from '../../config.js'; import { config } from '../../config.js';
import { withExplicitProxyRequestInit } from '../siteProxy.js';
import type { OAuthProviderDefinition } from './providers.js'; import type { OAuthProviderDefinition } from './providers.js';
export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli'; export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli';
@@ -147,15 +148,18 @@ async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms)); await new Promise((resolve) => setTimeout(resolve, ms));
} }
async function postGeminiToken(body: URLSearchParams) { async function postGeminiToken(
const response = await fetch(GEMINI_CLI_TOKEN_URL, { body: URLSearchParams,
proxyUrl?: string | null,
) {
const response = await fetch(GEMINI_CLI_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json', Accept: 'application/json',
}, },
body: body.toString(), body: body.toString(),
}); }));
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error(text || `gemini token exchange failed with status ${response.status}`); 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<string | undefined> { async function fetchGeminiUserEmail(
const response = await fetch(GEMINI_CLI_USERINFO_URL, { accessToken: string,
proxyUrl?: string | null,
): Promise<string | undefined> {
const response = await fetch(GEMINI_CLI_USERINFO_URL, withExplicitProxyRequestInit(proxyUrl, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
Accept: 'application/json', Accept: 'application/json',
}, },
}); }));
if (!response.ok) return undefined; if (!response.ok) return undefined;
const payload = await response.json() as { email?: unknown }; const payload = await response.json() as { email?: unknown };
return asTrimmedString(payload.email); return asTrimmedString(payload.email);
@@ -192,10 +199,11 @@ async function callGeminiCliInternalApi<T>(
accessToken: string, accessToken: string,
method: 'loadCodeAssist' | 'onboardUser', method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>, body: Record<string, unknown>,
proxyUrl?: string | null,
): Promise<T> { ): Promise<T> {
const response = await fetch( const response = await fetch(
`${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`, `${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`,
{ withExplicitProxyRequestInit(proxyUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
@@ -205,7 +213,7 @@ async function callGeminiCliInternalApi<T>(
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}, }),
); );
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
@@ -214,13 +222,13 @@ async function callGeminiCliInternalApi<T>(
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
async function fetchGcpProjects(accessToken: string): Promise<string[]> { async function fetchGcpProjects(accessToken: string, proxyUrl?: string | null): Promise<string[]> {
const response = await fetch(GEMINI_CLI_PROJECTS_URL, { const response = await fetch(GEMINI_CLI_PROJECTS_URL, withExplicitProxyRequestInit(proxyUrl, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
Accept: 'application/json', Accept: 'application/json',
}, },
}); }));
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error(text || `project list request failed with status ${response.status}`); throw new Error(text || `project list request failed with status ${response.status}`);
@@ -231,17 +239,21 @@ async function fetchGcpProjects(accessToken: string): Promise<string[]> {
.filter((projectId): projectId is string => !!projectId); .filter((projectId): projectId is string => !!projectId);
} }
async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise<void> { async function checkCloudAIAPIEnabled(
accessToken: string,
projectId: string,
proxyUrl?: string | null,
): Promise<void> {
const checkResponse = await fetch( const checkResponse = await fetch(
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
{ withExplicitProxyRequestInit(proxyUrl, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
Accept: 'application/json', Accept: 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT, 'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
}, },
}, }),
); );
if (checkResponse.ok) { if (checkResponse.ok) {
const payload = await checkResponse.json() as { state?: unknown }; const payload = await checkResponse.json() as { state?: unknown };
@@ -253,7 +265,7 @@ async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): P
const response = await fetch( const response = await fetch(
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`, `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`,
{ withExplicitProxyRequestInit(proxyUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
@@ -263,7 +275,7 @@ async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): P
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
}, },
body: '{}', body: '{}',
}, }),
); );
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
if (response.ok) { if (response.ok) {
@@ -278,6 +290,7 @@ async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): P
async function performGeminiCliSetup( async function performGeminiCliSetup(
accessToken: string, accessToken: string,
requestedProjectId: string, requestedProjectId: string,
proxyUrl?: string | null,
): Promise<string> { ): Promise<string> {
const trimmedRequest = requestedProjectId.trim(); const trimmedRequest = requestedProjectId.trim();
const explicitProject = !!trimmedRequest; const explicitProject = !!trimmedRequest;
@@ -291,6 +304,7 @@ async function performGeminiCliSetup(
cloudaicompanionProject: trimmedRequest, cloudaicompanionProject: trimmedRequest,
} }
: { metadata }, : { metadata },
proxyUrl,
); );
const tierId = extractGeminiDefaultTierId(loadResponse); const tierId = extractGeminiDefaultTierId(loadResponse);
@@ -305,6 +319,7 @@ async function performGeminiCliSetup(
tierId, tierId,
metadata, metadata,
}, },
proxyUrl,
); );
if (onboardResponse.done === true) { if (onboardResponse.done === true) {
const response = ( const response = (
@@ -337,6 +352,7 @@ async function performGeminiCliSetup(
metadata, metadata,
cloudaicompanionProject: projectId, cloudaicompanionProject: projectId,
}, },
proxyUrl,
); );
if (onboardResponse.done === true) { if (onboardResponse.done === true) {
const response = ( const response = (
@@ -372,18 +388,19 @@ async function performGeminiCliSetup(
async function ensureGeminiProjectAndOnboard( async function ensureGeminiProjectAndOnboard(
accessToken: string, accessToken: string,
requestedProjectId?: string, requestedProjectId?: string,
proxyUrl?: string | null,
): Promise<string> { ): Promise<string> {
const explicitProject = asTrimmedString(requestedProjectId); const explicitProject = asTrimmedString(requestedProjectId);
if (explicitProject) { 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]; const firstProject = projects[0];
if (!firstProject) { if (!firstProject) {
throw new Error('no Google Cloud projects available for this account'); throw new Error('no Google Cloud projects available for this account');
} }
return performGeminiCliSetup(accessToken, firstProject); return performGeminiCliSetup(accessToken, firstProject, proxyUrl);
} }
export const geminiCliOauthProvider: OAuthProviderDefinition = { export const geminiCliOauthProvider: OAuthProviderDefinition = {
@@ -422,7 +439,7 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
}); });
return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`; return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`;
}, },
exchangeAuthorizationCode: async ({ code, redirectUri, projectId }) => { exchangeAuthorizationCode: async ({ code, redirectUri, projectId, proxyUrl }) => {
const oauthConfig = requireGeminiCliOAuthConfig(); const oauthConfig = requireGeminiCliOAuthConfig();
const token = await postGeminiToken(new URLSearchParams({ const token = await postGeminiToken(new URLSearchParams({
code, code,
@@ -430,10 +447,10 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
client_secret: oauthConfig.clientSecret, client_secret: oauthConfig.clientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
grant_type: 'authorization_code', grant_type: 'authorization_code',
})); }), proxyUrl);
const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId); const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId, proxyUrl);
await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId); await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId, proxyUrl);
const email = await fetchGeminiUserEmail(token.accessToken); const email = await fetchGeminiUserEmail(token.accessToken, proxyUrl);
return { return {
...token, ...token,
email, email,
@@ -442,19 +459,19 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
projectId: resolvedProjectId, projectId: resolvedProjectId,
}; };
}, },
refreshAccessToken: async ({ refreshToken, oauth }) => { refreshAccessToken: async ({ refreshToken, oauth, proxyUrl }) => {
const oauthConfig = requireGeminiCliOAuthConfig(); const oauthConfig = requireGeminiCliOAuthConfig();
const token = await postGeminiToken(new URLSearchParams({ const token = await postGeminiToken(new URLSearchParams({
client_id: oauthConfig.clientId, client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret, client_secret: oauthConfig.clientSecret,
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: refreshToken, refresh_token: refreshToken,
})); }), proxyUrl);
const nextProjectId = oauth?.projectId const nextProjectId = oauth?.projectId
? oauth.projectId ? oauth.projectId
: await ensureGeminiProjectAndOnboard(token.accessToken); : await ensureGeminiProjectAndOnboard(token.accessToken, undefined, proxyUrl);
await checkCloudAIAPIEnabled(token.accessToken, nextProjectId); await checkCloudAIAPIEnabled(token.accessToken, nextProjectId, proxyUrl);
const email = await fetchGeminiUserEmail(token.accessToken); const email = await fetchGeminiUserEmail(token.accessToken, proxyUrl);
return { return {
...token, ...token,
email, email,
@@ -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);
});
});
@@ -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<number> {
const row = await db.select({
maxSortOrder: sql<number>`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<void> {
const definitions = listOAuthProviderDefinitions();
for (const definition of definitions) {
await ensureOauthProviderSite(definition);
}
}
+2
View File
@@ -71,6 +71,7 @@ export interface OAuthProviderDefinition {
redirectUri: string; redirectUri: string;
codeVerifier: string; codeVerifier: string;
projectId?: string; projectId?: string;
proxyUrl?: string | null;
}): Promise<OAuthProviderExchangeResult>; }): Promise<OAuthProviderExchangeResult>;
refreshAccessToken(input: { refreshAccessToken(input: {
refreshToken: string; refreshToken: string;
@@ -78,6 +79,7 @@ export interface OAuthProviderDefinition {
projectId?: string; projectId?: string;
providerData?: Record<string, unknown>; providerData?: Record<string, unknown>;
}; };
proxyUrl?: string | null;
}): Promise<OAuthProviderRefreshResult>; }): Promise<OAuthProviderRefreshResult>;
buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record<string, string>; buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record<string, string>;
} }
+19
View File
@@ -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<string | null> {
const definition = getOAuthProviderDefinition(provider);
if (!definition) return null;
return resolveSiteProxyUrlByRequestUrl(definition.site.url);
}
export async function resolveOauthAccountProxyUrl(siteId: number | null | undefined): Promise<string | null> {
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);
}
+6 -23
View File
@@ -15,6 +15,7 @@ import {
listOAuthProviderDefinitions, listOAuthProviderDefinitions,
type OAuthProviderDefinition, type OAuthProviderDefinition,
} from './providers.js'; } from './providers.js';
import { ensureOauthProviderSite } from './oauthSiteRegistry.js';
import { import {
buildOauthInfo, buildOauthInfo,
buildOauthInfoFromAccount, buildOauthInfoFromAccount,
@@ -28,6 +29,7 @@ import {
type OauthExtraConfigInput, type OauthExtraConfigInput,
type OauthIdentityCarrierLike, type OauthIdentityCarrierLike,
} from './codexAccount.js'; } from './codexAccount.js';
import { resolveOauthAccountProxyUrl, resolveOauthProviderProxyUrl } from './requestProxy.js';
import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js'; import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js';
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js'; import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
@@ -140,30 +142,8 @@ async function getNextAccountSortOrder(): Promise<number> {
return (row?.maxSortOrder ?? -1) + 1; return (row?.maxSortOrder ?? -1) + 1;
} }
async function getNextSiteSortOrder(): Promise<number> {
const row = await db.select({
maxSortOrder: sql<number>`COALESCE(MAX(${schema.sites.sortOrder}), -1)`,
}).from(schema.sites).get();
return (row?.maxSortOrder ?? -1) + 1;
}
async function ensureOauthSite(definition: OAuthProviderDefinition) { async function ensureOauthSite(definition: OAuthProviderDefinition) {
const existing = await db.select().from(schema.sites).where(and( return ensureOauthProviderSite(definition);
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();
} }
async function findExistingOauthAccount(input: { async function findExistingOauthAccount(input: {
@@ -375,12 +355,14 @@ export async function handleOauthCallback(input: {
} }
try { try {
const proxyUrl = await resolveOauthProviderProxyUrl(input.provider);
const exchange = await definition.exchangeAuthorizationCode({ const exchange = await definition.exchangeAuthorizationCode({
code, code,
state: input.state, state: input.state,
redirectUri: session.redirectUri, redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier, codeVerifier: session.codeVerifier,
projectId: session.projectId, projectId: session.projectId,
proxyUrl,
}); });
const { account, site, created, previousAccount } = await upsertOauthAccount({ const { account, site, created, previousAccount } = await upsertOauthAccount({
definition, definition,
@@ -655,6 +637,7 @@ export async function refreshOauthAccessToken(accountId: number) {
projectId: oauth.projectId, projectId: oauth.projectId,
providerData: oauth.providerData, providerData: oauth.providerData,
}, },
proxyUrl: await resolveOauthAccountProxyUrl(account.siteId),
}); });
const nextOauth = buildOauthInfoFromAccount(account, { const nextOauth = buildOauthInfoFromAccount(account, {
provider: oauth.provider, provider: oauth.provider,