From 03ca1154a0baf6cf8ef332a99c730fbfbe1d56c0 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 29 Mar 2026 00:35:45 +0800 Subject: [PATCH] Add OAuth account proxy controls (#307) * Add oauth account proxy controls * Refine oauth proxy review fixes * Fix oauth clear-proxy exchange fallback --- src/server/routes/api/oauth.test.ts | 182 ++++++++++++++++ src/server/routes/api/oauth.ts | 22 +- .../services/oauth/antigravityProvider.ts | 2 +- src/server/services/oauth/requestProxy.ts | 12 +- src/server/services/oauth/service.ts | 28 ++- src/server/services/oauth/sessionStore.ts | 5 + src/web/api.ts | 7 +- src/web/pages/OAuthManagement.test.tsx | 203 +++++++++++++++++- src/web/pages/OAuthManagement.tsx | 144 ++++++++++++- 9 files changed, 584 insertions(+), 21 deletions(-) diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index b784ce1..634bf09 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -382,6 +382,9 @@ describe('oauth routes', { timeout: 15_000 }, () => { host: 'metapi.example', 'x-forwarded-proto': 'https', }, + payload: { + proxyUrl: 'http://127.0.0.1:7890', + }, }); const startBody = startResponse.json() as { state: string }; @@ -397,6 +400,7 @@ describe('oauth routes', { timeout: 15_000 }, () => { expect(String(fetchMock.mock.calls[0]?.[1]?.body || '')).toContain( 'redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback', ); + expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined(); const sessionResponse = await app.inject({ method: 'GET', @@ -435,6 +439,7 @@ describe('oauth routes', { timeout: 15_000 }, () => { }); expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ credentialMode: 'session', + proxyUrl: 'http://127.0.0.1:7890', oauth: { email: 'codex-user@example.com', planType: 'plus', @@ -451,6 +456,70 @@ describe('oauth routes', { timeout: 15_000 }, () => { expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']); }); + it('uses the stored account proxy when refreshing a codex oauth access token', async () => { + const oauthService = await import('../../services/oauth/service.js'); + const refreshJwt = buildJwt({ + email: 'codex-refreshed@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-refresh', + chatgpt_plan_type: 'team', + }, + }); + const site = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + const existing = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-existing@example.com', + accessToken: 'oauth-access-token-old', + apiToken: null, + checkinEnabled: false, + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-refresh', + extraConfig: JSON.stringify({ + credentialMode: 'session', + proxyUrl: 'http://127.0.0.1:7890', + oauth: { + email: 'codex-existing@example.com', + refreshToken: 'oauth-refresh-token-old', + idToken: refreshJwt, + }, + }), + }).returning().get(); + + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token-new', + refresh_token: 'oauth-refresh-token-new', + id_token: refreshJwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const refreshed = await oauthService.refreshOauthAccessToken(existing.id); + + expect(refreshed.accessToken).toBe('oauth-access-token-new'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined(); + + const updated = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(); + expect(updated?.accessToken).toBe('oauth-access-token-new'); + expect(JSON.parse(updated?.extraConfig || '{}')).toMatchObject({ + proxyUrl: 'http://127.0.0.1:7890', + oauth: { + refreshToken: 'oauth-refresh-token-new', + }, + }); + }); + it('includes dispatcher for codex token exchange when oauth site enables the system proxy', async () => { const jwt = buildJwt({ email: 'codex-proxy@example.com', @@ -519,6 +588,119 @@ describe('oauth routes', { timeout: 15_000 }, () => { })); }); + it('falls back to the site proxy during rebind exchange when clearing an account proxy override', async () => { + const originalJwt = buildJwt({ + email: 'codex-clear@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-clear-existing', + chatgpt_plan_type: 'plus', + }, + }); + const reboundJwt = buildJwt({ + email: 'codex-clear@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-clear-rebound', + chatgpt_plan_type: 'team', + }, + }); + await db.insert(schema.settings).values({ + key: 'system_proxy_url', + value: JSON.stringify('http://127.0.0.1:7890'), + }).run(); + const site = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + useSystemProxy: true, + }).returning().get(); + const existing = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-clear@example.com', + accessToken: 'stable-access-token', + apiToken: null, + checkinEnabled: false, + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-clear-existing', + extraConfig: JSON.stringify({ + credentialMode: 'session', + proxyUrl: 'http://127.0.0.1:9999', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-clear-existing', + accountKey: 'chatgpt-account-clear-existing', + email: 'codex-clear@example.com', + planType: 'plus', + refreshToken: 'stable-refresh-token', + idToken: originalJwt, + }, + }), + }).returning().get(); + + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'rebound-access-token', + refresh_token: 'rebound-refresh-token', + id_token: reboundJwt, + 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/connections/${existing.id}/rebind`, + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + payload: { + proxyUrl: null, + }, + }); + 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-clear-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(), + })); + + const stored = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(); + expect(stored?.accessToken).toBe('rebound-access-token'); + expect(stored?.oauthAccountKey).toBe('chatgpt-account-clear-rebound'); + expect(JSON.parse(stored?.extraConfig || '{}')).toMatchObject({ + proxyUrl: null, + oauth: { + refreshToken: 'rebound-refresh-token', + idToken: reboundJwt, + }, + }); + }); + 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/routes/api/oauth.ts b/src/server/routes/api/oauth.ts index 32976eb..5a819ef 100644 --- a/src/server/routes/api/oauth.ts +++ b/src/server/routes/api/oauth.ts @@ -11,6 +11,7 @@ import { startOauthRebindFlow, submitOauthManualCallback, } from '../../services/oauth/service.js'; +import { parseSiteProxyUrlInput } from '../../services/siteProxy.js'; const limitOauthProviderRead = createRateLimitGuard({ bucket: 'oauth-provider-read', @@ -110,7 +111,7 @@ export async function oauthRoutes(app: FastifyInstance) { providers: listOauthProviders(), })); - app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string } }>( + app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string; proxyUrl?: string | null } }>( '/api/oauth/providers/:provider/start', { preHandler: [limitOauthStart] }, async (request, reply) => { @@ -124,12 +125,17 @@ export async function oauthRoutes(app: FastifyInstance) { if (request.body?.projectId !== undefined && projectId === null) { return reply.code(400).send({ message: 'invalid project id' }); } + const normalizedProxyUrl = parseSiteProxyUrlInput(request.body?.proxyUrl); + if (normalizedProxyUrl.present && !normalizedProxyUrl.valid) { + return reply.code(400).send({ message: 'invalid proxy url' }); + } try { return await startOauthProviderFlow({ provider: request.params.provider, rebindAccountId: rebindAccountId ?? undefined, projectId: projectId ?? undefined, + proxyUrl: normalizedProxyUrl.present ? normalizedProxyUrl.proxyUrl : undefined, requestOrigin: resolveRequestOrigin(request), }); } catch (error: any) { @@ -203,7 +209,7 @@ export async function oauthRoutes(app: FastifyInstance) { }, ); - app.post<{ Params: { accountId: string } }>( + app.post<{ Params: { accountId: string }; Body?: { proxyUrl?: string | null } }>( '/api/oauth/connections/:accountId/rebind', { preHandler: [limitOauthConnectionMutate] }, async (request, reply) => { @@ -211,8 +217,18 @@ export async function oauthRoutes(app: FastifyInstance) { if (accountId === null) { return reply.code(400).send({ message: 'invalid account id' }); } + const normalizedProxyUrl = parseSiteProxyUrlInput(request.body?.proxyUrl); + if (normalizedProxyUrl.present && !normalizedProxyUrl.valid) { + return reply.code(400).send({ message: 'invalid proxy url' }); + } try { - return await startOauthRebindFlow(accountId, resolveRequestOrigin(request)); + return await startOauthRebindFlow( + accountId, + { + requestOrigin: resolveRequestOrigin(request), + proxyUrl: normalizedProxyUrl.present ? normalizedProxyUrl.proxyUrl : undefined, + }, + ); } catch (error: any) { return reply.code(404).send({ message: error?.message || 'oauth account not found' }); } diff --git a/src/server/services/oauth/antigravityProvider.ts b/src/server/services/oauth/antigravityProvider.ts index 32a8834..3c2e1fc 100644 --- a/src/server/services/oauth/antigravityProvider.ts +++ b/src/server/services/oauth/antigravityProvider.ts @@ -237,7 +237,7 @@ export const antigravityOauthProvider: OAuthProviderDefinition = { requiresProjectId: false, supportsDirectAccountRouting: true, supportsCloudValidation: true, - supportsNativeProxy: false, + supportsNativeProxy: true, }, site: { name: 'Google Antigravity OAuth', diff --git a/src/server/services/oauth/requestProxy.ts b/src/server/services/oauth/requestProxy.ts index 9c7075b..2c9ced1 100644 --- a/src/server/services/oauth/requestProxy.ts +++ b/src/server/services/oauth/requestProxy.ts @@ -1,6 +1,7 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; import { getOAuthProviderDefinition } from './providers.js'; +import { getProxyUrlFromExtraConfig } from '../accountExtraConfig.js'; import { resolveSiteProxyUrlByRequestUrl } from '../siteProxy.js'; export async function resolveOauthProviderProxyUrl(provider: string): Promise { @@ -9,11 +10,16 @@ export async function resolveOauthProviderProxyUrl(provider: string): Promise { - if (!siteId || siteId <= 0) return null; +export async function resolveOauthAccountProxyUrl(input: { + siteId?: number | null; + extraConfig?: string | null; +}): Promise { + const accountProxyUrl = getProxyUrlFromExtraConfig(input.extraConfig); + if (accountProxyUrl) return accountProxyUrl; + if (!input.siteId || input.siteId <= 0) return null; const site = await db.select({ url: schema.sites.url, - }).from(schema.sites).where(eq(schema.sites.id, siteId)).get(); + }).from(schema.sites).where(eq(schema.sites.id, input.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 529bce1..cfb90c1 100644 --- a/src/server/services/oauth/service.ts +++ b/src/server/services/oauth/service.ts @@ -1,6 +1,6 @@ import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; -import { mergeAccountExtraConfig } from '../accountExtraConfig.js'; +import { getProxyUrlFromExtraConfig, mergeAccountExtraConfig } from '../accountExtraConfig.js'; import { refreshModelsForAccount } from '../modelService.js'; import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js'; import { @@ -200,6 +200,7 @@ async function upsertOauthAccount(input: { providerData?: Record; }; rebindAccountId?: number; + proxyUrl?: string | null; }) { const site = await ensureOauthSite(input.definition); const existing = await findExistingOauthAccount({ @@ -228,6 +229,7 @@ async function upsertOauthAccount(input: { }); const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, { credentialMode: 'session', + ...(input.proxyUrl !== undefined ? { proxyUrl: input.proxyUrl } : {}), oauth: buildStoredOauthState(oauth), }); @@ -284,6 +286,7 @@ export async function startOauthProviderFlow(input: { provider: string; rebindAccountId?: number; projectId?: string; + proxyUrl?: string | null; requestOrigin?: string; }) { const definition = getOAuthProviderDefinition(input.provider); @@ -300,6 +303,7 @@ export async function startOauthProviderFlow(input: { redirectUri, rebindAccountId: input.rebindAccountId, projectId: input.projectId, + proxyUrl: input.proxyUrl, }); return { provider: input.provider, @@ -355,19 +359,22 @@ export async function handleOauthCallback(input: { } try { - const proxyUrl = await resolveOauthProviderProxyUrl(input.provider); + const resolvedProxyUrl = session.proxyUrl == null + ? await resolveOauthProviderProxyUrl(input.provider) + : session.proxyUrl; const exchange = await definition.exchangeAuthorizationCode({ code, state: input.state, redirectUri: session.redirectUri, codeVerifier: session.codeVerifier, projectId: session.projectId, - proxyUrl, + proxyUrl: resolvedProxyUrl, }); const { account, site, created, previousAccount } = await upsertOauthAccount({ definition, exchange, rebindAccountId: session.rebindAccountId, + proxyUrl: session.proxyUrl, }); if (!account) { markOauthSessionError(input.state, 'failed to persist oauth account'); @@ -534,6 +541,7 @@ export async function listOauthConnections(options: { routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0, lastModelSyncAt: oauth.lastModelSyncAt, lastModelSyncError: oauth.lastModelSyncError, + proxyUrl: getProxyUrlFromExtraConfig(row.accounts.extraConfig), site: { id: row.sites.id, name: row.sites.name, @@ -567,7 +575,11 @@ export async function refreshOauthConnectionQuota(accountId: number) { return { success: true, quota }; } -export async function startOauthRebindFlow(accountId: number, requestOrigin?: string) { +export async function startOauthRebindFlow( + accountId: number, + options?: { requestOrigin?: string; proxyUrl?: string | null }, +) { + const { requestOrigin, proxyUrl } = options ?? {}; const account = await db.select().from(schema.accounts) .where(eq(schema.accounts.id, accountId)) .get(); @@ -582,6 +594,9 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st provider: oauth.provider, rebindAccountId: accountId, projectId: oauth.projectId, + proxyUrl: proxyUrl !== undefined + ? proxyUrl + : (getProxyUrlFromExtraConfig(account.extraConfig) ?? undefined), requestOrigin, }); } @@ -637,7 +652,10 @@ export async function refreshOauthAccessToken(accountId: number) { projectId: oauth.projectId, providerData: oauth.providerData, }, - proxyUrl: await resolveOauthAccountProxyUrl(account.siteId), + proxyUrl: await resolveOauthAccountProxyUrl({ + siteId: account.siteId, + extraConfig: account.extraConfig, + }), }); const nextOauth = buildOauthInfoFromAccount(account, { provider: oauth.provider, diff --git a/src/server/services/oauth/sessionStore.ts b/src/server/services/oauth/sessionStore.ts index 6cd1257..65d1a2e 100644 --- a/src/server/services/oauth/sessionStore.ts +++ b/src/server/services/oauth/sessionStore.ts @@ -16,6 +16,7 @@ export type OAuthSessionRecord = { error?: string; rebindAccountId?: number; projectId?: string; + proxyUrl?: string | null; }; export interface OAuthSessionStore { @@ -24,6 +25,7 @@ export interface OAuthSessionStore { redirectUri: string; rebindAccountId?: number; projectId?: string; + proxyUrl?: string | null; }): OAuthSessionRecord; get(state: string): OAuthSessionRecord | null; markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null; @@ -65,6 +67,7 @@ class MemoryOAuthSessionStore implements OAuthSessionStore { redirectUri: string; rebindAccountId?: number; projectId?: string; + proxyUrl?: string | null; }): OAuthSessionRecord { this.pruneExpiredSessions(); const state = toBase64Url(randomBytes(24)); @@ -82,6 +85,7 @@ class MemoryOAuthSessionStore implements OAuthSessionStore { expiresAt, rebindAccountId: input.rebindAccountId, projectId: input.projectId, + proxyUrl: input.proxyUrl, }; this.sessions.set(state, record); return record; @@ -132,6 +136,7 @@ export function createOauthSession(input: { redirectUri: string; rebindAccountId?: number; projectId?: string; + proxyUrl?: string | null; }): OAuthSessionRecord { return oauthSessionStore.create(input); } diff --git a/src/web/api.ts b/src/web/api.ts index d7d9c0d..5d2847f 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -497,6 +497,7 @@ export type OAuthConnectionInfo = { routeChannelCount?: number; lastModelSyncAt?: string | null; lastModelSyncError?: string | null; + proxyUrl?: string | null; site?: { id: number; name: string; url: string; platform: string } | null; }; @@ -625,7 +626,7 @@ export const api = { // OAuth getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>, - startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, { + startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string; proxyUrl?: string | null }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, { method: 'POST', body: JSON.stringify(data || {}), }) as Promise, @@ -640,9 +641,9 @@ export const api = { method: 'POST', body: JSON.stringify({}), }) as Promise<{ success: true; quota: OAuthQuotaInfo }>, - rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, { + rebindOAuthConnection: (accountId: number, data?: { proxyUrl?: string | null }) => request(`/api/oauth/connections/${accountId}/rebind`, { method: 'POST', - body: JSON.stringify({}), + body: JSON.stringify(data || {}), }) as Promise, deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, { method: 'DELETE', diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx index 1fbfcbe..932e001 100644 --- a/src/web/pages/OAuthManagement.test.tsx +++ b/src/web/pages/OAuthManagement.test.tsx @@ -221,6 +221,25 @@ describe('OAuthManagement page', () => { await flushMicrotasks(); }); + const proxyToggle = root!.root.find((node) => ( + node.type === 'input' + && node.props.type === 'checkbox' + && node.props['data-oauth-setting'] === 'use-proxy' + )); + + await act(async () => { + proxyToggle.props.onChange({ target: { checked: true } }); + }); + + const proxyInput = root!.root.find((node) => ( + node.type === 'input' + && node.props['data-oauth-setting'] === 'proxy-url' + )); + + await act(async () => { + proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } }); + }); + const startButton = root!.root.find((node) => ( node.type === 'button' && typeof node.props.onClick === 'function' @@ -234,7 +253,10 @@ describe('OAuthManagement page', () => { await flushMicrotasks(); }); - expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', { projectId: undefined }); + expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', { + projectId: undefined, + proxyUrl: 'http://127.0.0.1:7890', + }); expect(openMock).toHaveBeenCalledWith( 'https://auth.openai.com/oauth/authorize?state=oauth-state-123', 'oauth-codex', @@ -647,7 +669,7 @@ describe('OAuthManagement page', () => { }); expect(promptMock).not.toHaveBeenCalled(); - expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11); + expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, {}); expect(openMock).toHaveBeenCalledWith( 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123', 'oauth-gemini-cli', @@ -658,6 +680,180 @@ describe('OAuthManagement page', () => { } }); + it('reuses the stored account proxy for rebind when no new override is selected', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'gemini-cli', + label: 'Gemini CLI', + platform: 'gemini-cli', + enabled: true, + loginType: 'oauth', + requiresProjectId: true, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [ + { + accountId: 11, + provider: 'gemini-cli', + email: 'gemini@example.com', + projectId: 'project-demo', + modelCount: 5, + modelsPreview: ['gemini-2.5-pro'], + status: 'healthy', + proxyUrl: 'http://127.0.0.1:7890', + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + apiMock.rebindOAuthConnection.mockResolvedValue({ + provider: 'gemini-cli', + state: 'gemini-rebind-proxy', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-proxy', + instructions: { + redirectUri: 'http://localhost:8085/oauth2callback', + callbackPort: 8085, + callbackPath: '/oauth2callback', + manualCallbackDelayMs: 15000, + }, + }); + apiMock.getOAuthSession.mockResolvedValue({ + provider: 'gemini-cli', + state: 'gemini-rebind-proxy', + status: 'pending', + }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const rebindButton = root!.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('重新授权') + )); + + await act(async () => { + await rebindButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, { + proxyUrl: 'http://127.0.0.1:7890', + }); + } finally { + root?.unmount(); + } + }); + + it('can clear the stored account proxy during rebind', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'gemini-cli', + label: 'Gemini CLI', + platform: 'gemini-cli', + enabled: true, + loginType: 'oauth', + requiresProjectId: true, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [ + { + accountId: 11, + provider: 'gemini-cli', + email: 'gemini@example.com', + projectId: 'project-demo', + modelCount: 5, + modelsPreview: ['gemini-2.5-pro'], + status: 'healthy', + proxyUrl: 'http://127.0.0.1:7890', + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + apiMock.rebindOAuthConnection.mockResolvedValue({ + provider: 'gemini-cli', + state: 'gemini-rebind-clear', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-clear', + instructions: { + redirectUri: 'http://localhost:8085/oauth2callback', + callbackPort: 8085, + callbackPath: '/oauth2callback', + manualCallbackDelayMs: 15000, + }, + }); + apiMock.getOAuthSession.mockResolvedValue({ + provider: 'gemini-cli', + state: 'gemini-rebind-clear', + status: 'pending', + }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const clearProxyToggle = root!.root.find((node) => ( + node.type === 'input' + && node.props.type === 'checkbox' + && node.props['data-oauth-setting'] === 'clear-proxy' + )); + + await act(async () => { + clearProxyToggle.props.onChange({ target: { checked: true } }); + }); + + const rebindButton = root!.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('重新授权') + )); + + await act(async () => { + await rebindButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, { + proxyUrl: null, + }); + } finally { + root?.unmount(); + } + }); + it('shows oauth connection status metadata and allows deleting a connection', async () => { apiMock.getOAuthProviders.mockResolvedValue({ providers: [ @@ -708,6 +904,7 @@ describe('OAuthManagement page', () => { routeChannelCount: 1, lastModelSyncAt: '2026-03-17T08:00:00.000Z', lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', + proxyUrl: 'http://oauth-user:secret@127.0.0.1:7890', }, ], total: 1, @@ -740,6 +937,8 @@ describe('OAuthManagement page', () => { expect(text).toContain('2026-04-01T00:00:00.000Z'); expect(text).toContain('2026-03-17T13:00:00.000Z'); expect(text).toContain('官方未提供'); + expect(text).toContain('http://***@127.0.0.1:7890'); + expect(text).not.toContain('oauth-user:secret'); }); const deleteButton = root!.root.find((node) => ( diff --git a/src/web/pages/OAuthManagement.tsx b/src/web/pages/OAuthManagement.tsx index 16190a9..031625f 100644 --- a/src/web/pages/OAuthManagement.tsx +++ b/src/web/pages/OAuthManagement.tsx @@ -45,6 +45,24 @@ function asTrimmedString(value: string | null | undefined): string { return typeof value === 'string' ? value.trim() : ''; } +function redactProxyUrl(value: string | null | undefined): string { + const text = asTrimmedString(value); + if (!text) return ''; + try { + const parsed = new URL(text); + if (parsed.username || parsed.password) { + parsed.username = '***'; + parsed.password = ''; + } + const serialized = parsed.toString(); + return parsed.pathname === '/' && !parsed.search && !parsed.hash + ? serialized.replace(/\/$/, '') + : serialized; + } catch { + return text.replace(/\/\/[^/@:\s]+(?::[^/@\s]*)?@/, '//***@'); + } +} + function resolveProviderActionLabel(provider: OAuthProviderInfo, loading: boolean): string { if (loading) return '启动中...'; if (!provider.enabled) return '当前不可用'; @@ -143,6 +161,9 @@ export default function OAuthManagement() { const [manualCallbackVisible, setManualCallbackVisible] = useState(false); const [manualCallbackUrl, setManualCallbackUrl] = useState(''); const [manualCallbackSubmitting, setManualCallbackSubmitting] = useState(false); + const [oauthProxyEnabled, setOauthProxyEnabled] = useState(false); + const [oauthProxyClearEnabled, setOauthProxyClearEnabled] = useState(false); + const [oauthProxyUrl, setOauthProxyUrl] = useState(''); const loadConnections = async () => { const response = await api.getOAuthConnections({ @@ -230,11 +251,31 @@ export default function OAuthManagement() { return () => clearTimeout(timer); }, [activeSession]); - const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => { + const handleStart = async ( + provider: OAuthProviderInfo, + options?: { + accountId?: number; + fallbackProxyUrl?: string | null; + }, + ) => { if (!provider.enabled) { setSessionMessage(`${provider.label} 当前环境未启用`); return; } + const accountId = options?.accountId; + const customProxyUrl = asTrimmedString(oauthProxyUrl); + if (oauthProxyEnabled && !customProxyUrl) { + setSessionMessage('已开启代理,请先输入完整代理地址'); + return; + } + let resolvedProxyUrl: string | null | undefined; + if (oauthProxyEnabled) { + resolvedProxyUrl = customProxyUrl; + } else if (oauthProxyClearEnabled) { + resolvedProxyUrl = null; + } else if (options?.fallbackProxyUrl !== undefined) { + resolvedProxyUrl = asTrimmedString(options.fallbackProxyUrl) || null; + } const actionKey = `start:${provider.provider}:${accountId || 0}`; setActionLoadingKey(actionKey); try { @@ -247,8 +288,14 @@ export default function OAuthManagement() { })() : undefined; const started = accountId - ? await api.rebindOAuthConnection(accountId) - : await api.startOAuthProvider(provider.provider, { projectId }); + ? await api.rebindOAuthConnection( + accountId, + resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}, + ) + : await api.startOAuthProvider(provider.provider, { + projectId, + ...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}), + }); setSessionMessage('等待授权完成'); setActiveSession({ provider: started.provider, @@ -325,6 +372,87 @@ export default function OAuthManagement() { +
+
授权设置
+
+ 这里的设置会作用于下一次“连接”或“重新授权”。填写代理地址后,本次 OAuth 换 token 和后续生成的账号都会直接带上这份账号级代理配置。 +
+
+ + +
+
+
代理地址
+ setOauthProxyUrl(event.target.value)} + placeholder="如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080" + disabled={!oauthProxyEnabled} + style={{ + width: '100%', + maxWidth: 460, + padding: '10px 14px', + borderRadius: 'var(--radius-sm)', + border: '1px solid var(--color-border)', + background: oauthProxyEnabled ? 'var(--color-bg)' : 'var(--color-bg-card)', + color: 'var(--color-text-primary)', + fontSize: 13, + opacity: oauthProxyEnabled ? 1 : 0.7, + }} + /> +
+ 关闭时,新建连接会按站点/系统默认链路处理;重新授权已有账号时,会继续沿用该账号已有的代理配置。 + 勾选“清空已有账号代理”后,重新授权会显式移除当前账号上的代理覆盖。 +
+
+
+ {sessionMessage && (
{sessionMessage}
@@ -494,6 +622,11 @@ export default function OAuthManagement() { Project: {connection.projectId}
)} + {connection.proxyUrl && ( +
+ 账号代理: {redactProxyUrl(connection.proxyUrl)} +
+ )}
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
@@ -593,7 +726,10 @@ export default function OAuthManagement() { supportsCloudValidation: true, supportsNativeProxy: false, }, - connection.accountId, + { + accountId: connection.accountId, + fallbackProxyUrl: connection.proxyUrl, + }, )} disabled={ actionLoadingKey === rebindActionKey