feat: add multi-provider OAuth support for Claude and Gemini CLI

This commit is contained in:
ciat-777
2026-03-17 13:36:44 +08:00
parent 4e3dc92083
commit d8e44eb805
53 changed files with 3618 additions and 719 deletions
@@ -7,6 +7,8 @@ import {
mergeAccountExtraConfig,
normalizeCredentialMode,
resolvePlatformUserId,
requiresManagedAccountTokens,
supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js';
describe('accountExtraConfig', () => {
@@ -67,4 +69,40 @@ describe('accountExtraConfig', () => {
sub2apiAuth: { refreshToken: ' ' },
}))).toBeNull();
});
it('treats auto-mode api token connections as direct-account routable', () => {
expect(supportsDirectAccountRoutingConnection({
accessToken: '',
apiToken: 'sk-demo',
extraConfig: null,
})).toBe(true);
expect(requiresManagedAccountTokens({
accessToken: '',
apiToken: 'sk-demo',
extraConfig: null,
})).toBe(false);
});
it('treats oauth and session connections as non-managed-token direct routes only when intended', () => {
expect(supportsDirectAccountRoutingConnection({
accessToken: 'oauth-access-token',
apiToken: null,
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe(true);
expect(requiresManagedAccountTokens({
accessToken: 'oauth-access-token',
apiToken: null,
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe(false);
expect(supportsDirectAccountRoutingConnection({
accessToken: 'session-token',
apiToken: 'sk-default',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(false);
expect(requiresManagedAccountTokens({
accessToken: 'session-token',
apiToken: 'sk-default',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(true);
});
});
+10 -4
View File
@@ -102,19 +102,25 @@ function hasCredentialValue(value: string | null | undefined): boolean {
export function supportsDirectAccountRoutingConnection(account: DirectAccountRoutingInput): boolean {
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
if (hasOauthProvider(account.extraConfig)) {
return hasCredentialValue(account.accessToken) || hasCredentialValue(account.apiToken);
}
if (credentialMode === 'apikey') {
return hasCredentialValue(account.apiToken);
}
if (hasOauthProvider(account.extraConfig)) {
return hasCredentialValue(account.accessToken);
if (credentialMode === 'session') {
return false;
}
return false;
if (hasCredentialValue(account.accessToken)) return false;
return hasCredentialValue(account.apiToken);
}
export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean {
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
if (credentialMode === 'apikey') return false;
if (hasOauthProvider(account.extraConfig)) return false;
if (credentialMode === 'apikey') return false;
if (credentialMode === 'session') return true;
if (hasCredentialValue(account.apiToken) && !hasCredentialValue(account.accessToken)) return false;
return true;
}
+3
View File
@@ -215,6 +215,9 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
username,
accessToken: accountAccessToken,
apiToken,
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
balance: importedBalance,
balanceUsed: importedUsed,
quota: importedQuota > 0 ? importedQuota : importedBalance,
+234 -9
View File
@@ -19,12 +19,40 @@ import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
import { withSiteRecordProxyRequestInit } from './siteProxy.js';
import { buildCodexOauthInfo, getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js';
import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
import {
GEMINI_CLI_GOOGLE_API_CLIENT,
GEMINI_CLI_REQUIRED_SERVICE,
GEMINI_CLI_USER_AGENT,
} from './oauth/geminiCliProvider.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
const MODEL_REFRESH_BATCH_SIZE = 3;
const CODEX_MODELS_CLIENT_VERSION = '1.0.0';
const CLAUDE_STATIC_MODELS = [
'claude-haiku-4-5-20251001',
'claude-sonnet-4-5-20250929',
'claude-sonnet-4-6',
'claude-opus-4-6',
'claude-opus-4-5-20251101',
'claude-opus-4-1-20250805',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
'claude-3-7-sonnet-20250219',
'claude-3-5-haiku-20241022',
];
const GEMINI_CLI_STATIC_MODELS = [
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemini-3-pro-preview',
'gemini-3.1-pro-preview',
'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview',
];
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
@@ -156,16 +184,18 @@ function extractCodexModelIds(payload: unknown): string[] {
});
}
async function updateCodexOauthModelDiscoveryState(input: {
async function updateOauthModelDiscoveryState(input: {
account: typeof schema.accounts.$inferSelect;
checkedAt: string;
status: 'healthy' | 'abnormal';
lastModelSyncError?: string;
lastDiscoveredModels?: string[];
}) {
if (!isCodexPlatform(input.account)) return input.account.extraConfig || null;
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
if (!oauth) return input.account.extraConfig || null;
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
oauth: buildCodexOauthInfo(input.account.extraConfig, {
oauth: buildOauthInfo(input.account.extraConfig, {
provider: oauth.provider,
modelDiscoveryStatus: input.status,
lastModelSyncAt: input.checkedAt,
lastModelSyncError: input.lastModelSyncError,
@@ -176,7 +206,6 @@ async function updateCodexOauthModelDiscoveryState(input: {
extraConfig,
updatedAt: input.checkedAt,
}).where(eq(schema.accounts.id, input.account.id)).run();
input.account.extraConfig = extraConfig;
return extraConfig;
}
@@ -209,6 +238,65 @@ async function discoverCodexModelsFromCloud(input: {
return normalizeModels(extractCodexModelIds(await response.json()));
}
async function validateClaudeOauthConnection(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
}): Promise<void> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('claude oauth access token missing');
}
const response = await fetch(
`${input.site.url.replace(/\/+$/, '')}/v1/models`,
withSiteRecordProxyRequestInit(input.site, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
},
}),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'claude oauth validation failed'}`);
}
}
async function validateGeminiCliOauthConnection(input: {
account: typeof schema.accounts.$inferSelect;
}): Promise<void> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('gemini cli oauth access token missing');
}
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
const projectId = (oauth?.projectId || '').trim();
if (!projectId) {
throw new Error('gemini cli oauth project id missing');
}
const response = await fetch(
`https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
},
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`);
}
const payload = await response.json() as { state?: unknown };
if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') {
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
}
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
@@ -316,6 +404,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const account = row.accounts;
const site = row.sites;
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const adapter = getAdapter(site.platform);
const accountTokens = await db.select()
@@ -337,11 +426,11 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
}
if (!adapter || account.status !== 'active') {
if (account.status !== 'active') {
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
}
if (isCodexPlatform(account)) {
if (oauth?.provider === 'codex') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
@@ -363,7 +452,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
checkedAt,
})),
).run();
await updateCodexOauthModelDiscoveryState({
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
@@ -387,7 +476,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Codex 模型获取失败(${rawMessage})`;
await updateCodexOauthModelDiscoveryState({
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'abnormal',
@@ -411,6 +500,142 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
}
}
if (oauth?.provider === 'claude') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
await withTimeout(
() => validateClaudeOauthConnection({ site, account }),
MODEL_DISCOVERY_TIMEOUT_MS,
`claude oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
await db.insert(schema.modelAvailability).values(
CLAUDE_STATIC_MODELS.map((modelName) => ({
accountId,
modelName,
available: true,
latencyMs: Date.now() - startedAt,
checkedAt,
})),
).run();
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
lastDiscoveredModels: CLAUDE_STATIC_MODELS,
});
await setAccountRuntimeHealth(accountId, {
state: 'healthy',
reason: 'Claude OAuth 健康探测成功',
source: 'model-discovery',
checkedAt,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: CLAUDE_STATIC_MODELS.length,
modelsPreview: CLAUDE_STATIC_MODELS.slice(0, 10),
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'claude oauth validation failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Claude 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'abnormal',
lastModelSyncError: errorMessage,
lastDiscoveredModels: [],
});
await setAccountRuntimeHealth(account.id, {
state: 'unhealthy',
reason: errorMessage,
source: 'model-discovery',
checkedAt,
});
return buildFailedRefreshResult({
accountId,
errorCode,
errorMessage,
tokenScanned: 0,
discoveredByCredential: false,
discoveredApiToken: false,
});
}
}
if (oauth?.provider === 'gemini-cli') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
await withTimeout(
() => validateGeminiCliOauthConnection({ account }),
MODEL_DISCOVERY_TIMEOUT_MS,
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
await db.insert(schema.modelAvailability).values(
GEMINI_CLI_STATIC_MODELS.map((modelName) => ({
accountId,
modelName,
available: true,
latencyMs: Date.now() - startedAt,
checkedAt,
})),
).run();
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
lastDiscoveredModels: GEMINI_CLI_STATIC_MODELS,
});
await setAccountRuntimeHealth(accountId, {
state: 'healthy',
reason: 'Gemini CLI OAuth 健康探测成功',
source: 'model-discovery',
checkedAt,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: GEMINI_CLI_STATIC_MODELS.length,
modelsPreview: GEMINI_CLI_STATIC_MODELS.slice(0, 10),
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'gemini cli oauth validation failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Gemini CLI 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'abnormal',
lastModelSyncError: errorMessage,
lastDiscoveredModels: [],
});
await setAccountRuntimeHealth(account.id, {
state: 'unhealthy',
reason: errorMessage,
source: 'model-discovery',
checkedAt,
});
return buildFailedRefreshResult({
accountId,
errorCode,
errorMessage,
tokenScanned: 0,
discoveredByCredential: false,
discoveredApiToken: false,
});
}
}
if (!adapter) {
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let discoveredApiToken: string | null = null;
+152
View File
@@ -0,0 +1,152 @@
import { fetch } from 'undici';
import { config } from '../../config.js';
import { createPkceChallenge } from './sessionStore.js';
import type { OAuthProviderDefinition } from './providers.js';
export const CLAUDE_OAUTH_PROVIDER = 'claude';
export const CLAUDE_AUTH_URL = 'https://claude.ai/oauth/authorize';
export const CLAUDE_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token';
export const CLAUDE_CLIENT_ID = config.claudeClientId;
export const CLAUDE_LOOPBACK_CALLBACK_PORT = 54545;
export const CLAUDE_LOOPBACK_CALLBACK_PATH = '/callback';
export const CLAUDE_LOOPBACK_REDIRECT_URI = `http://localhost:${CLAUDE_LOOPBACK_CALLBACK_PORT}${CLAUDE_LOOPBACK_CALLBACK_PATH}`;
export const CLAUDE_UPSTREAM_BASE_URL = 'https://api.anthropic.com';
export const CLAUDE_DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
function requireClaudeClientId(): string {
if (!CLAUDE_CLIENT_ID) {
throw new Error('CLAUDE_CLIENT_ID is not configured');
}
return CLAUDE_CLIENT_ID;
}
type ClaudeTokenResponse = {
access_token?: unknown;
refresh_token?: unknown;
token_type?: unknown;
expires_in?: unknown;
organization?: {
uuid?: unknown;
name?: unknown;
};
account?: {
uuid?: unknown;
email_address?: unknown;
};
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseExpiresAt(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Date.now() + Math.trunc(value) * 1000;
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return Date.now() + parsed * 1000;
}
}
return undefined;
}
function parseClaudeTokenPayload(payload: unknown) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('claude token exchange returned invalid payload');
}
const body = payload as ClaudeTokenResponse;
const accessToken = asTrimmedString(body.access_token);
if (!accessToken) {
throw new Error('claude token exchange response missing access token');
}
return {
accessToken,
refreshToken: asTrimmedString(body.refresh_token),
tokenExpiresAt: parseExpiresAt(body.expires_in),
email: asTrimmedString(body.account?.email_address),
accountId: asTrimmedString(body.account?.uuid),
accountKey: asTrimmedString(body.account?.uuid) || asTrimmedString(body.account?.email_address),
providerData: {
organizationId: asTrimmedString(body.organization?.uuid),
organizationName: asTrimmedString(body.organization?.name),
},
};
}
async function postClaudeToken(body: Record<string, unknown>) {
const response = await fetch(CLAUDE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `claude token exchange failed with status ${response.status}`);
}
return parseClaudeTokenPayload(await response.json());
}
export const claudeOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: CLAUDE_OAUTH_PROVIDER,
label: 'Claude',
platform: 'claude',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
site: {
name: 'Anthropic Claude OAuth',
url: CLAUDE_UPSTREAM_BASE_URL,
platform: 'claude',
},
loopback: {
host: '127.0.0.1',
port: CLAUDE_LOOPBACK_CALLBACK_PORT,
path: CLAUDE_LOOPBACK_CALLBACK_PATH,
redirectUri: CLAUDE_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: async ({ state, redirectUri, codeVerifier }) => {
const params = new URLSearchParams({
code: 'true',
client_id: requireClaudeClientId(),
response_type: 'code',
redirect_uri: redirectUri,
scope: 'org:create_api_key user:profile user:inference',
code_challenge: await createPkceChallenge(codeVerifier),
code_challenge_method: 'S256',
state,
});
return `${CLAUDE_AUTH_URL}?${params.toString()}`;
},
exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier }) => {
return postClaudeToken({
code,
state,
grant_type: 'authorization_code',
client_id: requireClaudeClientId(),
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
},
refreshAccessToken: async ({ refreshToken }) => {
return postClaudeToken({
client_id: requireClaudeClientId(),
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
},
buildProxyHeaders: () => ({
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
}),
};
+15 -109
View File
@@ -1,124 +1,30 @@
import { schema } from '../../db/index.js';
import {
buildOauthInfo,
getOauthInfoFromExtraConfig,
isOauthProvider,
type OauthInfo,
} from './oauthAccount.js';
type ParsedOauthInfo = {
provider?: unknown;
accountId?: unknown;
email?: unknown;
planType?: unknown;
tokenExpiresAt?: unknown;
refreshToken?: unknown;
idToken?: unknown;
modelDiscoveryStatus?: unknown;
lastModelSyncAt?: unknown;
lastModelSyncError?: unknown;
lastDiscoveredModels?: unknown;
};
type ParsedExtraConfig = {
oauth?: ParsedOauthInfo;
};
export type CodexOauthInfo = {
export type CodexOauthInfo = OauthInfo & {
provider: 'codex';
accountId?: string;
email?: string;
planType?: string;
tokenExpiresAt?: number;
refreshToken?: string;
idToken?: string;
modelDiscoveryStatus?: 'healthy' | 'abnormal';
lastModelSyncAt?: string;
lastModelSyncError?: string;
lastDiscoveredModels?: string[];
};
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
if (!extraConfig) return {};
try {
const parsed = JSON.parse(extraConfig) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as ParsedExtraConfig;
} catch {
return {};
}
}
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function asPositiveInteger(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return undefined;
}
function asIsoDateTime(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Date.parse(trimmed);
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString();
}
function asStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const normalized = value
.map((item) => asTrimmedString(item))
.filter((item): item is string => !!item);
return normalized.length > 0 ? normalized : [];
}
function asModelDiscoveryStatus(value: unknown): 'healthy' | 'abnormal' | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === 'healthy') return 'healthy';
if (normalized === 'abnormal') return 'abnormal';
return undefined;
}
export function getCodexOauthInfoFromExtraConfig(extraConfig?: string | null): CodexOauthInfo | null {
const parsed = parseExtraConfig(extraConfig).oauth;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const provider = asTrimmedString(parsed.provider);
if (provider !== 'codex') return null;
return {
provider: 'codex',
accountId: asTrimmedString(parsed.accountId),
email: asTrimmedString(parsed.email),
planType: asTrimmedString(parsed.planType),
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
refreshToken: asTrimmedString(parsed.refreshToken),
idToken: asTrimmedString(parsed.idToken),
modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus),
lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt),
lastModelSyncError: asTrimmedString(parsed.lastModelSyncError),
lastDiscoveredModels: asStringArray(parsed.lastDiscoveredModels),
};
const oauth = getOauthInfoFromExtraConfig(extraConfig);
if (!oauth || oauth.provider !== 'codex') return null;
return oauth as CodexOauthInfo;
}
export function isCodexPlatform(account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined): boolean {
const extraConfig = typeof account === 'string' || account == null
? account
: account.extraConfig;
return getCodexOauthInfoFromExtraConfig(extraConfig)?.provider === 'codex';
export function isCodexPlatform(
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
): boolean {
return isOauthProvider(account, 'codex');
}
export function buildCodexOauthInfo(
extraConfig?: string | null,
patch: Partial<CodexOauthInfo> = {},
): CodexOauthInfo {
const current = getCodexOauthInfoFromExtraConfig(extraConfig);
return {
provider: 'codex',
...(current || {}),
...patch,
};
return buildOauthInfo(extraConfig, { provider: 'codex', ...patch }) as CodexOauthInfo;
}
+134 -7
View File
@@ -1,16 +1,51 @@
import { fetch } from 'undici';
import { config } from '../../config.js';
import { createPkceChallenge } from './sessionStore.js';
import type { OAuthProviderDefinition } from './providers.js';
export const CODEX_OAUTH_PROVIDER = 'codex';
export const CODEX_AUTH_URL = 'https://auth.openai.com/oauth/authorize';
export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
export const CODEX_CLIENT_ID = config.codexClientId;
export const CODEX_CALLBACK_PATH = '/api/oauth/callback/codex';
export const CODEX_LOOPBACK_CALLBACK_PATH = '/auth/callback';
export const CODEX_LOOPBACK_CALLBACK_PORT = 1455;
export const CODEX_LOOPBACK_REDIRECT_URI = `http://localhost:${CODEX_LOOPBACK_CALLBACK_PORT}${CODEX_LOOPBACK_CALLBACK_PATH}`;
export const CODEX_UPSTREAM_BASE_URL = 'https://chatgpt.com/backend-api/codex';
function requireCodexClientId(): string {
if (!CODEX_CLIENT_ID) {
throw new Error('CODEX_CLIENT_ID is not configured');
}
return CODEX_CLIENT_ID;
}
function normalizeOrigin(origin?: string): string | null {
if (typeof origin !== 'string') return null;
const trimmed = origin.trim().replace(/\/+$/, '');
return trimmed || null;
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]';
}
export function resolveCodexRedirectUri(requestOrigin?: string): string {
const origin = normalizeOrigin(requestOrigin);
if (!origin) return CODEX_LOOPBACK_REDIRECT_URI;
try {
const parsed = new URL(origin);
if (isLoopbackHostname(parsed.hostname)) {
return CODEX_LOOPBACK_REDIRECT_URI;
}
return `${origin}${CODEX_CALLBACK_PATH}`;
} catch {
return CODEX_LOOPBACK_REDIRECT_URI;
}
}
type CodexJwtClaims = {
email?: unknown;
'https://api.openai.com/auth'?: {
@@ -47,18 +82,18 @@ function parseJwtClaims(token: string): CodexJwtClaims | null {
}
}
export function buildCodexAuthorizationUrl(input: {
export async function buildCodexAuthorizationUrl(input: {
state: string;
redirectUri: string;
codeVerifier: string;
}): string {
}): Promise<string> {
const params = new URLSearchParams({
client_id: CODEX_CLIENT_ID,
client_id: requireCodexClientId(),
response_type: 'code',
redirect_uri: input.redirectUri,
scope: 'openid email profile offline_access',
state: input.state,
code_challenge: createPkceChallenge(input.codeVerifier),
code_challenge: await createPkceChallenge(input.codeVerifier),
code_challenge_method: 'S256',
prompt: 'login',
id_token_add_organizations: 'true',
@@ -116,7 +151,7 @@ export async function exchangeCodexAuthorizationCode(input: {
}): Promise<CodexTokenExchangeResult> {
return exchangeCodexToken(new URLSearchParams({
grant_type: 'authorization_code',
client_id: CODEX_CLIENT_ID,
client_id: requireCodexClientId(),
code: input.code,
redirect_uri: input.redirectUri,
code_verifier: input.codeVerifier,
@@ -125,9 +160,101 @@ export async function exchangeCodexAuthorizationCode(input: {
export async function refreshCodexTokens(refreshToken: string): Promise<CodexTokenExchangeResult> {
return exchangeCodexToken(new URLSearchParams({
client_id: CODEX_CLIENT_ID,
client_id: requireCodexClientId(),
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope: 'openid profile email',
}));
}
function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined {
if (!headers) return undefined;
const loweredKey = key.toLowerCase();
for (const [rawKey, rawValue] of Object.entries(headers)) {
if (rawKey.toLowerCase() !== loweredKey) continue;
if (typeof rawValue === 'string') {
const trimmed = rawValue.trim();
if (trimmed) return trimmed;
}
if (Array.isArray(rawValue)) {
for (const item of rawValue) {
if (typeof item !== 'string') continue;
const trimmed = item.trim();
if (trimmed) return trimmed;
}
}
}
return undefined;
}
export const codexOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: CODEX_OAUTH_PROVIDER,
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
site: {
name: 'ChatGPT Codex OAuth',
url: CODEX_UPSTREAM_BASE_URL,
platform: 'codex',
},
loopback: {
host: '127.0.0.1',
port: CODEX_LOOPBACK_CALLBACK_PORT,
path: CODEX_LOOPBACK_CALLBACK_PATH,
redirectUri: CODEX_LOOPBACK_REDIRECT_URI,
},
resolveRedirectUri: ({ requestOrigin }) => resolveCodexRedirectUri(requestOrigin),
buildAuthorizationUrl: ({ state, redirectUri, codeVerifier }) => buildCodexAuthorizationUrl({
state,
redirectUri,
codeVerifier,
}),
exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier }) => {
const exchange = await exchangeCodexAuthorizationCode({
code,
redirectUri,
codeVerifier,
});
return {
accessToken: exchange.accessToken,
refreshToken: exchange.refreshToken,
tokenExpiresAt: exchange.tokenExpiresAt,
email: exchange.email,
accountId: exchange.accountId,
accountKey: exchange.accountId,
planType: exchange.planType,
idToken: exchange.idToken,
};
},
refreshAccessToken: async ({ refreshToken }) => {
const refreshed = await refreshCodexTokens(refreshToken);
return {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
tokenExpiresAt: refreshed.tokenExpiresAt,
email: refreshed.email,
accountId: refreshed.accountId,
accountKey: refreshed.accountId,
planType: refreshed.planType,
idToken: refreshed.idToken,
};
},
buildProxyHeaders: ({ oauth, downstreamHeaders }) => {
const accountId = getHeaderValue(downstreamHeaders, 'chatgpt-account-id') || oauth.accountId || oauth.accountKey;
const originator = getHeaderValue(downstreamHeaders, 'originator') || 'codex_cli_rs';
const headers: Record<string, string> = {
Originator: originator,
};
if (accountId) {
headers['Chatgpt-Account-Id'] = accountId;
}
return headers;
},
};
@@ -0,0 +1,250 @@
import { fetch } from 'undici';
import { config } from '../../config.js';
import type { OAuthProviderDefinition } from './providers.js';
export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli';
export const GEMINI_CLI_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
export const GEMINI_CLI_TOKEN_URL = 'https://oauth2.googleapis.com/token';
export const GEMINI_CLI_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
export const GEMINI_CLI_PROJECTS_URL = 'https://cloudresourcemanager.googleapis.com/v1/projects';
export const GEMINI_CLI_SERVICE_USAGE_URL = 'https://serviceusage.googleapis.com/v1';
export const GEMINI_CLI_CLIENT_ID = config.geminiCliClientId;
export const GEMINI_CLI_CLIENT_SECRET = config.geminiCliClientSecret;
export const GEMINI_CLI_LOOPBACK_CALLBACK_PORT = 8085;
export const GEMINI_CLI_LOOPBACK_CALLBACK_PATH = '/oauth2callback';
export const GEMINI_CLI_LOOPBACK_REDIRECT_URI = `http://localhost:${GEMINI_CLI_LOOPBACK_CALLBACK_PORT}${GEMINI_CLI_LOOPBACK_CALLBACK_PATH}`;
export const GEMINI_CLI_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com';
export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0';
export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)';
export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com';
function requireGeminiCliOAuthConfig() {
if (!GEMINI_CLI_CLIENT_ID) {
throw new Error('GEMINI_CLI_CLIENT_ID is not configured');
}
if (!GEMINI_CLI_CLIENT_SECRET) {
throw new Error('GEMINI_CLI_CLIENT_SECRET is not configured');
}
return {
clientId: GEMINI_CLI_CLIENT_ID,
clientSecret: GEMINI_CLI_CLIENT_SECRET,
};
}
const GEMINI_CLI_SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
type GeminiOAuthTokenPayload = {
access_token?: unknown;
refresh_token?: unknown;
token_type?: unknown;
expires_in?: unknown;
scope?: unknown;
expiry?: unknown;
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseExpiresAt(payload: GeminiOAuthTokenPayload): number | undefined {
if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) {
return Date.now() + Math.trunc(payload.expires_in) * 1000;
}
if (typeof payload.expires_in === 'string') {
const parsed = Number.parseInt(payload.expires_in.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return Date.now() + parsed * 1000;
}
}
if (typeof payload.expiry === 'string') {
const parsed = Date.parse(payload.expiry);
if (!Number.isNaN(parsed)) return parsed;
}
return undefined;
}
async function postGeminiToken(body: URLSearchParams) {
const response = await fetch(GEMINI_CLI_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `gemini token exchange failed with status ${response.status}`);
}
const payload = await response.json() as GeminiOAuthTokenPayload;
const accessToken = asTrimmedString(payload.access_token);
if (!accessToken) {
throw new Error('gemini token exchange response missing access token');
}
return {
accessToken,
refreshToken: asTrimmedString(payload.refresh_token),
tokenExpiresAt: parseExpiresAt(payload),
providerData: {
tokenType: asTrimmedString(payload.token_type),
scope: asTrimmedString(payload.scope),
},
};
}
async function fetchGeminiUserEmail(accessToken: string): Promise<string | undefined> {
const response = await fetch(GEMINI_CLI_USERINFO_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) return undefined;
const payload = await response.json() as { email?: unknown };
return asTrimmedString(payload.email);
}
async function fetchGcpProjects(accessToken: string): Promise<string[]> {
const response = await fetch(GEMINI_CLI_PROJECTS_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `project list request failed with status ${response.status}`);
}
const payload = await response.json() as { projects?: Array<{ projectId?: unknown }> };
return (payload.projects || [])
.map((project) => asTrimmedString(project.projectId))
.filter((projectId): projectId is string => !!projectId);
}
async function ensureGeminiProjectEnabled(accessToken: string, projectId: string): Promise<void> {
const response = await fetch(
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `service usage lookup failed with status ${response.status}`);
}
const payload = await response.json() as { state?: unknown };
const state = asTrimmedString(payload.state);
if ((state || '').toUpperCase() !== 'ENABLED') {
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
}
}
async function resolveGeminiProjectId(accessToken: string, requestedProjectId?: string): Promise<string> {
const explicitProject = asTrimmedString(requestedProjectId);
if (explicitProject) {
await ensureGeminiProjectEnabled(accessToken, explicitProject);
return explicitProject;
}
const projects = await fetchGcpProjects(accessToken);
if (projects.length === 1 && projects[0]) {
await ensureGeminiProjectEnabled(accessToken, projects[0]);
return projects[0];
}
if (projects.length <= 0) {
throw new Error('no Google Cloud projects available for this account');
}
throw new Error('gemini cli: project selection required');
}
export const geminiCliOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: GEMINI_CLI_OAUTH_PROVIDER,
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
site: {
name: 'Google Gemini CLI OAuth',
url: GEMINI_CLI_UPSTREAM_BASE_URL,
platform: 'gemini-cli',
},
loopback: {
host: '127.0.0.1',
port: GEMINI_CLI_LOOPBACK_CALLBACK_PORT,
path: GEMINI_CLI_LOOPBACK_CALLBACK_PATH,
redirectUri: GEMINI_CLI_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: async ({ state, redirectUri }) => {
const oauthConfig = requireGeminiCliOAuthConfig();
const params = new URLSearchParams({
client_id: oauthConfig.clientId,
redirect_uri: redirectUri,
response_type: 'code',
access_type: 'offline',
prompt: 'consent',
scope: GEMINI_CLI_SCOPES.join(' '),
state,
});
return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`;
},
exchangeAuthorizationCode: async ({ code, redirectUri, projectId }) => {
const oauthConfig = requireGeminiCliOAuthConfig();
const token = await postGeminiToken(new URLSearchParams({
code,
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}));
const resolvedProjectId = await resolveGeminiProjectId(token.accessToken, projectId);
const email = await fetchGeminiUserEmail(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId: resolvedProjectId,
};
},
refreshAccessToken: async ({ refreshToken, oauth }) => {
const oauthConfig = requireGeminiCliOAuthConfig();
const token = await postGeminiToken(new URLSearchParams({
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}));
if (oauth?.projectId) {
await ensureGeminiProjectEnabled(token.accessToken, oauth.projectId);
}
const email = await fetchGeminiUserEmail(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId: oauth?.projectId,
};
},
buildProxyHeaders: () => ({
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
}),
};
@@ -1,19 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
startCodexLoopbackCallbackServer,
stopCodexLoopbackCallbackServer,
startOAuthLoopbackCallbackServer,
stopOAuthLoopbackCallbackServers,
} from './localCallbackServer.js';
describe('codex loopback callback server', () => {
describe('oauth loopback callback server', () => {
afterEach(async () => {
await stopCodexLoopbackCallbackServer();
await stopOAuthLoopbackCallbackServers();
});
it('accepts oauth callback requests and closes the popup on success', async () => {
it('accepts codex oauth callback requests and closes the popup on success', async () => {
const callbackHandler = vi.fn(async () => ({ accountId: 12, siteId: 34 }));
const started = await startCodexLoopbackCallbackServer({
host: '127.0.0.1',
port: 0,
const started = await startOAuthLoopbackCallbackServer('codex', {
callbackHandler,
});
@@ -30,20 +28,19 @@ describe('codex loopback callback server', () => {
expect(body).toContain('window.close()');
});
it('renders an error page when oauth completion fails', async () => {
it('renders a stable error page when oauth completion fails', async () => {
const callbackHandler = vi.fn(async () => {
throw new Error('oauth failed');
});
const started = await startCodexLoopbackCallbackServer({
host: '127.0.0.1',
port: 0,
const started = await startOAuthLoopbackCallbackServer('claude', {
callbackHandler,
});
const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`);
const response = await fetch(`${started.origin}/callback?state=test-state&code=test-code`);
const body = await response.text();
expect(response.status).toBe(500);
expect(body).toContain('oauth failed');
expect(body).toContain('OAuth authorization failed');
expect(body).not.toContain('oauth failed');
});
});
+155 -93
View File
@@ -1,39 +1,31 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { type AddressInfo } from 'node:net';
import {
CODEX_LOOPBACK_CALLBACK_PATH,
CODEX_LOOPBACK_CALLBACK_PORT,
CODEX_OAUTH_PROVIDER,
} from './codexProvider.js';
import { getOAuthProviderDefinition, listOAuthProviderDefinitions } from './providers.js';
import { handleOauthCallback } from './service.js';
type CallbackHandler = typeof handleOauthCallback;
type StartCodexLoopbackCallbackServerOptions = {
port?: number;
type StartOAuthLoopbackCallbackServerOptions = {
host?: string;
port?: number;
callbackHandler?: CallbackHandler;
};
export type CodexLoopbackCallbackServerState = {
export type OAuthLoopbackCallbackServerState = {
provider: string;
attempted: boolean;
ready: boolean;
host?: string;
port: number;
path: string;
origin: string;
redirectUri: string;
error?: string;
};
const DEFAULT_STATE: CodexLoopbackCallbackServerState = {
attempted: false,
ready: false,
port: CODEX_LOOPBACK_CALLBACK_PORT,
origin: `http://localhost:${CODEX_LOOPBACK_CALLBACK_PORT}`,
};
let callbackServer: Server | null = null;
let callbackServerState: CodexLoopbackCallbackServerState = { ...DEFAULT_STATE };
let startPromise: Promise<CodexLoopbackCallbackServerState> | null = null;
const servers = new Map<string, Server>();
const states = new Map<string, OAuthLoopbackCallbackServerState>();
const startPromises = new Map<string, Promise<OAuthLoopbackCallbackServerState>>();
function escapeHtml(value: string): string {
return value
@@ -50,7 +42,7 @@ function renderCompletionPage(message: string): string {
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Codex OAuth Callback</title>
<title>OAuth Callback</title>
</head>
<body>
<script>window.close();</script>
@@ -67,37 +59,6 @@ function respondHtml(response: ServerResponse, statusCode: number, message: stri
response.end(renderCompletionPage(message));
}
async function handleCallbackRequest(
request: IncomingMessage,
response: ServerResponse,
callbackHandler: CallbackHandler,
) {
if (request.method !== 'GET') {
response.writeHead(405, { Allow: 'GET' });
response.end('Method not allowed');
return;
}
const requestUrl = new URL(request.url || '/', 'http://localhost');
if (requestUrl.pathname !== CODEX_LOOPBACK_CALLBACK_PATH) {
response.writeHead(404);
response.end('Not found');
return;
}
try {
await callbackHandler({
provider: CODEX_OAUTH_PROVIDER,
state: requestUrl.searchParams.get('state') || '',
code: requestUrl.searchParams.get('code') || undefined,
error: requestUrl.searchParams.get('error') || undefined,
});
respondHtml(response, 200, 'OAuth authorization succeeded. You can close this window.');
} catch (error: any) {
respondHtml(response, 500, `OAuth authorization failed: ${error?.message || 'unknown error'}`);
}
}
function normalizeOrigin(host: string | undefined, port: number): string {
if (!host || host === '::' || host === '0.0.0.0') {
return `http://localhost:${port}`;
@@ -108,76 +69,177 @@ function normalizeOrigin(host: string | undefined, port: number): string {
return `http://${host}:${port}`;
}
export function getCodexLoopbackCallbackServerState(): CodexLoopbackCallbackServerState {
return { ...callbackServerState };
function createDefaultState(provider: string): OAuthLoopbackCallbackServerState {
const definition = getOAuthProviderDefinition(provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${provider}`);
}
return {
provider,
attempted: false,
ready: false,
host: definition.loopback.host,
port: definition.loopback.port,
path: definition.loopback.path,
origin: normalizeOrigin(definition.loopback.host, definition.loopback.port),
redirectUri: definition.loopback.redirectUri,
};
}
export async function startCodexLoopbackCallbackServer(
options: StartCodexLoopbackCallbackServerOptions = {},
): Promise<CodexLoopbackCallbackServerState> {
if (callbackServer) {
return getCodexLoopbackCallbackServerState();
async function handleCallbackRequest(
provider: string,
request: IncomingMessage,
response: ServerResponse,
callbackHandler: CallbackHandler,
) {
const definition = getOAuthProviderDefinition(provider);
if (!definition) {
response.writeHead(404);
response.end('Not found');
return;
}
if (startPromise) {
return startPromise;
if (request.method !== 'GET') {
response.writeHead(405, { Allow: 'GET' });
response.end('Method not allowed');
return;
}
const requestUrl = new URL(request.url || '/', 'http://localhost');
if (requestUrl.pathname !== definition.loopback.path) {
response.writeHead(404);
response.end('Not found');
return;
}
try {
await callbackHandler({
provider,
state: requestUrl.searchParams.get('state') || '',
code: requestUrl.searchParams.get('code') || undefined,
error: requestUrl.searchParams.get('error') || undefined,
});
respondHtml(response, 200, 'OAuth authorization succeeded. You can close this window.');
} catch {
respondHtml(response, 500, 'OAuth authorization failed. Return to metapi and review the server logs.');
}
}
export function getOAuthLoopbackCallbackServerState(provider: string): OAuthLoopbackCallbackServerState {
return { ...(states.get(provider) || createDefaultState(provider)) };
}
export function getOAuthLoopbackCallbackServerStates(): OAuthLoopbackCallbackServerState[] {
return listOAuthProviderDefinitions().map((provider) => getOAuthLoopbackCallbackServerState(provider.metadata.provider));
}
export async function startOAuthLoopbackCallbackServer(
provider: string,
options: StartOAuthLoopbackCallbackServerOptions = {},
): Promise<OAuthLoopbackCallbackServerState> {
const definition = getOAuthProviderDefinition(provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${provider}`);
}
if (servers.has(provider)) {
return getOAuthLoopbackCallbackServerState(provider);
}
const existingStart = startPromises.get(provider);
if (existingStart) {
return existingStart;
}
const callbackHandler = options.callbackHandler || handleOauthCallback;
const requestedPort = options.port ?? CODEX_LOOPBACK_CALLBACK_PORT;
const requestedHost = options.host;
startPromise = new Promise<CodexLoopbackCallbackServerState>((resolve, reject) => {
const host = options.host || definition.loopback.host;
const port = options.port ?? definition.loopback.port;
const { path, redirectUri } = definition.loopback;
const startPromise = new Promise<OAuthLoopbackCallbackServerState>((resolve, reject) => {
const server = createServer((request, response) => {
void handleCallbackRequest(request, response, callbackHandler);
void handleCallbackRequest(provider, request, response, callbackHandler);
});
const finalizeFailure = (error: Error) => {
callbackServerState = {
const failedState: OAuthLoopbackCallbackServerState = {
provider,
attempted: true,
ready: false,
host: requestedHost,
port: requestedPort,
origin: normalizeOrigin(requestedHost, requestedPort),
error: error.message || 'failed to start codex oauth callback server',
host,
port,
path,
origin: normalizeOrigin(host, port),
redirectUri,
error: error.message || `failed to start ${provider} oauth callback server`,
};
callbackServer = null;
states.set(provider, failedState);
servers.delete(provider);
reject(error);
};
server.once('error', (error) => {
finalizeFailure(error as Error);
});
const onStartupError = (error: Error) => {
finalizeFailure(error);
};
server.listen(requestedPort, requestedHost, () => {
server.removeAllListeners('error');
callbackServer = server;
server.once('error', onStartupError);
server.listen(port, host, () => {
server.off('error', onStartupError);
servers.set(provider, server);
const address = server.address() as AddressInfo | null;
const port = address?.port || requestedPort;
const host = address?.address || requestedHost;
callbackServerState = {
const listeningPort = address?.port || port;
const listeningHost = address?.address || host;
const nextState: OAuthLoopbackCallbackServerState = {
provider,
attempted: true,
ready: true,
host,
port,
origin: normalizeOrigin(host, port),
host: listeningHost,
port: listeningPort,
path,
origin: normalizeOrigin(listeningHost, listeningPort),
redirectUri,
};
resolve(getCodexLoopbackCallbackServerState());
states.set(provider, nextState);
resolve(getOAuthLoopbackCallbackServerState(provider));
});
}).finally(() => {
startPromise = null;
startPromises.delete(provider);
});
startPromises.set(provider, startPromise);
return startPromise;
}
export async function stopCodexLoopbackCallbackServer(): Promise<void> {
const server = callbackServer;
callbackServer = null;
callbackServerState = { ...DEFAULT_STATE };
if (!server) return;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
export async function startOAuthLoopbackCallbackServers(
options: StartOAuthLoopbackCallbackServerOptions = {},
): Promise<OAuthLoopbackCallbackServerState[]> {
const results = await Promise.allSettled(
listOAuthProviderDefinitions().map((provider) =>
startOAuthLoopbackCallbackServer(provider.metadata.provider, options)),
);
return results.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
return getOAuthLoopbackCallbackServerState(listOAuthProviderDefinitions()[index]!.metadata.provider);
});
}
export async function stopOAuthLoopbackCallbackServers(): Promise<void> {
const activeServers = Array.from(servers.entries());
servers.clear();
states.clear();
startPromises.clear();
await Promise.all(activeServers.map(async ([provider, server]) => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}).catch(() => undefined);
states.set(provider, createDefaultState(provider));
}));
}
export async function startCodexLoopbackCallbackServer(
options: StartOAuthLoopbackCallbackServerOptions = {},
): Promise<OAuthLoopbackCallbackServerState> {
return startOAuthLoopbackCallbackServer('codex', options);
}
export async function stopCodexLoopbackCallbackServer(): Promise<void> {
await stopOAuthLoopbackCallbackServers();
}
+157
View File
@@ -0,0 +1,157 @@
import { schema } from '../../db/index.js';
type ParsedOauthInfo = {
provider?: unknown;
accountId?: unknown;
accountKey?: unknown;
email?: unknown;
planType?: unknown;
projectId?: unknown;
tokenExpiresAt?: unknown;
refreshToken?: unknown;
idToken?: unknown;
providerData?: unknown;
modelDiscoveryStatus?: unknown;
lastModelSyncAt?: unknown;
lastModelSyncError?: unknown;
lastDiscoveredModels?: unknown;
};
type ParsedExtraConfig = {
oauth?: ParsedOauthInfo;
};
export type OauthModelDiscoveryStatus = 'healthy' | 'abnormal';
export type OauthInfo = {
provider: string;
accountId?: string;
accountKey?: string;
email?: string;
planType?: string;
projectId?: string;
tokenExpiresAt?: number;
refreshToken?: string;
idToken?: string;
providerData?: Record<string, unknown>;
modelDiscoveryStatus?: OauthModelDiscoveryStatus;
lastModelSyncAt?: string;
lastModelSyncError?: string;
lastDiscoveredModels?: string[];
};
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
if (!extraConfig) return {};
try {
const parsed = JSON.parse(extraConfig) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as ParsedExtraConfig;
} catch {
return {};
}
}
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function asPositiveInteger(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return undefined;
}
function asIsoDateTime(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Date.parse(trimmed);
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString();
}
function asStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
return value
.map((item) => asTrimmedString(item))
.filter((item): item is string => !!item);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === 'healthy') return 'healthy';
if (normalized === 'abnormal') return 'abnormal';
return undefined;
}
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
const parsed = parseExtraConfig(extraConfig).oauth;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const provider = asTrimmedString(parsed.provider);
if (!provider) return null;
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
return {
provider,
accountId: asTrimmedString(parsed.accountId) || accountKey,
accountKey,
email: asTrimmedString(parsed.email),
planType: asTrimmedString(parsed.planType),
projectId: asTrimmedString(parsed.projectId),
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
refreshToken: asTrimmedString(parsed.refreshToken),
idToken: asTrimmedString(parsed.idToken),
providerData: asRecord(parsed.providerData),
modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus),
lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt),
lastModelSyncError: asTrimmedString(parsed.lastModelSyncError),
lastDiscoveredModels: asStringArray(parsed.lastDiscoveredModels),
};
}
export function buildOauthInfo(
extraConfig?: string | null,
patch: Partial<OauthInfo> = {},
): OauthInfo {
const provider = patch.provider || getOauthInfoFromExtraConfig(extraConfig)?.provider;
if (!provider) {
throw new Error('oauth provider is required');
}
const current = getOauthInfoFromExtraConfig(extraConfig);
const next: OauthInfo = {
provider,
...(current || {}),
...patch,
};
if (!next.accountKey && next.accountId) {
next.accountKey = next.accountId;
}
if (!next.accountId && next.accountKey) {
next.accountId = next.accountKey;
}
return next;
}
export function isOauthProvider(
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
provider?: string,
): boolean {
const extraConfig = typeof account === 'string' || account == null
? account
: account.extraConfig;
const oauth = getOauthInfoFromExtraConfig(extraConfig);
if (!oauth) return false;
if (!provider) return true;
return oauth.provider === provider;
}
+98
View File
@@ -0,0 +1,98 @@
import { codexOauthProvider } from './codexProvider.js';
import { claudeOauthProvider } from './claudeProvider.js';
import { geminiCliOauthProvider } from './geminiCliProvider.js';
export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli';
export type OAuthProviderMetadata = {
provider: OAuthProviderId;
label: string;
platform: string;
enabled: boolean;
loginType: 'oauth';
requiresProjectId: boolean;
supportsDirectAccountRouting: boolean;
supportsCloudValidation: boolean;
supportsNativeProxy: boolean;
};
export type OAuthProviderExchangeResult = {
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: number;
email?: string;
accountKey?: string;
accountId?: string;
planType?: string;
projectId?: string;
idToken?: string;
providerData?: Record<string, unknown>;
};
export type OAuthProviderRefreshResult = OAuthProviderExchangeResult;
export type OAuthProviderProxyHeaderInput = {
oauth: {
provider: string;
accountKey?: string;
accountId?: string;
projectId?: string;
providerData?: Record<string, unknown>;
};
downstreamHeaders?: Record<string, unknown>;
};
export interface OAuthProviderDefinition {
metadata: OAuthProviderMetadata;
site: {
name: string;
url: string;
platform: string;
};
loopback: {
host: string;
port: number;
path: string;
redirectUri: string;
};
buildAuthorizationUrl(input: {
state: string;
redirectUri: string;
codeVerifier: string;
projectId?: string;
}): Promise<string>;
resolveRedirectUri?(input: {
requestOrigin?: string;
}): string;
exchangeAuthorizationCode(input: {
code: string;
state: string;
redirectUri: string;
codeVerifier: string;
projectId?: string;
}): Promise<OAuthProviderExchangeResult>;
refreshAccessToken(input: {
refreshToken: string;
oauth?: {
projectId?: string;
providerData?: Record<string, unknown>;
};
}): Promise<OAuthProviderRefreshResult>;
buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record<string, string>;
}
const PROVIDERS: OAuthProviderDefinition[] = [
codexOauthProvider,
claudeOauthProvider,
geminiCliOauthProvider,
];
const PROVIDER_BY_ID = new Map(PROVIDERS.map((provider) => [provider.metadata.provider, provider] as const));
export function listOAuthProviderDefinitions(): OAuthProviderDefinition[] {
return PROVIDERS.slice();
}
export function getOAuthProviderDefinition(provider: string): OAuthProviderDefinition | undefined {
return PROVIDER_BY_ID.get(provider as OAuthProviderId);
}
@@ -0,0 +1,16 @@
import { refreshOauthAccessToken } from './service.js';
const refreshInFlight = new Map<number, Promise<Awaited<ReturnType<typeof refreshOauthAccessToken>>>>();
export async function refreshOauthAccessTokenSingleflight(accountId: number) {
const existing = refreshInFlight.get(accountId);
if (existing) {
return existing;
}
const promise = refreshOauthAccessToken(accountId).finally(() => {
refreshInFlight.delete(accountId);
});
refreshInFlight.set(accountId, promise);
return promise;
}
+287 -183
View File
@@ -1,44 +1,23 @@
import { eq } from 'drizzle-orm';
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../modelService.js';
import {
buildCodexAuthorizationUrl,
CODEX_LOOPBACK_REDIRECT_URI,
CODEX_OAUTH_PROVIDER,
CODEX_UPSTREAM_BASE_URL,
exchangeCodexAuthorizationCode,
refreshCodexTokens,
type CodexTokenExchangeResult,
} from './codexProvider.js';
import {
createOauthSession,
getOauthSession,
markOauthSessionError,
markOauthSessionSuccess,
} from './sessionStore.js';
import { getCodexOauthInfoFromExtraConfig } from './codexAccount.js';
import { getCodexLoopbackCallbackServerState } from './localCallbackServer.js';
import { getOAuthLoopbackCallbackServerState } from './localCallbackServer.js';
import {
getOAuthProviderDefinition,
listOAuthProviderDefinitions,
type OAuthProviderDefinition,
} from './providers.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
import { buildCodexOauthInfo } from './codexAccount.js';
type OAuthProviderMetadata = {
provider: string;
label: string;
platform: string;
enabled: boolean;
loginType: 'oauth';
};
const OAUTH_PROVIDER_LIST: OAuthProviderMetadata[] = [
{
provider: CODEX_OAUTH_PROVIDER,
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
},
];
const CODEX_SITE_NAME = 'ChatGPT Codex OAuth';
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
function asNonEmptyString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
@@ -46,54 +25,39 @@ function asNonEmptyString(value: unknown): string | undefined {
return trimmed || undefined;
}
function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined {
if (!headers) return undefined;
const loweredKey = key.toLowerCase();
for (const [rawKey, rawValue] of Object.entries(headers)) {
if (rawKey.toLowerCase() !== loweredKey) continue;
if (typeof rawValue === 'string') {
const trimmed = rawValue.trim();
if (trimmed) return trimmed;
}
if (Array.isArray(rawValue)) {
for (const item of rawValue) {
if (typeof item !== 'string') continue;
const trimmed = item.trim();
if (trimmed) return trimmed;
}
}
}
return undefined;
}
function buildUsername(exchange: CodexTokenExchangeResult): string {
return exchange.email || exchange.accountId || 'codex-user';
function buildUsername(input: {
email?: string;
accountKey?: string;
provider: string;
}) {
return input.email || input.accountKey || `${input.provider}-user`;
}
async function getNextAccountSortOrder(): Promise<number> {
const rows = await db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
return max + 1;
const row = await db.select({
maxSortOrder: sql<number>`COALESCE(MAX(${schema.accounts.sortOrder}), -1)`,
}).from(schema.accounts).get();
return (row?.maxSortOrder ?? -1) + 1;
}
async function getNextSiteSortOrder(): Promise<number> {
const rows = await db.select({ sortOrder: schema.sites.sortOrder }).from(schema.sites).all();
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
return max + 1;
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 ensureCodexSite() {
const sites = await db.select().from(schema.sites).all();
const existing = sites.find((site) => (
String(site.platform || '').trim().toLowerCase() === 'codex'
&& String(site.url || '').trim() === CODEX_UPSTREAM_BASE_URL
));
async function ensureOauthSite(definition: OAuthProviderDefinition) {
const existing = await db.select().from(schema.sites).where(and(
eq(schema.sites.platform, definition.site.platform),
eq(schema.sites.url, definition.site.url),
)).get();
if (existing) return existing;
return db.insert(schema.sites).values({
name: CODEX_SITE_NAME,
url: CODEX_UPSTREAM_BASE_URL,
platform: 'codex',
name: definition.site.name,
url: definition.site.url,
platform: definition.site.platform,
status: 'active',
useSystemProxy: false,
isPinned: false,
@@ -102,55 +66,102 @@ async function ensureCodexSite() {
}).returning().get();
}
function matchesCodexIdentity(
account: typeof schema.accounts.$inferSelect,
exchange: CodexTokenExchangeResult,
): boolean {
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth || oauth.provider !== CODEX_OAUTH_PROVIDER) return false;
if (oauth.accountId && exchange.accountId) return oauth.accountId === exchange.accountId;
if (oauth.email && exchange.email) return oauth.email === exchange.email;
const username = asNonEmptyString(account.username);
if (username && exchange.email) return username === exchange.email;
return false;
}
async function findExistingCodexAccount(exchange: CodexTokenExchangeResult, rebindAccountId?: number) {
if (typeof rebindAccountId === 'number' && rebindAccountId > 0) {
async function findExistingOauthAccount(input: {
provider: string;
accountKey?: string;
email?: string;
projectId?: string;
rebindAccountId?: number;
}) {
if (typeof input.rebindAccountId === 'number' && input.rebindAccountId > 0) {
return db.select().from(schema.accounts)
.where(eq(schema.accounts.id, rebindAccountId))
.where(eq(schema.accounts.id, input.rebindAccountId))
.get();
}
const accounts = await db.select().from(schema.accounts).all();
return accounts.find((account) => matchesCodexIdentity(account, exchange)) || null;
const accountKey = asNonEmptyString(input.accountKey);
const email = asNonEmptyString(input.email);
const projectId = asNonEmptyString(input.projectId);
if (accountKey) {
const byKey = await db.select().from(schema.accounts).where(and(
eq(schema.accounts.oauthProvider, input.provider),
eq(schema.accounts.oauthAccountKey, accountKey),
projectId
? eq(schema.accounts.oauthProjectId, projectId)
: or(isNull(schema.accounts.oauthProjectId), eq(schema.accounts.oauthProjectId, '')),
)).get();
if (byKey) return byKey;
}
if (email) {
const byEmail = await db.select().from(schema.accounts).where(and(
eq(schema.accounts.oauthProvider, input.provider),
eq(schema.accounts.username, email),
)).get();
if (byEmail) return byEmail;
}
return null;
}
async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAccountId?: number) {
const site = await ensureCodexSite();
const existing = await findExistingCodexAccount(exchange, rebindAccountId);
const username = buildUsername(exchange);
async function upsertOauthAccount(input: {
definition: OAuthProviderDefinition;
exchange: {
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: number;
email?: string;
accountKey?: string;
accountId?: string;
planType?: string;
projectId?: string;
idToken?: string;
providerData?: Record<string, unknown>;
};
rebindAccountId?: number;
}) {
const site = await ensureOauthSite(input.definition);
const existing = await findExistingOauthAccount({
provider: input.definition.metadata.provider,
accountKey: input.exchange.accountKey || input.exchange.accountId,
email: input.exchange.email,
projectId: input.exchange.projectId,
rebindAccountId: input.rebindAccountId,
});
const username = buildUsername({
email: input.exchange.email,
accountKey: input.exchange.accountKey || input.exchange.accountId,
provider: input.definition.metadata.provider,
});
const oauth = buildOauthInfo(existing?.extraConfig, {
provider: input.definition.metadata.provider,
accountId: input.exchange.accountId || input.exchange.accountKey,
accountKey: input.exchange.accountKey || input.exchange.accountId,
email: input.exchange.email,
planType: input.exchange.planType,
projectId: input.exchange.projectId,
refreshToken: input.exchange.refreshToken,
tokenExpiresAt: input.exchange.tokenExpiresAt,
idToken: input.exchange.idToken,
providerData: input.exchange.providerData,
});
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
credentialMode: 'session',
oauth: {
provider: CODEX_OAUTH_PROVIDER,
accountId: exchange.accountId,
email: exchange.email,
planType: exchange.planType,
refreshToken: exchange.refreshToken,
idToken: exchange.idToken,
tokenExpiresAt: exchange.tokenExpiresAt,
},
oauth,
});
if (existing) {
await db.update(schema.accounts).set({
siteId: site.id,
username,
accessToken: exchange.accessToken,
accessToken: input.exchange.accessToken,
apiToken: null,
checkinEnabled: false,
status: 'active',
oauthProvider: input.definition.metadata.provider,
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
oauthProjectId: oauth.projectId || null,
extraConfig,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, existing.id)).run();
@@ -164,10 +175,13 @@ async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAcco
const created = await db.insert(schema.accounts).values({
siteId: site.id,
username,
accessToken: exchange.accessToken,
accessToken: input.exchange.accessToken,
apiToken: null,
checkinEnabled: false,
status: 'active',
oauthProvider: input.definition.metadata.provider,
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
oauthProjectId: oauth.projectId || null,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
@@ -176,34 +190,47 @@ async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAcco
}
export function listOauthProviders() {
return OAUTH_PROVIDER_LIST;
return listOAuthProviderDefinitions().map((definition) => {
const state = getOAuthLoopbackCallbackServerState(definition.metadata.provider);
return {
...definition.metadata,
enabled: state.ready || !state.attempted,
};
});
}
export function startOauthProviderFlow(input: {
export async function startOauthProviderFlow(input: {
provider: string;
redirectOrigin: string;
rebindAccountId?: number;
projectId?: string;
requestOrigin?: string;
}) {
if (input.provider !== CODEX_OAUTH_PROVIDER) {
const definition = getOAuthProviderDefinition(input.provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${input.provider}`);
}
const callbackServerState = getCodexLoopbackCallbackServerState();
if (callbackServerState.attempted && !callbackServerState.ready) {
throw new Error(`codex oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
const redirectUri = definition.resolveRedirectUri?.({
requestOrigin: input.requestOrigin,
}) || definition.loopback.redirectUri;
const callbackServerState = getOAuthLoopbackCallbackServerState(input.provider);
const usesLoopbackCallback = redirectUri === definition.loopback.redirectUri;
if (usesLoopbackCallback && callbackServerState.attempted && !callbackServerState.ready) {
throw new Error(`${input.provider} oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
}
const redirectUri = CODEX_LOOPBACK_REDIRECT_URI;
const session = createOauthSession({
provider: input.provider,
redirectUri,
rebindAccountId: input.rebindAccountId,
projectId: input.projectId,
});
return {
provider: input.provider,
state: session.state,
authorizationUrl: buildCodexAuthorizationUrl({
authorizationUrl: await definition.buildAuthorizationUrl({
state: session.state,
redirectUri,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
projectId: session.projectId,
}),
};
}
@@ -233,6 +260,11 @@ export async function handleOauthCallback(input: {
if (!session || session.provider !== input.provider) {
throw new Error('oauth session not found or provider mismatch');
}
const definition = getOAuthProviderDefinition(input.provider);
if (!definition) {
markOauthSessionError(input.state, `unsupported oauth provider: ${input.provider}`);
throw new Error(`unsupported oauth provider: ${input.provider}`);
}
if (input.error) {
markOauthSessionError(input.state, input.error);
throw new Error(input.error);
@@ -243,12 +275,18 @@ export async function handleOauthCallback(input: {
throw new Error('missing oauth code');
}
const exchange = await exchangeCodexAuthorizationCode({
const exchange = await definition.exchangeAuthorizationCode({
code,
codeVerifier: session.codeVerifier,
state: input.state,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
projectId: session.projectId,
});
const { account, site, created } = await upsertOauthAccount({
definition,
exchange,
rebindAccountId: session.rebindAccountId,
});
const { account, site, created } = await upsertCodexAccount(exchange, session.rebindAccountId) as Awaited<ReturnType<typeof upsertCodexAccount>> & { created: boolean };
if (!account) {
markOauthSessionError(input.state, 'failed to persist oauth account');
throw new Error('failed to persist oauth account');
@@ -260,7 +298,7 @@ export async function handleOauthCallback(input: {
await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run();
}
await rebuildTokenRoutesFromAvailability();
const errorMessage = refreshResult.errorMessage || 'codex model discovery failed';
const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`;
markOauthSessionError(input.state, errorMessage);
throw new Error(errorMessage);
}
@@ -273,15 +311,41 @@ export async function handleOauthCallback(input: {
return { accountId: account.id, siteId: site.id };
}
export async function listOauthConnections() {
export async function listOauthConnections(options: {
limit?: number;
offset?: number;
} = {}) {
const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)));
const offset = Math.max(0, Math.trunc(options.offset ?? 0));
const totalRow = await db.select({
count: sql<number>`COUNT(*)`,
}).from(schema.accounts)
.where(sql`${schema.accounts.oauthProvider} IS NOT NULL`)
.get();
const total = totalRow?.count ?? 0;
const rows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(sql`${schema.accounts.oauthProvider} IS NOT NULL`)
.orderBy(desc(schema.accounts.id))
.limit(limit)
.offset(offset)
.all();
const accountIds = rows.map((row) => row.accounts.id);
if (accountIds.length <= 0) {
return { items: [], total, limit, offset };
}
const modelRows = await db.select({
accountId: schema.modelAvailability.accountId,
modelName: schema.modelAvailability.modelName,
}).from(schema.modelAvailability)
.where(eq(schema.modelAvailability.available, true))
.where(and(
inArray(schema.modelAvailability.accountId, accountIds),
eq(schema.modelAvailability.available, true),
))
.all();
const modelMap = new Map<number, string[]>();
for (const row of modelRows) {
@@ -290,47 +354,52 @@ export async function listOauthConnections() {
list.push(row.modelName);
modelMap.set(row.accountId, list);
}
const routeChannelRows = await db.select({
accountId: schema.routeChannels.accountId,
}).from(schema.routeChannels).all();
count: sql<number>`COUNT(*)`,
}).from(schema.routeChannels)
.where(inArray(schema.routeChannels.accountId, accountIds))
.groupBy(schema.routeChannels.accountId)
.all();
const routeChannelCountByAccount = new Map<number, number>();
for (const row of routeChannelRows) {
const current = routeChannelCountByAccount.get(row.accountId) || 0;
routeChannelCountByAccount.set(row.accountId, current + 1);
routeChannelCountByAccount.set(row.accountId, row.count ?? 0);
}
return rows
.filter((row) => getCodexOauthInfoFromExtraConfig(row.accounts.extraConfig)?.provider === CODEX_OAUTH_PROVIDER)
.map((row) => {
const oauth = getCodexOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
const models = modelMap.get(row.accounts.id) || [];
const status = (
oauth.modelDiscoveryStatus === 'abnormal'
|| row.accounts.status !== 'active'
|| row.sites.status !== 'active'
) ? 'abnormal' : 'healthy';
return {
accountId: row.accounts.id,
siteId: row.sites.id,
provider: CODEX_OAUTH_PROVIDER,
username: row.accounts.username,
email: oauth.email,
accountKey: oauth.accountId,
planType: oauth.planType,
modelCount: models.length,
modelsPreview: models.slice(0, 10),
status,
routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0,
lastModelSyncAt: oauth.lastModelSyncAt,
lastModelSyncError: oauth.lastModelSyncError,
site: {
id: row.sites.id,
name: row.sites.name,
url: row.sites.url,
platform: row.sites.platform,
},
};
});
const items = rows.map((row) => {
const oauth = getOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
const models = modelMap.get(row.accounts.id) || [];
const status = (
oauth.modelDiscoveryStatus === 'abnormal'
|| row.accounts.status !== 'active'
|| row.sites.status !== 'active'
) ? 'abnormal' : 'healthy';
return {
accountId: row.accounts.id,
siteId: row.sites.id,
provider: oauth.provider,
username: row.accounts.username,
email: oauth.email,
accountKey: oauth.accountKey || oauth.accountId,
planType: oauth.planType,
projectId: oauth.projectId,
modelCount: models.length,
modelsPreview: models.slice(0, 10),
status,
routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0,
lastModelSyncAt: oauth.lastModelSyncAt,
lastModelSyncError: oauth.lastModelSyncError,
site: {
id: row.sites.id,
name: row.sites.name,
url: row.sites.url,
platform: row.sites.platform,
},
};
});
return { items, total, limit, offset };
}
export async function deleteOauthConnection(accountId: number) {
@@ -340,7 +409,7 @@ export async function deleteOauthConnection(accountId: number) {
if (!account) {
throw new Error('oauth account not found');
}
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
@@ -349,21 +418,36 @@ export async function deleteOauthConnection(accountId: number) {
return { success: true };
}
export async function startOauthRebindFlow(accountId: number, redirectOrigin: string) {
export async function startOauthRebindFlow(accountId: number, requestOrigin?: string) {
const account = await db.select().from(schema.accounts)
.where(eq(schema.accounts.id, accountId))
.get();
if (!account) {
throw new Error('oauth account not found');
}
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
return startOauthProviderFlow({
provider: oauth.provider,
redirectOrigin,
rebindAccountId: accountId,
projectId: oauth.projectId,
requestOrigin,
});
}
export function buildOauthProviderHeaders(input: {
extraConfig?: string | null;
downstreamHeaders?: Record<string, unknown>;
}) {
const oauth = getOauthInfoFromExtraConfig(input.extraConfig);
if (!oauth) return {};
const definition = getOAuthProviderDefinition(oauth.provider);
if (!definition?.buildProxyHeaders) return {};
return definition.buildProxyHeaders({
oauth,
downstreamHeaders: input.downstreamHeaders,
});
}
@@ -371,48 +455,62 @@ export function buildCodexOauthProviderHeaders(input: {
extraConfig?: string | null;
downstreamHeaders?: Record<string, unknown>;
}) {
const oauth = getCodexOauthInfoFromExtraConfig(input.extraConfig);
if (!oauth) return {};
const accountId = getHeaderValue(input.downstreamHeaders, 'chatgpt-account-id') || oauth.accountId;
const originator = getHeaderValue(input.downstreamHeaders, 'originator') || 'codex_cli_rs';
const headers: Record<string, string> = {
Originator: originator,
};
if (accountId) {
headers['Chatgpt-Account-Id'] = accountId;
}
return headers;
const oauth = buildCodexOauthInfo(input.extraConfig);
const definition = getOAuthProviderDefinition('codex');
return definition?.buildProxyHeaders?.({
oauth,
downstreamHeaders: input.downstreamHeaders,
}) || {};
}
export async function refreshCodexOauthAccessToken(accountId: number) {
export async function refreshOauthAccessToken(accountId: number) {
const account = await db.select().from(schema.accounts)
.where(eq(schema.accounts.id, accountId))
.get();
if (!account) {
throw new Error('codex oauth account not found');
throw new Error('oauth account not found');
}
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth?.refreshToken) {
throw new Error('codex oauth refresh token missing');
throw new Error('oauth refresh token missing');
}
const definition = getOAuthProviderDefinition(oauth.provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${oauth.provider}`);
}
const refreshed = await refreshCodexTokens(oauth.refreshToken);
const refreshed = await definition.refreshAccessToken({
refreshToken: oauth.refreshToken,
oauth: {
projectId: oauth.projectId,
providerData: oauth.providerData,
},
});
const nextOauth = buildOauthInfo(account.extraConfig, {
provider: oauth.provider,
accountId: refreshed.accountId || oauth.accountId,
accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId,
email: refreshed.email || oauth.email,
planType: refreshed.planType || oauth.planType,
projectId: refreshed.projectId || oauth.projectId,
refreshToken: refreshed.refreshToken || oauth.refreshToken,
tokenExpiresAt: refreshed.tokenExpiresAt || oauth.tokenExpiresAt,
idToken: refreshed.idToken || oauth.idToken,
providerData: {
...(oauth.providerData || {}),
...(refreshed.providerData || {}),
},
});
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
credentialMode: 'session',
oauth: {
provider: CODEX_OAUTH_PROVIDER,
accountId: refreshed.accountId || oauth.accountId,
email: refreshed.email || oauth.email,
planType: refreshed.planType || oauth.planType,
refreshToken: refreshed.refreshToken,
idToken: refreshed.idToken,
tokenExpiresAt: refreshed.tokenExpiresAt,
},
oauth: nextOauth,
});
await db.update(schema.accounts).set({
accessToken: refreshed.accessToken,
oauthProvider: oauth.provider,
oauthAccountKey: nextOauth.accountKey || nextOauth.accountId || null,
oauthProjectId: nextOauth.projectId || null,
extraConfig,
status: 'active',
updatedAt: new Date().toISOString(),
@@ -421,7 +519,13 @@ export async function refreshCodexOauthAccessToken(accountId: number) {
return {
accountId,
accessToken: refreshed.accessToken,
accountKey: refreshed.accountId || oauth.accountId,
accountKey: nextOauth.accountKey || nextOauth.accountId,
extraConfig,
};
}
export async function refreshCodexOauthAccessToken(accountId: number) {
return refreshOauthAccessToken(accountId);
}
export type { OAuthProviderMetadata };
+100 -54
View File
@@ -1,4 +1,4 @@
import { createHash, randomBytes } from 'node:crypto';
import { randomBytes, webcrypto } from 'node:crypto';
export type OAuthSessionStatus = 'pending' | 'success' | 'error';
@@ -15,10 +15,22 @@ export type OAuthSessionRecord = {
siteId?: number;
error?: string;
rebindAccountId?: number;
projectId?: string;
};
export interface OAuthSessionStore {
create(input: {
provider: string;
redirectUri: string;
rebindAccountId?: number;
projectId?: string;
}): OAuthSessionRecord;
get(state: string): OAuthSessionRecord | null;
markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null;
markError(state: string, error: string): OAuthSessionRecord | null;
}
const SESSION_TTL_MS = 10 * 60 * 1000;
const oauthSessions = new Map<string, OAuthSessionRecord>();
function nowIso(): string {
return new Date().toISOString();
@@ -28,79 +40,113 @@ function toBase64Url(input: Buffer): string {
return input.toString('base64url');
}
function pruneExpiredSessions(nowMs = Date.now()) {
for (const [state, session] of oauthSessions.entries()) {
if (Date.parse(session.expiresAt) <= nowMs) {
oauthSessions.delete(state);
}
}
}
function createPkceVerifier(): string {
return toBase64Url(randomBytes(48));
}
export function createPkceChallenge(codeVerifier: string): string {
return createHash('sha256').update(codeVerifier).digest('base64url');
export async function createPkceChallenge(codeVerifier: string): Promise<string> {
const digest = await webcrypto.subtle.digest('SHA-256', Buffer.from(codeVerifier, 'utf8'));
return Buffer.from(digest).toString('base64url');
}
class MemoryOAuthSessionStore implements OAuthSessionStore {
private readonly sessions = new Map<string, OAuthSessionRecord>();
private pruneExpiredSessions(nowMs = Date.now()) {
for (const [state, session] of this.sessions.entries()) {
if (Date.parse(session.expiresAt) <= nowMs) {
this.sessions.delete(state);
}
}
}
create(input: {
provider: string;
redirectUri: string;
rebindAccountId?: number;
projectId?: string;
}): OAuthSessionRecord {
this.pruneExpiredSessions();
const state = toBase64Url(randomBytes(24));
const codeVerifier = createPkceVerifier();
const createdAt = nowIso();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
const record: OAuthSessionRecord = {
provider: input.provider,
state,
status: 'pending',
codeVerifier,
redirectUri: input.redirectUri,
createdAt,
updatedAt: createdAt,
expiresAt,
rebindAccountId: input.rebindAccountId,
projectId: input.projectId,
};
this.sessions.set(state, record);
return record;
}
get(state: string): OAuthSessionRecord | null {
this.pruneExpiredSessions();
return this.sessions.get(state) || null;
}
markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null {
const existing = this.get(state);
if (!existing) return null;
const next: OAuthSessionRecord = {
...existing,
status: 'success',
updatedAt: nowIso(),
accountId: patch.accountId,
siteId: patch.siteId,
error: undefined,
};
this.sessions.set(state, next);
return next;
}
markError(state: string, error: string): OAuthSessionRecord | null {
const existing = this.get(state);
if (!existing) return null;
const next: OAuthSessionRecord = {
...existing,
status: 'error',
updatedAt: nowIso(),
error: error.trim() || 'OAuth failed',
};
this.sessions.set(state, next);
return next;
}
}
let oauthSessionStore: OAuthSessionStore = new MemoryOAuthSessionStore();
export function setOauthSessionStore(store: OAuthSessionStore) {
oauthSessionStore = store;
}
export function createOauthSession(input: {
provider: string;
redirectUri: string;
rebindAccountId?: number;
projectId?: string;
}): OAuthSessionRecord {
pruneExpiredSessions();
const state = toBase64Url(randomBytes(24));
const codeVerifier = createPkceVerifier();
const createdAt = nowIso();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
const record: OAuthSessionRecord = {
provider: input.provider,
state,
status: 'pending',
codeVerifier,
redirectUri: input.redirectUri,
createdAt,
updatedAt: createdAt,
expiresAt,
rebindAccountId: input.rebindAccountId,
};
oauthSessions.set(state, record);
return record;
return oauthSessionStore.create(input);
}
export function getOauthSession(state: string): OAuthSessionRecord | null {
pruneExpiredSessions();
return oauthSessions.get(state) || null;
return oauthSessionStore.get(state);
}
export function markOauthSessionSuccess(
state: string,
patch: { accountId: number; siteId: number },
): OAuthSessionRecord | null {
const existing = getOauthSession(state);
if (!existing) return null;
const next: OAuthSessionRecord = {
...existing,
status: 'success',
updatedAt: nowIso(),
accountId: patch.accountId,
siteId: patch.siteId,
error: undefined,
};
oauthSessions.set(state, next);
return next;
return oauthSessionStore.markSuccess(state, patch);
}
export function markOauthSessionError(state: string, error: string): OAuthSessionRecord | null {
const existing = getOauthSession(state);
if (!existing) return null;
const next: OAuthSessionRecord = {
...existing,
status: 'error',
updatedAt: nowIso(),
error: error.trim() || 'OAuth failed',
};
oauthSessions.set(state, next);
return next;
return oauthSessionStore.markError(state, error);
}
+5 -1
View File
@@ -1,7 +1,11 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
let normalized = (baseUrl || '').trim();
while (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
export class CodexAdapter extends BasePlatformAdapter {
+1 -1
View File
@@ -42,7 +42,7 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string
}
export class GeminiAdapter extends BasePlatformAdapter {
readonly platformName = 'gemini';
readonly platformName: string = 'gemini';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
@@ -0,0 +1,10 @@
import { GeminiAdapter } from './gemini.js';
export class GeminiCliAdapter extends GeminiAdapter {
override readonly platformName = 'gemini-cli';
override async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
return normalized.includes('cloudcode-pa.googleapis.com');
}
}
+4
View File
@@ -10,6 +10,7 @@ import { OpenAiAdapter } from './openai.js';
import { CodexAdapter } from './codex.js';
import { ClaudeAdapter } from './claude.js';
import { GeminiAdapter } from './gemini.js';
import { GeminiCliAdapter } from './geminiCli.js';
import { CliProxyApiAdapter } from './cliproxyapi.js';
import { detectPlatformByTitle } from './titleHint.js';
@@ -19,6 +20,7 @@ const adapters: PlatformAdapter[] = [
new CodexAdapter(),
new ClaudeAdapter(),
new GeminiAdapter(),
new GeminiCliAdapter(),
new CliProxyApiAdapter(),
new AnyRouterAdapter(),
new DoneHubAdapter(),
@@ -57,6 +59,7 @@ const platformAliases: Record<string, string> = {
anthropic: 'claude',
claude: 'claude',
gemini: 'gemini',
'gemini-cli': 'gemini-cli',
google: 'gemini',
// CLIProxyAPI aliases
cliproxyapi: 'cliproxyapi',
@@ -97,6 +100,7 @@ function detectPlatformByUrlHint(url: string): string | undefined {
) {
return 'gemini';
}
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
// NewAPI-family forks and common aliases.
if (normalized.includes('anyrouter')) return 'anyrouter';
+2 -2
View File
@@ -10,7 +10,7 @@ import {
} from './routeRoutingStrategy.js';
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
import { isUsableAccountToken } from './accountTokenService.js';
import { isCodexPlatform } from './oauth/codexAccount.js';
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
interface RouteMatch {
route: typeof schema.tokenRoutes.$inferSelect;
@@ -1045,7 +1045,7 @@ export class TokenRouter {
const fallback = candidate.account.apiToken?.trim();
if (fallback) return fallback;
if ((candidate.site?.platform || '').trim().toLowerCase() === 'codex' || isCodexPlatform(candidate.account)) {
if (getOauthInfoFromExtraConfig(candidate.account.extraConfig)) {
const accessToken = candidate.account.accessToken?.trim();
return accessToken || null;
}