forked from boos/BoosAPI
Add OAuth account proxy controls (#307)
* Add oauth account proxy controls * Refine oauth proxy review fixes * Fix oauth clear-proxy exchange fallback
This commit is contained in:
@@ -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<string, unknown> | 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',
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export const antigravityOauthProvider: OAuthProviderDefinition = {
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: false,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
site: {
|
||||
name: 'Google Antigravity OAuth',
|
||||
|
||||
@@ -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<string | null> {
|
||||
@@ -9,11 +10,16 @@ export async function resolveOauthProviderProxyUrl(provider: string): Promise<st
|
||||
return resolveSiteProxyUrlByRequestUrl(definition.site.url);
|
||||
}
|
||||
|
||||
export async function resolveOauthAccountProxyUrl(siteId: number | null | undefined): Promise<string | null> {
|
||||
if (!siteId || siteId <= 0) return null;
|
||||
export async function resolveOauthAccountProxyUrl(input: {
|
||||
siteId?: number | null;
|
||||
extraConfig?: string | null;
|
||||
}): Promise<string | null> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+4
-3
@@ -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<OAuthStartResponse>,
|
||||
@@ -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<OAuthStartResponse>,
|
||||
deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -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(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
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) => (
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16, display: 'grid', gap: 12 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>授权设置</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||
这里的设置会作用于下一次“连接”或“重新授权”。填写代理地址后,本次 OAuth 换 token 和后续生成的账号都会直接带上这份账号级代理配置。
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={oauthProxyEnabled}
|
||||
data-oauth-setting="use-proxy"
|
||||
onChange={(event) => {
|
||||
const checked = !!event.target.checked;
|
||||
setOauthProxyEnabled(checked);
|
||||
if (checked) setOauthProxyClearEnabled(false);
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-primary)' }}>使用自定义代理</span>
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={oauthProxyClearEnabled}
|
||||
data-oauth-setting="clear-proxy"
|
||||
onChange={(event) => {
|
||||
const checked = !!event.target.checked;
|
||||
setOauthProxyClearEnabled(checked);
|
||||
if (checked) setOauthProxyEnabled(false);
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-primary)' }}>清空已有账号代理</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>代理地址</div>
|
||||
<input
|
||||
type="text"
|
||||
value={oauthProxyUrl}
|
||||
data-oauth-setting="proxy-url"
|
||||
onChange={(event) => 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,
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||
关闭时,新建连接会按站点/系统默认链路处理;重新授权已有账号时,会继续沿用该账号已有的代理配置。
|
||||
勾选“清空已有账号代理”后,重新授权会显式移除当前账号上的代理覆盖。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionMessage && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>{sessionMessage}</div>
|
||||
@@ -494,6 +622,11 @@ export default function OAuthManagement() {
|
||||
Project: {connection.projectId}
|
||||
</div>
|
||||
)}
|
||||
{connection.proxyUrl && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
账号代理: {redactProxyUrl(connection.proxyUrl)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
|
||||
</div>
|
||||
@@ -593,7 +726,10 @@ export default function OAuthManagement() {
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: false,
|
||||
},
|
||||
connection.accountId,
|
||||
{
|
||||
accountId: connection.accountId,
|
||||
fallbackProxyUrl: connection.proxyUrl,
|
||||
},
|
||||
)}
|
||||
disabled={
|
||||
actionLoadingKey === rebindActionKey
|
||||
|
||||
Reference in New Issue
Block a user