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
@@ -25,7 +25,7 @@ vi.mock('../../services/platforms/index.js', () => ({
type DbModule = typeof import('../../db/index.js');
describe('account token coverage refresh', () => {
describe('account token coverage refresh', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
@@ -172,7 +172,7 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
});
});
it('marks codex oauth connection as proxy-capable but not checkin/balance-capable', async () => {
it('marks codex oauth connection as direct-routed proxy-only connection without checkin/balance capabilities', async () => {
const site = await db.insert(schema.sites).values({
name: 'Codex Site',
url: 'https://chatgpt.com/backend-api/codex',
@@ -221,12 +221,12 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
}>;
const item = list.find((entry) => entry.id === account.id);
expect(item?.credentialMode).toBe('session');
expect(item?.capabilities).toMatchObject({
canCheckin: false,
canRefreshBalance: false,
proxyOnly: false,
expect(item?.capabilities).toMatchObject({
canCheckin: false,
canRefreshBalance: false,
proxyOnly: true,
});
});
});
it('stores managed refresh token for sub2api session account', async () => {
verifyTokenMock.mockResolvedValueOnce({
+3 -3
View File
@@ -8,6 +8,7 @@ import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../serv
import {
getCredentialModeFromExtraConfig,
guessPlatformUserIdFromUsername,
hasOauthProvider,
getSub2ApiAuthFromExtraConfig,
mergeAccountExtraConfig,
normalizeCredentialMode as normalizeCredentialModeInput,
@@ -19,7 +20,6 @@ import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
import { getLocalDayRangeUtc } from '../../services/localTimeService.js';
import { isCodexPlatform } from '../../services/oauth/codexAccount.js';
import {
buildRuntimeHealthForAccount,
setAccountRuntimeHealth,
@@ -82,11 +82,11 @@ function buildCapabilitiesFromCredentialMode(
hasSessionToken: boolean,
extraConfig?: string | null,
): AccountCapabilities {
if (isCodexPlatform(extraConfig)) {
if (hasOauthProvider(extraConfig)) {
return {
canCheckin: false,
canRefreshBalance: false,
proxyOnly: false,
proxyOnly: true,
};
}
const sessionCapable = credentialMode === 'session'
@@ -1,18 +1,32 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';
import { oauthRoutes } from './oauth.js';
import { isPublicApiRoute } from '../../desktop.js';
describe('oauth route registration', () => {
it('registers oauth routes in the main server entry', () => {
const source = readFileSync(resolve(process.cwd(), 'src/server/index.ts'), 'utf8');
const apps: Array<Awaited<ReturnType<typeof Fastify>>> = [];
expect(source).toContain("import { oauthRoutes } from './routes/api/oauth.js';");
expect(source).toContain('await app.register(oauthRoutes);');
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
});
it('treats oauth callback as a public API route', () => {
const source = readFileSync(resolve(process.cwd(), 'src/server/desktop.ts'), 'utf8');
it('registers oauth routes on a Fastify instance at runtime', async () => {
const app = Fastify();
apps.push(app);
await app.register(oauthRoutes);
expect(source).toContain("url.startsWith('/api/oauth/callback/')");
const routes = app.printRoutes();
expect(routes).toContain('providers (GET, HEAD)');
expect(routes).toContain(':provider');
expect(routes).toContain('sessions/');
expect(routes).toContain(':state (GET, HEAD)');
expect(routes).toContain('onnections (GET, HEAD)');
expect(routes).toContain('allback/');
});
it('treats oauth callback route as a public desktop API route', () => {
expect(isPublicApiRoute('/api/oauth/callback/codex')).toBe(true);
expect(isPublicApiRoute('/api/oauth/callback/claude')).toBe(true);
expect(isPublicApiRoute('/api/oauth/providers')).toBe(false);
});
});
+62 -8
View File
@@ -27,6 +27,10 @@ describe('oauth routes', { timeout: 15_000 }, () => {
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-'));
process.env.DATA_DIR = dataDir;
process.env.CODEX_CLIENT_ID = 'test-codex-client-id';
process.env.CLAUDE_CLIENT_ID = 'test-claude-client-id';
process.env.GEMINI_CLI_CLIENT_ID = 'test-gemini-client-id';
process.env.GEMINI_CLI_CLIENT_SECRET = 'test-gemini-client-secret';
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
@@ -52,9 +56,13 @@ describe('oauth routes', { timeout: 15_000 }, () => {
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
delete process.env.CODEX_CLIENT_ID;
delete process.env.CLAUDE_CLIENT_ID;
delete process.env.GEMINI_CLI_CLIENT_ID;
delete process.env.GEMINI_CLI_CLIENT_SECRET;
});
it('lists codex oauth provider metadata', async () => {
it('lists multi-provider oauth metadata', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/oauth/providers',
@@ -62,14 +70,27 @@ describe('oauth routes', { timeout: 15_000 }, () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
providers: [
providers: expect.arrayContaining([
expect.objectContaining({
provider: 'codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
}),
],
expect.objectContaining({
provider: 'claude',
platform: 'claude',
enabled: true,
loginType: 'oauth',
}),
expect.objectContaining({
provider: 'gemini-cli',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
}),
]),
});
});
@@ -93,7 +114,7 @@ describe('oauth routes', { timeout: 15_000 }, () => {
expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/);
expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?');
expect(startBody.authorizationUrl).toContain('client_id=');
expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback'));
expect(startBody.authorizationUrl).toContain(encodeURIComponent('https://metapi.example/api/oauth/callback/codex'));
expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`);
expect(startBody.authorizationUrl).toContain('code_challenge=');
@@ -109,6 +130,26 @@ describe('oauth routes', { timeout: 15_000 }, () => {
});
});
it('keeps the codex loopback callback for local origins', async () => {
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/codex/start',
headers: {
host: 'localhost:4000',
},
});
expect(startResponse.statusCode).toBe(200);
const startBody = startResponse.json() as {
provider: string;
state: string;
authorizationUrl: string;
};
expect(startBody.provider).toBe('codex');
expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback'));
expect(startBody.authorizationUrl).not.toContain(encodeURIComponent('http://localhost:4000/api/oauth/callback/codex'));
});
it('handles codex callback, creates oauth-backed account, and discovers plan models', async () => {
const jwt = buildJwt({
email: 'codex-user@example.com',
@@ -163,6 +204,9 @@ describe('oauth routes', { timeout: 15_000 }, () => {
});
expect(callbackResponse.statusCode).toBe(200);
expect(callbackResponse.body).toContain('window.close()');
expect(String(fetchMock.mock.calls[0]?.[1]?.body || '')).toContain(
'redirect_uri=https%3A%2F%2Fmetapi.example%2Fapi%2Foauth%2Fcallback%2Fcodex',
);
const sessionResponse = await app.inject({
method: 'GET',
@@ -284,7 +328,12 @@ describe('oauth routes', { timeout: 15_000 }, () => {
url: '/api/oauth/connections',
});
expect(connectionsResponse.statusCode).toBe(200);
expect(connectionsResponse.json()).toEqual({ items: [] });
expect(connectionsResponse.json()).toMatchObject({
items: [],
total: 0,
limit: 50,
offset: 0,
});
});
it('lists oauth connection health metadata and supports deleting the connection', async () => {
@@ -301,6 +350,8 @@ describe('oauth routes', { timeout: 15_000 }, () => {
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
oauthProvider: 'codex',
oauthAccountKey: 'chatgpt-account-123',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
@@ -342,8 +393,8 @@ describe('oauth routes', { timeout: 15_000 }, () => {
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({
items: [
expect(listResponse.json()).toMatchObject({
items: expect.arrayContaining([
expect.objectContaining({
accountId: account.id,
provider: 'codex',
@@ -353,7 +404,10 @@ describe('oauth routes', { timeout: 15_000 }, () => {
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
}),
],
]),
total: 1,
limit: 50,
offset: 0,
});
const deleteResponse = await app.inject({
+147 -35
View File
@@ -1,4 +1,5 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
import {
deleteOauthConnection,
getOauthSessionStatus,
@@ -9,7 +10,80 @@ import {
startOauthRebindFlow,
} from '../../services/oauth/service.js';
function resolveRequestOrigin(request: FastifyRequest): string {
const limitOauthProviderRead = createRateLimitGuard({
bucket: 'oauth-provider-read',
max: 60,
windowMs: 60_000,
});
const limitOauthStart = createRateLimitGuard({
bucket: 'oauth-start',
max: 20,
windowMs: 60_000,
});
const limitOauthSessionRead = createRateLimitGuard({
bucket: 'oauth-session-read',
max: 120,
windowMs: 60_000,
});
const limitOauthConnectionRead = createRateLimitGuard({
bucket: 'oauth-connection-read',
max: 60,
windowMs: 60_000,
});
const limitOauthConnectionMutate = createRateLimitGuard({
bucket: 'oauth-connection-mutate',
max: 20,
windowMs: 60_000,
});
const limitOauthCallback = createRateLimitGuard({
bucket: 'oauth-callback',
max: 30,
windowMs: 60_000,
});
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderCallbackPage(message: string): string {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>OAuth Callback</title>
</head>
<body>
<script>window.close();</script>
${escapeHtml(message)}
</body>
</html>`;
}
function parsePositiveInteger(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
if (typeof value !== 'string') return null;
const parsed = Number.parseInt(value.trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function parseOptionalProjectId(value: unknown): string | undefined | null {
if (value === undefined || value === null) return undefined;
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed || undefined;
}
function resolveRequestOrigin(request: FastifyRequest): string | undefined {
const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string'
? request.headers['x-forwarded-proto'].split(',')[0]?.trim()
: '';
@@ -18,24 +92,37 @@ function resolveRequestOrigin(request: FastifyRequest): string {
? request.headers['x-forwarded-host'].split(',')[0]?.trim()
: '';
const host = forwardedHost
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '')
|| 'localhost';
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '');
if (!host) return undefined;
return `${protocol}://${host}`;
}
export async function oauthRoutes(app: FastifyInstance) {
app.get('/api/oauth/providers', async () => ({
app.get('/api/oauth/providers', { preHandler: [limitOauthProviderRead] }, async () => ({
providers: listOauthProviders(),
}));
app.post<{ Params: { provider: string }; Body: { accountId?: number } }>(
app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string } }>(
'/api/oauth/providers/:provider/start',
{ preHandler: [limitOauthStart] },
async (request, reply) => {
const rebindAccountId = request.body?.accountId === undefined
? undefined
: parsePositiveInteger(request.body.accountId);
if (request.body?.accountId !== undefined && rebindAccountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
const projectId = parseOptionalProjectId(request.body?.projectId);
if (request.body?.projectId !== undefined && projectId === null) {
return reply.code(400).send({ message: 'invalid project id' });
}
try {
return startOauthProviderFlow({
return await startOauthProviderFlow({
provider: request.params.provider,
redirectOrigin: resolveRequestOrigin(request),
rebindAccountId: request.body?.accountId,
rebindAccountId: rebindAccountId ?? undefined,
projectId: projectId ?? undefined,
requestOrigin: resolveRequestOrigin(request),
});
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth provider not found' });
@@ -43,23 +130,52 @@ export async function oauthRoutes(app: FastifyInstance) {
},
);
app.get<{ Params: { state: string } }>('/api/oauth/sessions/:state', async (request, reply) => {
const session = getOauthSessionStatus(request.params.state);
if (!session) {
return reply.code(404).send({ message: 'oauth session not found' });
}
return session;
});
app.get<{ Params: { state: string } }>(
'/api/oauth/sessions/:state',
{ preHandler: [limitOauthSessionRead] },
async (request, reply) => {
const session = getOauthSessionStatus(request.params.state);
if (!session) {
return reply.code(404).send({ message: 'oauth session not found' });
}
return session;
},
);
app.get('/api/oauth/connections', async () => ({
items: await listOauthConnections(),
}));
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/api/oauth/connections',
{ preHandler: [limitOauthConnectionRead] },
async (request, reply) => {
const limit = request.query.limit === undefined ? undefined : parsePositiveInteger(request.query.limit);
const offset = request.query.offset === undefined
? undefined
: (() => {
if (typeof request.query.offset !== 'string') return null;
const parsed = Number.parseInt(request.query.offset.trim(), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
})();
if (request.query.limit !== undefined && limit === null) {
return reply.code(400).send({ message: 'invalid limit' });
}
if (request.query.offset !== undefined && offset === null) {
return reply.code(400).send({ message: 'invalid offset' });
}
return listOauthConnections({
limit: limit ?? undefined,
offset: offset ?? undefined,
});
},
);
app.post<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId/rebind',
{ preHandler: [limitOauthConnectionMutate] },
async (request, reply) => {
const accountId = parsePositiveInteger(request.params.accountId);
if (accountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
try {
const accountId = Number.parseInt(request.params.accountId, 10);
return await startOauthRebindFlow(accountId, resolveRequestOrigin(request));
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
@@ -69,9 +185,13 @@ export async function oauthRoutes(app: FastifyInstance) {
app.delete<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId',
{ preHandler: [limitOauthConnectionMutate] },
async (request, reply) => {
const accountId = parsePositiveInteger(request.params.accountId);
if (accountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
try {
const accountId = Number.parseInt(request.params.accountId, 10);
return await deleteOauthConnection(accountId);
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
@@ -81,6 +201,7 @@ export async function oauthRoutes(app: FastifyInstance) {
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
'/api/oauth/callback/:provider',
{ preHandler: [limitOauthCallback] },
async (request, reply) => {
let message = 'OAuth callback received.';
try {
@@ -91,21 +212,12 @@ export async function oauthRoutes(app: FastifyInstance) {
error: request.query.error,
});
message = 'OAuth authorization succeeded. You can close this window.';
} catch (error: any) {
message = `OAuth authorization failed: ${error?.message || 'unknown error'}`;
} catch {
message = 'OAuth authorization failed. Return to metapi and review the server logs.';
}
reply.type('text/html; charset=utf-8');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>OAuth Callback</title>
</head>
<body>
<script>window.close();</script>
${message}
</body>
</html>`;
});
reply.type('text/html; charset=utf-8');
return renderCallbackPage(message);
},
);
}
+7 -1
View File
@@ -27,6 +27,7 @@ import {
parseStoredUtcDateTime,
toLocalDayKeyFromStoredUtc,
} from '../../services/localTimeService.js';
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
function parseBooleanFlag(raw?: string): boolean {
if (!raw) return false;
@@ -36,6 +37,11 @@ function parseBooleanFlag(raw?: string): boolean {
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
const limitModelTokenCandidatesRead = createRateLimitGuard({
bucket: 'models-token-candidates-read',
max: 30,
windowMs: 60_000,
});
type ModelsMarketplaceCacheEntry = {
expiresAt: number;
@@ -970,7 +976,7 @@ export async function statsRoutes(app: FastifyInstance) {
};
});
app.get('/api/models/token-candidates', async () => {
app.get('/api/models/token-candidates', { preHandler: [limitModelTokenCandidatesRead] }, async () => {
const resolveTokenGroupLabel = (tokenGroup: string | null, tokenName: string | null): string | null => {
const explicit = (tokenGroup || '').trim();
if (explicit) return explicit;