[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 { 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);
+85
View File
@@ -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<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 () => {
const jwt = buildJwt({
email: 'codex-fail@example.com',
@@ -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<T>(
accessToken: string,
method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>,
proxyUrl?: string | null,
): Promise<T | undefined> {
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<T>(
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
},
body: JSON.stringify(body),
},
}),
);
if (!response.ok) return undefined;
return response.json() as Promise<T>;
}
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<string | undefined> {
const response = await fetch(ANTIGRAVITY_USERINFO_URL, {
async function fetchAntigravityUserEmail(
accessToken: string,
proxyUrl?: string | null,
): Promise<string | undefined> {
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<string | undefined> {
async function fetchAntigravityProjectId(
accessToken: string,
proxyUrl?: string | null,
): Promise<string | undefined> {
const metadata = buildAntigravityMetadata();
const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>(
accessToken,
'loadCodeAssist',
{ metadata },
proxyUrl,
);
if (!payload) return undefined;
@@ -194,6 +206,7 @@ async function fetchAntigravityProjectId(accessToken: string): Promise<string |
tierId,
metadata,
},
proxyUrl,
);
if (!onboardPayload) return undefined;
if (onboardPayload.done === true) {
@@ -249,16 +262,16 @@ export const antigravityOauthProvider: OAuthProviderDefinition = {
});
return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`;
},
exchangeAuthorizationCode: async ({ code, redirectUri }) => {
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: () => ({
+11 -7
View File
@@ -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<string, unknown>) {
const response = await fetch(CLAUDE_TOKEN_URL, {
async function postClaudeToken(
body: Record<string, unknown>,
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,
+18 -9
View File
@@ -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<CodexTokenExchangeResult> {
const response = await fetch(CODEX_TOKEN_URL, {
async function exchangeCodexToken(
form: URLSearchParams,
proxyUrl?: string | null,
): Promise<CodexTokenExchangeResult> {
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<CodexTokenExchangeResult> {
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<CodexTokenExchangeResult> {
export async function refreshCodexTokens(
refreshToken: string,
proxyUrl?: string | null,
): Promise<CodexTokenExchangeResult> {
return exchangeCodexToken(new URLSearchParams({
client_id: requireCodexClientId(),
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope: 'openid profile email',
}));
}), proxyUrl);
}
function getHeaderValue(headers: Record<string, unknown> | 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,
+46 -29
View File
@@ -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<void> {
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<string | undefined> {
const response = await fetch(GEMINI_CLI_USERINFO_URL, {
async function fetchGeminiUserEmail(
accessToken: string,
proxyUrl?: string | null,
): Promise<string | undefined> {
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<T>(
accessToken: string,
method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>,
proxyUrl?: string | null,
): Promise<T> {
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<T>(
'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<T>(
return response.json() as Promise<T>;
}
async function fetchGcpProjects(accessToken: string): Promise<string[]> {
const response = await fetch(GEMINI_CLI_PROJECTS_URL, {
async function fetchGcpProjects(accessToken: string, proxyUrl?: string | null): Promise<string[]> {
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<string[]> {
.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(
`${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<string> {
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<string> {
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,
@@ -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;
codeVerifier: string;
projectId?: string;
proxyUrl?: string | null;
}): Promise<OAuthProviderExchangeResult>;
refreshAccessToken(input: {
refreshToken: string;
@@ -78,6 +79,7 @@ export interface OAuthProviderDefinition {
projectId?: string;
providerData?: Record<string, unknown>;
};
proxyUrl?: string | null;
}): Promise<OAuthProviderRefreshResult>;
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,
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<number> {
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) {
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,