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:
Cita
2026-03-29 00:35:45 +08:00
committed by GitHub
parent 785ee93505
commit 03ca1154a0
9 changed files with 584 additions and 21 deletions
+182
View File
@@ -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',
+19 -3
View File
@@ -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',
+9 -3
View File
@@ -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);
}
+23 -5
View File
@@ -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);
}