forked from boos/BoosAPI
[codex] inherit site proxy settings for oauth (#296)
* test: define oauth proxy inheritance behavior * feat: apply site proxy settings to oauth requests * feat: backfill oauth provider sites at startup * test: remove oauth site registry temp dir
This commit is contained in:
@@ -30,6 +30,7 @@ import { repairStoredCreatedAtValues } from './services/storedTimestampRepairSer
|
|||||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||||
import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js';
|
import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js';
|
||||||
|
import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js';
|
||||||
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
||||||
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
|
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
|
||||||
import { reloadBackupWebdavScheduler } from './services/backupService.js';
|
import { reloadBackupWebdavScheduler } from './services/backupService.js';
|
||||||
@@ -369,6 +370,8 @@ try {
|
|||||||
console.warn(`Failed to load runtime settings overrides: ${(error as Error)?.message || 'unknown error'}`);
|
console.warn(`Failed to load runtime settings overrides: ${(error as Error)?.message || 'unknown error'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureOauthProviderSitesExist();
|
||||||
|
|
||||||
const app = Fastify(buildFastifyOptions(config));
|
const app = Fastify(buildFastifyOptions(config));
|
||||||
|
|
||||||
await app.register(cors);
|
await app.register(cors);
|
||||||
|
|||||||
@@ -6,9 +6,21 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
|
const undiciAgentCtorMock = vi.fn();
|
||||||
|
const undiciProxyAgentCtorMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', () => ({
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
|
Agent: class MockUndiciAgent {
|
||||||
|
constructor(...args: unknown[]) {
|
||||||
|
undiciAgentCtorMock(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ProxyAgent: class MockUndiciProxyAgent {
|
||||||
|
constructor(...args: unknown[]) {
|
||||||
|
undiciProxyAgentCtorMock(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type DbModule = typeof import('../../db/index.js');
|
type DbModule = typeof import('../../db/index.js');
|
||||||
@@ -51,13 +63,18 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
|
undiciAgentCtorMock.mockReset();
|
||||||
|
undiciProxyAgentCtorMock.mockReset();
|
||||||
await db.delete(schema.routeChannels).run();
|
await db.delete(schema.routeChannels).run();
|
||||||
await db.delete(schema.tokenRoutes).run();
|
await db.delete(schema.tokenRoutes).run();
|
||||||
await db.delete(schema.tokenModelAvailability).run();
|
await db.delete(schema.tokenModelAvailability).run();
|
||||||
await db.delete(schema.modelAvailability).run();
|
await db.delete(schema.modelAvailability).run();
|
||||||
await db.delete(schema.accountTokens).run();
|
await db.delete(schema.accountTokens).run();
|
||||||
await db.delete(schema.accounts).run();
|
await db.delete(schema.accounts).run();
|
||||||
|
await db.delete(schema.settings).run();
|
||||||
await db.delete(schema.sites).run();
|
await db.delete(schema.sites).run();
|
||||||
|
const { invalidateSiteProxyCache } = await import('../../services/siteProxy.js');
|
||||||
|
invalidateSiteProxyCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -434,6 +451,74 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']);
|
expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes dispatcher for codex token exchange when oauth site enables the system proxy', async () => {
|
||||||
|
const jwt = buildJwt({
|
||||||
|
email: 'codex-proxy@example.com',
|
||||||
|
'https://api.openai.com/auth': {
|
||||||
|
chatgpt_account_id: 'chatgpt-account-proxy',
|
||||||
|
chatgpt_plan_type: 'plus',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.insert(schema.settings).values({
|
||||||
|
key: 'system_proxy_url',
|
||||||
|
value: JSON.stringify('http://127.0.0.1:7890'),
|
||||||
|
}).run();
|
||||||
|
await db.insert(schema.sites).values({
|
||||||
|
name: 'ChatGPT Codex OAuth',
|
||||||
|
url: 'https://chatgpt.com/backend-api/codex',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
useSystemProxy: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'oauth-access-token',
|
||||||
|
refresh_token: 'oauth-refresh-token',
|
||||||
|
id_token: jwt,
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
models: [{ id: 'gpt-5.4' }],
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const startResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/oauth/providers/codex/start',
|
||||||
|
headers: {
|
||||||
|
host: 'metapi.example',
|
||||||
|
'x-forwarded-proto': 'https',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(startResponse.statusCode).toBe(200);
|
||||||
|
const startBody = startResponse.json() as { state: string };
|
||||||
|
|
||||||
|
const callbackResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
|
||||||
|
payload: {
|
||||||
|
callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-proxy`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(callbackResponse.statusCode).toBe(200);
|
||||||
|
const codexTokenCall = fetchMock.mock.calls.find((call) => String(call[0] || '') === 'https://auth.openai.com/oauth/token');
|
||||||
|
const codexTokenFetchInit = codexTokenCall?.[1] as Record<string, unknown> | undefined;
|
||||||
|
expect(codexTokenFetchInit).toEqual(expect.objectContaining({
|
||||||
|
dispatcher: expect.anything(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('marks oauth session as error and avoids creating a connection when manual codex callback model discovery fails', async () => {
|
it('marks oauth session as error and avoids creating a connection when manual codex callback model discovery fails', async () => {
|
||||||
const jwt = buildJwt({
|
const jwt = buildJwt({
|
||||||
email: 'codex-fail@example.com',
|
email: 'codex-fail@example.com',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
|
import { withExplicitProxyRequestInit } from '../siteProxy.js';
|
||||||
import type { OAuthProviderDefinition } from './providers.js';
|
import type { OAuthProviderDefinition } from './providers.js';
|
||||||
|
|
||||||
export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity';
|
export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity';
|
||||||
@@ -110,10 +111,11 @@ async function callAntigravityInternalApi<T>(
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
method: 'loadCodeAssist' | 'onboardUser',
|
method: 'loadCodeAssist' | 'onboardUser',
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
|
proxyUrl?: string | null,
|
||||||
): Promise<T | undefined> {
|
): Promise<T | undefined> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`,
|
`${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`,
|
||||||
{
|
withExplicitProxyRequestInit(proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
@@ -124,21 +126,24 @@ async function callAntigravityInternalApi<T>(
|
|||||||
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postAntigravityToken(body: URLSearchParams) {
|
async function postAntigravityToken(
|
||||||
const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
|
body: URLSearchParams,
|
||||||
|
proxyUrl?: string | null,
|
||||||
|
) {
|
||||||
|
const response = await fetch(ANTIGRAVITY_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
throw new Error(text || `antigravity token exchange failed with status ${response.status}`);
|
throw new Error(text || `antigravity token exchange failed with status ${response.status}`);
|
||||||
@@ -159,24 +164,31 @@ async function postAntigravityToken(body: URLSearchParams) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAntigravityUserEmail(accessToken: string): Promise<string | undefined> {
|
async function fetchAntigravityUserEmail(
|
||||||
const response = await fetch(ANTIGRAVITY_USERINFO_URL, {
|
accessToken: string,
|
||||||
|
proxyUrl?: string | null,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const response = await fetch(ANTIGRAVITY_USERINFO_URL, withExplicitProxyRequestInit(proxyUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
const payload = await response.json() as { email?: unknown };
|
const payload = await response.json() as { email?: unknown };
|
||||||
return asTrimmedString(payload.email);
|
return asTrimmedString(payload.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> {
|
async function fetchAntigravityProjectId(
|
||||||
|
accessToken: string,
|
||||||
|
proxyUrl?: string | null,
|
||||||
|
): Promise<string | undefined> {
|
||||||
const metadata = buildAntigravityMetadata();
|
const metadata = buildAntigravityMetadata();
|
||||||
const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>(
|
const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>(
|
||||||
accessToken,
|
accessToken,
|
||||||
'loadCodeAssist',
|
'loadCodeAssist',
|
||||||
{ metadata },
|
{ metadata },
|
||||||
|
proxyUrl,
|
||||||
);
|
);
|
||||||
if (!payload) return undefined;
|
if (!payload) return undefined;
|
||||||
|
|
||||||
@@ -194,6 +206,7 @@ async function fetchAntigravityProjectId(accessToken: string): Promise<string |
|
|||||||
tierId,
|
tierId,
|
||||||
metadata,
|
metadata,
|
||||||
},
|
},
|
||||||
|
proxyUrl,
|
||||||
);
|
);
|
||||||
if (!onboardPayload) return undefined;
|
if (!onboardPayload) return undefined;
|
||||||
if (onboardPayload.done === true) {
|
if (onboardPayload.done === true) {
|
||||||
@@ -249,16 +262,16 @@ export const antigravityOauthProvider: OAuthProviderDefinition = {
|
|||||||
});
|
});
|
||||||
return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`;
|
return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`;
|
||||||
},
|
},
|
||||||
exchangeAuthorizationCode: async ({ code, redirectUri }) => {
|
exchangeAuthorizationCode: async ({ code, redirectUri, proxyUrl }) => {
|
||||||
const token = await postAntigravityToken(new URLSearchParams({
|
const token = await postAntigravityToken(new URLSearchParams({
|
||||||
code,
|
code,
|
||||||
client_id: ANTIGRAVITY_CLIENT_ID,
|
client_id: ANTIGRAVITY_CLIENT_ID,
|
||||||
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
}));
|
}), proxyUrl);
|
||||||
const email = await fetchAntigravityUserEmail(token.accessToken);
|
const email = await fetchAntigravityUserEmail(token.accessToken, proxyUrl);
|
||||||
const projectId = await fetchAntigravityProjectId(token.accessToken);
|
const projectId = await fetchAntigravityProjectId(token.accessToken, proxyUrl);
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
email,
|
email,
|
||||||
@@ -267,20 +280,20 @@ export const antigravityOauthProvider: OAuthProviderDefinition = {
|
|||||||
projectId,
|
projectId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
refreshAccessToken: async ({ refreshToken, oauth }) => {
|
refreshAccessToken: async ({ refreshToken, oauth, proxyUrl }) => {
|
||||||
const token = await postAntigravityToken(new URLSearchParams({
|
const token = await postAntigravityToken(new URLSearchParams({
|
||||||
client_id: ANTIGRAVITY_CLIENT_ID,
|
client_id: ANTIGRAVITY_CLIENT_ID,
|
||||||
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
}));
|
}), proxyUrl);
|
||||||
const email = await fetchAntigravityUserEmail(token.accessToken);
|
const email = await fetchAntigravityUserEmail(token.accessToken, proxyUrl);
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
email,
|
email,
|
||||||
accountKey: email,
|
accountKey: email,
|
||||||
accountId: email,
|
accountId: email,
|
||||||
projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken),
|
projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken, proxyUrl),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
buildProxyHeaders: () => ({
|
buildProxyHeaders: () => ({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
|
import { withExplicitProxyRequestInit } from '../siteProxy.js';
|
||||||
import { createPkceChallenge } from './sessionStore.js';
|
import { createPkceChallenge } from './sessionStore.js';
|
||||||
import type { OAuthProviderDefinition } from './providers.js';
|
import type { OAuthProviderDefinition } from './providers.js';
|
||||||
|
|
||||||
@@ -77,15 +78,18 @@ function parseClaudeTokenPayload(payload: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postClaudeToken(body: Record<string, unknown>) {
|
async function postClaudeToken(
|
||||||
const response = await fetch(CLAUDE_TOKEN_URL, {
|
body: Record<string, unknown>,
|
||||||
|
proxyUrl?: string | null,
|
||||||
|
) {
|
||||||
|
const response = await fetch(CLAUDE_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
throw new Error(text || `claude token exchange failed with status ${response.status}`);
|
throw new Error(text || `claude token exchange failed with status ${response.status}`);
|
||||||
@@ -129,7 +133,7 @@ export const claudeOauthProvider: OAuthProviderDefinition = {
|
|||||||
});
|
});
|
||||||
return `${CLAUDE_AUTH_URL}?${params.toString()}`;
|
return `${CLAUDE_AUTH_URL}?${params.toString()}`;
|
||||||
},
|
},
|
||||||
exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier }) => {
|
exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier, proxyUrl }) => {
|
||||||
return postClaudeToken({
|
return postClaudeToken({
|
||||||
code,
|
code,
|
||||||
state,
|
state,
|
||||||
@@ -137,14 +141,14 @@ export const claudeOauthProvider: OAuthProviderDefinition = {
|
|||||||
client_id: requireClaudeClientId(),
|
client_id: requireClaudeClientId(),
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
});
|
}, proxyUrl);
|
||||||
},
|
},
|
||||||
refreshAccessToken: async ({ refreshToken }) => {
|
refreshAccessToken: async ({ refreshToken, proxyUrl }) => {
|
||||||
return postClaudeToken({
|
return postClaudeToken({
|
||||||
client_id: requireClaudeClientId(),
|
client_id: requireClaudeClientId(),
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
}, proxyUrl);
|
||||||
},
|
},
|
||||||
buildProxyHeaders: () => ({
|
buildProxyHeaders: () => ({
|
||||||
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
|
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
|
import { withExplicitProxyRequestInit } from '../siteProxy.js';
|
||||||
import { createPkceChallenge } from './sessionStore.js';
|
import { createPkceChallenge } from './sessionStore.js';
|
||||||
import type { OAuthProviderDefinition } from './providers.js';
|
import type { OAuthProviderDefinition } from './providers.js';
|
||||||
|
|
||||||
@@ -106,15 +107,18 @@ function parseTokenResponsePayload(payload: unknown): CodexTokenExchangeResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exchangeCodexToken(form: URLSearchParams): Promise<CodexTokenExchangeResult> {
|
async function exchangeCodexToken(
|
||||||
const response = await fetch(CODEX_TOKEN_URL, {
|
form: URLSearchParams,
|
||||||
|
proxyUrl?: string | null,
|
||||||
|
): Promise<CodexTokenExchangeResult> {
|
||||||
|
const response = await fetch(CODEX_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
body: form.toString(),
|
body: form.toString(),
|
||||||
});
|
}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
throw new Error(text || `codex token exchange failed with status ${response.status}`);
|
throw new Error(text || `codex token exchange failed with status ${response.status}`);
|
||||||
@@ -126,6 +130,7 @@ export async function exchangeCodexAuthorizationCode(input: {
|
|||||||
code: string;
|
code: string;
|
||||||
codeVerifier: string;
|
codeVerifier: string;
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
|
proxyUrl?: string | null;
|
||||||
}): Promise<CodexTokenExchangeResult> {
|
}): Promise<CodexTokenExchangeResult> {
|
||||||
return exchangeCodexToken(new URLSearchParams({
|
return exchangeCodexToken(new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
@@ -133,16 +138,19 @@ export async function exchangeCodexAuthorizationCode(input: {
|
|||||||
code: input.code,
|
code: input.code,
|
||||||
redirect_uri: input.redirectUri,
|
redirect_uri: input.redirectUri,
|
||||||
code_verifier: input.codeVerifier,
|
code_verifier: input.codeVerifier,
|
||||||
}));
|
}), input.proxyUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshCodexTokens(refreshToken: string): Promise<CodexTokenExchangeResult> {
|
export async function refreshCodexTokens(
|
||||||
|
refreshToken: string,
|
||||||
|
proxyUrl?: string | null,
|
||||||
|
): Promise<CodexTokenExchangeResult> {
|
||||||
return exchangeCodexToken(new URLSearchParams({
|
return exchangeCodexToken(new URLSearchParams({
|
||||||
client_id: requireCodexClientId(),
|
client_id: requireCodexClientId(),
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope: 'openid profile email',
|
scope: 'openid profile email',
|
||||||
}));
|
}), proxyUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined {
|
function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||||
@@ -193,11 +201,12 @@ export const codexOauthProvider: OAuthProviderDefinition = {
|
|||||||
redirectUri,
|
redirectUri,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
}),
|
}),
|
||||||
exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier }) => {
|
exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier, proxyUrl }) => {
|
||||||
const exchange = await exchangeCodexAuthorizationCode({
|
const exchange = await exchangeCodexAuthorizationCode({
|
||||||
code,
|
code,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
|
proxyUrl,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
accessToken: exchange.accessToken,
|
accessToken: exchange.accessToken,
|
||||||
@@ -210,8 +219,8 @@ export const codexOauthProvider: OAuthProviderDefinition = {
|
|||||||
idToken: exchange.idToken,
|
idToken: exchange.idToken,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
refreshAccessToken: async ({ refreshToken }) => {
|
refreshAccessToken: async ({ refreshToken, proxyUrl }) => {
|
||||||
const refreshed = await refreshCodexTokens(refreshToken);
|
const refreshed = await refreshCodexTokens(refreshToken, proxyUrl);
|
||||||
return {
|
return {
|
||||||
accessToken: refreshed.accessToken,
|
accessToken: refreshed.accessToken,
|
||||||
refreshToken: refreshed.refreshToken,
|
refreshToken: refreshed.refreshToken,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
|
import { withExplicitProxyRequestInit } from '../siteProxy.js';
|
||||||
import type { OAuthProviderDefinition } from './providers.js';
|
import type { OAuthProviderDefinition } from './providers.js';
|
||||||
|
|
||||||
export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli';
|
export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli';
|
||||||
@@ -147,15 +148,18 @@ async function sleep(ms: number): Promise<void> {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postGeminiToken(body: URLSearchParams) {
|
async function postGeminiToken(
|
||||||
const response = await fetch(GEMINI_CLI_TOKEN_URL, {
|
body: URLSearchParams,
|
||||||
|
proxyUrl?: string | null,
|
||||||
|
) {
|
||||||
|
const response = await fetch(GEMINI_CLI_TOKEN_URL, withExplicitProxyRequestInit(proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
throw new Error(text || `gemini token exchange failed with status ${response.status}`);
|
throw new Error(text || `gemini token exchange failed with status ${response.status}`);
|
||||||
@@ -176,13 +180,16 @@ async function postGeminiToken(body: URLSearchParams) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchGeminiUserEmail(accessToken: string): Promise<string | undefined> {
|
async function fetchGeminiUserEmail(
|
||||||
const response = await fetch(GEMINI_CLI_USERINFO_URL, {
|
accessToken: string,
|
||||||
|
proxyUrl?: string | null,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const response = await fetch(GEMINI_CLI_USERINFO_URL, withExplicitProxyRequestInit(proxyUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
const payload = await response.json() as { email?: unknown };
|
const payload = await response.json() as { email?: unknown };
|
||||||
return asTrimmedString(payload.email);
|
return asTrimmedString(payload.email);
|
||||||
@@ -192,10 +199,11 @@ async function callGeminiCliInternalApi<T>(
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
method: 'loadCodeAssist' | 'onboardUser',
|
method: 'loadCodeAssist' | 'onboardUser',
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
|
proxyUrl?: string | null,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`,
|
`${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`,
|
||||||
{
|
withExplicitProxyRequestInit(proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
@@ -205,7 +213,7 @@ async function callGeminiCliInternalApi<T>(
|
|||||||
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
@@ -214,13 +222,13 @@ async function callGeminiCliInternalApi<T>(
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchGcpProjects(accessToken: string): Promise<string[]> {
|
async function fetchGcpProjects(accessToken: string, proxyUrl?: string | null): Promise<string[]> {
|
||||||
const response = await fetch(GEMINI_CLI_PROJECTS_URL, {
|
const response = await fetch(GEMINI_CLI_PROJECTS_URL, withExplicitProxyRequestInit(proxyUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
throw new Error(text || `project list request failed with status ${response.status}`);
|
throw new Error(text || `project list request failed with status ${response.status}`);
|
||||||
@@ -231,17 +239,21 @@ async function fetchGcpProjects(accessToken: string): Promise<string[]> {
|
|||||||
.filter((projectId): projectId is string => !!projectId);
|
.filter((projectId): projectId is string => !!projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise<void> {
|
async function checkCloudAIAPIEnabled(
|
||||||
|
accessToken: string,
|
||||||
|
projectId: string,
|
||||||
|
proxyUrl?: string | null,
|
||||||
|
): Promise<void> {
|
||||||
const checkResponse = await fetch(
|
const checkResponse = await fetch(
|
||||||
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
|
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
|
||||||
{
|
withExplicitProxyRequestInit(proxyUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'User-Agent': GEMINI_CLI_USER_AGENT,
|
'User-Agent': GEMINI_CLI_USER_AGENT,
|
||||||
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
if (checkResponse.ok) {
|
if (checkResponse.ok) {
|
||||||
const payload = await checkResponse.json() as { state?: unknown };
|
const payload = await checkResponse.json() as { state?: unknown };
|
||||||
@@ -253,7 +265,7 @@ async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): P
|
|||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`,
|
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`,
|
||||||
{
|
withExplicitProxyRequestInit(proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
@@ -263,7 +275,7 @@ async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): P
|
|||||||
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||||
},
|
},
|
||||||
body: '{}',
|
body: '{}',
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -278,6 +290,7 @@ async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): P
|
|||||||
async function performGeminiCliSetup(
|
async function performGeminiCliSetup(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
requestedProjectId: string,
|
requestedProjectId: string,
|
||||||
|
proxyUrl?: string | null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const trimmedRequest = requestedProjectId.trim();
|
const trimmedRequest = requestedProjectId.trim();
|
||||||
const explicitProject = !!trimmedRequest;
|
const explicitProject = !!trimmedRequest;
|
||||||
@@ -291,6 +304,7 @@ async function performGeminiCliSetup(
|
|||||||
cloudaicompanionProject: trimmedRequest,
|
cloudaicompanionProject: trimmedRequest,
|
||||||
}
|
}
|
||||||
: { metadata },
|
: { metadata },
|
||||||
|
proxyUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tierId = extractGeminiDefaultTierId(loadResponse);
|
const tierId = extractGeminiDefaultTierId(loadResponse);
|
||||||
@@ -305,6 +319,7 @@ async function performGeminiCliSetup(
|
|||||||
tierId,
|
tierId,
|
||||||
metadata,
|
metadata,
|
||||||
},
|
},
|
||||||
|
proxyUrl,
|
||||||
);
|
);
|
||||||
if (onboardResponse.done === true) {
|
if (onboardResponse.done === true) {
|
||||||
const response = (
|
const response = (
|
||||||
@@ -337,6 +352,7 @@ async function performGeminiCliSetup(
|
|||||||
metadata,
|
metadata,
|
||||||
cloudaicompanionProject: projectId,
|
cloudaicompanionProject: projectId,
|
||||||
},
|
},
|
||||||
|
proxyUrl,
|
||||||
);
|
);
|
||||||
if (onboardResponse.done === true) {
|
if (onboardResponse.done === true) {
|
||||||
const response = (
|
const response = (
|
||||||
@@ -372,18 +388,19 @@ async function performGeminiCliSetup(
|
|||||||
async function ensureGeminiProjectAndOnboard(
|
async function ensureGeminiProjectAndOnboard(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
requestedProjectId?: string,
|
requestedProjectId?: string,
|
||||||
|
proxyUrl?: string | null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const explicitProject = asTrimmedString(requestedProjectId);
|
const explicitProject = asTrimmedString(requestedProjectId);
|
||||||
if (explicitProject) {
|
if (explicitProject) {
|
||||||
return performGeminiCliSetup(accessToken, explicitProject);
|
return performGeminiCliSetup(accessToken, explicitProject, proxyUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await fetchGcpProjects(accessToken);
|
const projects = await fetchGcpProjects(accessToken, proxyUrl);
|
||||||
const firstProject = projects[0];
|
const firstProject = projects[0];
|
||||||
if (!firstProject) {
|
if (!firstProject) {
|
||||||
throw new Error('no Google Cloud projects available for this account');
|
throw new Error('no Google Cloud projects available for this account');
|
||||||
}
|
}
|
||||||
return performGeminiCliSetup(accessToken, firstProject);
|
return performGeminiCliSetup(accessToken, firstProject, proxyUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
||||||
@@ -422,7 +439,7 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
|||||||
});
|
});
|
||||||
return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`;
|
return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`;
|
||||||
},
|
},
|
||||||
exchangeAuthorizationCode: async ({ code, redirectUri, projectId }) => {
|
exchangeAuthorizationCode: async ({ code, redirectUri, projectId, proxyUrl }) => {
|
||||||
const oauthConfig = requireGeminiCliOAuthConfig();
|
const oauthConfig = requireGeminiCliOAuthConfig();
|
||||||
const token = await postGeminiToken(new URLSearchParams({
|
const token = await postGeminiToken(new URLSearchParams({
|
||||||
code,
|
code,
|
||||||
@@ -430,10 +447,10 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
|||||||
client_secret: oauthConfig.clientSecret,
|
client_secret: oauthConfig.clientSecret,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
}));
|
}), proxyUrl);
|
||||||
const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId);
|
const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId, proxyUrl);
|
||||||
await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId);
|
await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId, proxyUrl);
|
||||||
const email = await fetchGeminiUserEmail(token.accessToken);
|
const email = await fetchGeminiUserEmail(token.accessToken, proxyUrl);
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
email,
|
email,
|
||||||
@@ -442,19 +459,19 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
|||||||
projectId: resolvedProjectId,
|
projectId: resolvedProjectId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
refreshAccessToken: async ({ refreshToken, oauth }) => {
|
refreshAccessToken: async ({ refreshToken, oauth, proxyUrl }) => {
|
||||||
const oauthConfig = requireGeminiCliOAuthConfig();
|
const oauthConfig = requireGeminiCliOAuthConfig();
|
||||||
const token = await postGeminiToken(new URLSearchParams({
|
const token = await postGeminiToken(new URLSearchParams({
|
||||||
client_id: oauthConfig.clientId,
|
client_id: oauthConfig.clientId,
|
||||||
client_secret: oauthConfig.clientSecret,
|
client_secret: oauthConfig.clientSecret,
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
}));
|
}), proxyUrl);
|
||||||
const nextProjectId = oauth?.projectId
|
const nextProjectId = oauth?.projectId
|
||||||
? oauth.projectId
|
? oauth.projectId
|
||||||
: await ensureGeminiProjectAndOnboard(token.accessToken);
|
: await ensureGeminiProjectAndOnboard(token.accessToken, undefined, proxyUrl);
|
||||||
await checkCloudAIAPIEnabled(token.accessToken, nextProjectId);
|
await checkCloudAIAPIEnabled(token.accessToken, nextProjectId, proxyUrl);
|
||||||
const email = await fetchGeminiUserEmail(token.accessToken);
|
const email = await fetchGeminiUserEmail(token.accessToken, proxyUrl);
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
|
describe('oauth site registry', () => {
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-site-registry-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(schema.sites).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
if (dataDir) {
|
||||||
|
rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates missing oauth provider sites without duplicating existing rows', async () => {
|
||||||
|
await db.insert(schema.sites).values({
|
||||||
|
name: 'Anthropic Claude OAuth',
|
||||||
|
url: 'https://api.anthropic.com',
|
||||||
|
platform: 'claude',
|
||||||
|
status: 'active',
|
||||||
|
useSystemProxy: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const { ensureOauthProviderSitesExist } = await import('./oauthSiteRegistry.js');
|
||||||
|
await ensureOauthProviderSitesExist();
|
||||||
|
|
||||||
|
const rows = await db.select().from(schema.sites).all();
|
||||||
|
expect(rows.filter((row) => row.platform === 'codex')).toHaveLength(1);
|
||||||
|
expect(rows.filter((row) => row.platform === 'gemini-cli')).toHaveLength(1);
|
||||||
|
expect(rows.filter((row) => row.platform === 'antigravity')).toHaveLength(1);
|
||||||
|
expect(rows.filter((row) => row.platform === 'claude')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import { listOAuthProviderDefinitions, type OAuthProviderDefinition } from './providers.js';
|
||||||
|
|
||||||
|
function isUniqueConstraintError(error: unknown): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
return normalized.includes('unique')
|
||||||
|
|| normalized.includes('duplicate')
|
||||||
|
|| normalized.includes('constraint failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNextSiteSortOrder(): Promise<number> {
|
||||||
|
const row = await db.select({
|
||||||
|
maxSortOrder: sql<number>`COALESCE(MAX(${schema.sites.sortOrder}), -1)`,
|
||||||
|
}).from(schema.sites).get();
|
||||||
|
return (row?.maxSortOrder ?? -1) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureOauthProviderSite(definition: OAuthProviderDefinition) {
|
||||||
|
const existing = await db.select().from(schema.sites).where(and(
|
||||||
|
eq(schema.sites.platform, definition.site.platform),
|
||||||
|
eq(schema.sites.url, definition.site.url),
|
||||||
|
)).get();
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await db.insert(schema.sites).values({
|
||||||
|
name: definition.site.name,
|
||||||
|
url: definition.site.url,
|
||||||
|
platform: definition.site.platform,
|
||||||
|
status: 'active',
|
||||||
|
useSystemProxy: false,
|
||||||
|
isPinned: false,
|
||||||
|
globalWeight: 1,
|
||||||
|
sortOrder: await getNextSiteSortOrder(),
|
||||||
|
}).returning().get();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUniqueConstraintError(error)) throw error;
|
||||||
|
const recovered = await db.select().from(schema.sites).where(and(
|
||||||
|
eq(schema.sites.platform, definition.site.platform),
|
||||||
|
eq(schema.sites.url, definition.site.url),
|
||||||
|
)).get();
|
||||||
|
if (recovered) return recovered;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureOauthProviderSitesExist(): Promise<void> {
|
||||||
|
const definitions = listOAuthProviderDefinitions();
|
||||||
|
for (const definition of definitions) {
|
||||||
|
await ensureOauthProviderSite(definition);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ export interface OAuthProviderDefinition {
|
|||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
codeVerifier: string;
|
codeVerifier: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
proxyUrl?: string | null;
|
||||||
}): Promise<OAuthProviderExchangeResult>;
|
}): Promise<OAuthProviderExchangeResult>;
|
||||||
refreshAccessToken(input: {
|
refreshAccessToken(input: {
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
@@ -78,6 +79,7 @@ export interface OAuthProviderDefinition {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
providerData?: Record<string, unknown>;
|
providerData?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
proxyUrl?: string | null;
|
||||||
}): Promise<OAuthProviderRefreshResult>;
|
}): Promise<OAuthProviderRefreshResult>;
|
||||||
buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record<string, string>;
|
buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
listOAuthProviderDefinitions,
|
listOAuthProviderDefinitions,
|
||||||
type OAuthProviderDefinition,
|
type OAuthProviderDefinition,
|
||||||
} from './providers.js';
|
} from './providers.js';
|
||||||
|
import { ensureOauthProviderSite } from './oauthSiteRegistry.js';
|
||||||
import {
|
import {
|
||||||
buildOauthInfo,
|
buildOauthInfo,
|
||||||
buildOauthInfoFromAccount,
|
buildOauthInfoFromAccount,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
type OauthExtraConfigInput,
|
type OauthExtraConfigInput,
|
||||||
type OauthIdentityCarrierLike,
|
type OauthIdentityCarrierLike,
|
||||||
} from './codexAccount.js';
|
} from './codexAccount.js';
|
||||||
|
import { resolveOauthAccountProxyUrl, resolveOauthProviderProxyUrl } from './requestProxy.js';
|
||||||
import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js';
|
import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js';
|
||||||
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
||||||
|
|
||||||
@@ -140,30 +142,8 @@ async function getNextAccountSortOrder(): Promise<number> {
|
|||||||
return (row?.maxSortOrder ?? -1) + 1;
|
return (row?.maxSortOrder ?? -1) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNextSiteSortOrder(): Promise<number> {
|
|
||||||
const row = await db.select({
|
|
||||||
maxSortOrder: sql<number>`COALESCE(MAX(${schema.sites.sortOrder}), -1)`,
|
|
||||||
}).from(schema.sites).get();
|
|
||||||
return (row?.maxSortOrder ?? -1) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureOauthSite(definition: OAuthProviderDefinition) {
|
async function ensureOauthSite(definition: OAuthProviderDefinition) {
|
||||||
const existing = await db.select().from(schema.sites).where(and(
|
return ensureOauthProviderSite(definition);
|
||||||
eq(schema.sites.platform, definition.site.platform),
|
|
||||||
eq(schema.sites.url, definition.site.url),
|
|
||||||
)).get();
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
return db.insert(schema.sites).values({
|
|
||||||
name: definition.site.name,
|
|
||||||
url: definition.site.url,
|
|
||||||
platform: definition.site.platform,
|
|
||||||
status: 'active',
|
|
||||||
useSystemProxy: false,
|
|
||||||
isPinned: false,
|
|
||||||
globalWeight: 1,
|
|
||||||
sortOrder: await getNextSiteSortOrder(),
|
|
||||||
}).returning().get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findExistingOauthAccount(input: {
|
async function findExistingOauthAccount(input: {
|
||||||
@@ -375,12 +355,14 @@ export async function handleOauthCallback(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const proxyUrl = await resolveOauthProviderProxyUrl(input.provider);
|
||||||
const exchange = await definition.exchangeAuthorizationCode({
|
const exchange = await definition.exchangeAuthorizationCode({
|
||||||
code,
|
code,
|
||||||
state: input.state,
|
state: input.state,
|
||||||
redirectUri: session.redirectUri,
|
redirectUri: session.redirectUri,
|
||||||
codeVerifier: session.codeVerifier,
|
codeVerifier: session.codeVerifier,
|
||||||
projectId: session.projectId,
|
projectId: session.projectId,
|
||||||
|
proxyUrl,
|
||||||
});
|
});
|
||||||
const { account, site, created, previousAccount } = await upsertOauthAccount({
|
const { account, site, created, previousAccount } = await upsertOauthAccount({
|
||||||
definition,
|
definition,
|
||||||
@@ -655,6 +637,7 @@ export async function refreshOauthAccessToken(accountId: number) {
|
|||||||
projectId: oauth.projectId,
|
projectId: oauth.projectId,
|
||||||
providerData: oauth.providerData,
|
providerData: oauth.providerData,
|
||||||
},
|
},
|
||||||
|
proxyUrl: await resolveOauthAccountProxyUrl(account.siteId),
|
||||||
});
|
});
|
||||||
const nextOauth = buildOauthInfoFromAccount(account, {
|
const nextOauth = buildOauthInfoFromAccount(account, {
|
||||||
provider: oauth.provider,
|
provider: oauth.provider,
|
||||||
|
|||||||
Reference in New Issue
Block a user