Merge pull request #139 from cita-777/feat/oauth-loopback-bridge

feat: align oauth loopback flow with cliproxyapi
This commit is contained in:
Cita
2026-03-17 18:02:31 +08:00
committed by GitHub
7 changed files with 440 additions and 52 deletions
+56 -17
View File
@@ -101,14 +101,28 @@ describe('oauth routes', { timeout: 15_000 }, () => {
provider: string;
state: string;
authorizationUrl: string;
instructions?: {
redirectUri: string;
callbackPort: number;
callbackPath: string;
manualCallbackDelayMs: number;
sshTunnelCommand?: string;
};
};
expect(startBody.provider).toBe('codex');
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('https://metapi.example/api/oauth/callback/codex'));
expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback'));
expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`);
expect(startBody.authorizationUrl).toContain('code_challenge=');
expect(startBody.instructions).toMatchObject({
redirectUri: 'http://localhost:1455/auth/callback',
callbackPort: 1455,
callbackPath: '/auth/callback',
manualCallbackDelayMs: 15000,
sshTunnelCommand: 'ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22',
});
const sessionResponse = await app.inject({
method: 'GET',
@@ -142,7 +156,7 @@ describe('oauth routes', { timeout: 15_000 }, () => {
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 () => {
it('handles manual codex callback submission, creates oauth-backed account, and discovers plan models', async () => {
const jwt = buildJwt({
email: 'codex-user@example.com',
'https://api.openai.com/auth': {
@@ -187,17 +201,16 @@ describe('oauth routes', { timeout: 15_000 }, () => {
const startBody = startResponse.json() as { state: string };
const callbackResponse = await app.inject({
method: 'GET',
url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`,
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
method: 'POST',
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
payload: {
callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`,
},
});
expect(callbackResponse.statusCode).toBe(200);
expect(callbackResponse.body).toContain('window.close()');
expect(callbackResponse.json()).toEqual({ success: true });
expect(String(fetchMock.mock.calls[0]?.[1]?.body || '')).toContain(
'redirect_uri=https%3A%2F%2Fmetapi.example%2Fapi%2Foauth%2Fcallback%2Fcodex',
'redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback',
);
const sessionResponse = await app.inject({
@@ -252,7 +265,7 @@ describe('oauth routes', { timeout: 15_000 }, () => {
expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']);
});
it('marks oauth session as error and avoids creating a connection when codex model discovery fails', async () => {
it('marks oauth session as error and avoids creating a connection when manual codex callback model discovery fails', async () => {
const jwt = buildJwt({
email: 'codex-fail@example.com',
'https://api.openai.com/auth': {
@@ -291,15 +304,16 @@ describe('oauth routes', { timeout: 15_000 }, () => {
const startBody = startResponse.json() as { state: string };
const callbackResponse = await app.inject({
method: 'GET',
url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`,
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
method: 'POST',
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
payload: {
callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`,
},
});
expect(callbackResponse.statusCode).toBe(200);
expect(callbackResponse.body).toContain('OAuth authorization failed');
expect(callbackResponse.statusCode).toBe(500);
expect(callbackResponse.json()).toMatchObject({
message: expect.stringContaining('HTTP 403'),
});
const sessionResponse = await app.inject({
method: 'GET',
@@ -412,4 +426,29 @@ describe('oauth routes', { timeout: 15_000 }, () => {
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toEqual([]);
});
it('rejects malformed manual callback submissions', async () => {
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/claude/start',
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
const startBody = startResponse.json() as { state: string };
const callbackResponse = await app.inject({
method: 'POST',
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
payload: {
callbackUrl: 'not-a-valid-url',
},
});
expect(callbackResponse.statusCode).toBe(400);
expect(callbackResponse.json()).toMatchObject({
message: expect.stringContaining('invalid oauth callback url'),
});
});
});
+35
View File
@@ -8,6 +8,7 @@ import {
listOauthProviders,
startOauthProviderFlow,
startOauthRebindFlow,
submitOauthManualCallback,
} from '../../services/oauth/service.js';
const limitOauthProviderRead = createRateLimitGuard({
@@ -28,6 +29,12 @@ const limitOauthSessionRead = createRateLimitGuard({
windowMs: 60_000,
});
const limitOauthSessionMutate = createRateLimitGuard({
bucket: 'oauth-session-mutate',
max: 30,
windowMs: 60_000,
});
const limitOauthConnectionRead = createRateLimitGuard({
bucket: 'oauth-connection-read',
max: 60,
@@ -142,6 +149,34 @@ export async function oauthRoutes(app: FastifyInstance) {
},
);
app.post<{ Params: { state: string }; Body: { callbackUrl?: string } }>(
'/api/oauth/sessions/:state/manual-callback',
{ preHandler: [limitOauthSessionMutate] },
async (request, reply) => {
const callbackUrl = typeof request.body?.callbackUrl === 'string'
? request.body.callbackUrl.trim()
: '';
if (!callbackUrl) {
return reply.code(400).send({ message: 'invalid oauth callback url' });
}
try {
return await submitOauthManualCallback({
state: request.params.state,
callbackUrl,
});
} catch (error: any) {
const message = error?.message || 'oauth callback submission failed';
if (message === 'invalid oauth callback url' || message === 'oauth callback state mismatch') {
return reply.code(400).send({ message });
}
if (message === 'oauth session not found') {
return reply.code(404).send({ message });
}
return reply.code(500).send({ message });
}
},
);
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/api/oauth/connections',
{ preHandler: [limitOauthConnectionRead] },
@@ -20,32 +20,6 @@ function requireCodexClientId(): string {
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'?: {
@@ -210,7 +184,6 @@ export const codexOauthProvider: OAuthProviderDefinition = {
path: CODEX_LOOPBACK_CALLBACK_PATH,
redirectUri: CODEX_LOOPBACK_REDIRECT_URI,
},
resolveRedirectUri: ({ requestOrigin }) => resolveCodexRedirectUri(requestOrigin),
buildAuthorizationUrl: ({ state, redirectUri, codeVerifier }) => buildCodexAuthorizationUrl({
state,
redirectUri,
+112 -5
View File
@@ -18,6 +18,89 @@ import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
import { buildCodexOauthInfo } from './codexAccount.js';
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
const MANUAL_CALLBACK_DELAY_MS = 15_000;
type OAuthStartInstructions = {
redirectUri: string;
callbackPort: number;
callbackPath: string;
manualCallbackDelayMs: number;
sshTunnelCommand?: string;
sshTunnelKeyCommand?: string;
};
function isLoopbackHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return normalized === 'localhost'
|| normalized === '127.0.0.1'
|| normalized === '::1'
|| normalized === '[::1]';
}
function resolveSshTunnelHost(requestOrigin?: string): string | undefined {
if (!requestOrigin) return undefined;
try {
const parsed = new URL(requestOrigin);
if (!parsed.hostname || isLoopbackHost(parsed.hostname)) {
return undefined;
}
return parsed.hostname;
} catch {
return undefined;
}
}
function buildLoopbackInstructions(
definition: OAuthProviderDefinition,
requestOrigin?: string,
): OAuthStartInstructions {
const sshHost = resolveSshTunnelHost(requestOrigin);
return {
redirectUri: definition.loopback.redirectUri,
callbackPort: definition.loopback.port,
callbackPath: definition.loopback.path,
manualCallbackDelayMs: MANUAL_CALLBACK_DELAY_MS,
sshTunnelCommand: sshHost
? `ssh -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22`
: undefined,
sshTunnelKeyCommand: sshHost
? `ssh -i <path_to_your_key> -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22`
: undefined,
};
}
function parseManualCallbackUrl(input: {
callbackUrl: string;
provider: string;
}) {
const raw = asNonEmptyString(input.callbackUrl);
if (!raw) {
throw new Error('invalid oauth callback url');
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error('invalid oauth callback url');
}
const state = asNonEmptyString(parsed.searchParams.get('state'));
const code = asNonEmptyString(parsed.searchParams.get('code'));
const error = asNonEmptyString(parsed.searchParams.get('error'));
const errorDescription = asNonEmptyString(parsed.searchParams.get('error_description'));
if (!state || (!code && !error)) {
throw new Error('invalid oauth callback url');
}
return {
state,
code,
error: error
? (errorDescription ? `${error}: ${errorDescription}` : error)
: undefined,
};
}
function asNonEmptyString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
@@ -209,12 +292,9 @@ export async function startOauthProviderFlow(input: {
if (!definition) {
throw new Error(`unsupported oauth provider: ${input.provider}`);
}
const redirectUri = definition.resolveRedirectUri?.({
requestOrigin: input.requestOrigin,
}) || definition.loopback.redirectUri;
const redirectUri = definition.loopback.redirectUri;
const callbackServerState = getOAuthLoopbackCallbackServerState(input.provider);
const usesLoopbackCallback = redirectUri === definition.loopback.redirectUri;
if (usesLoopbackCallback && callbackServerState.attempted && !callbackServerState.ready) {
if (callbackServerState.attempted && !callbackServerState.ready) {
throw new Error(`${input.provider} oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
}
const session = createOauthSession({
@@ -232,6 +312,7 @@ export async function startOauthProviderFlow(input: {
codeVerifier: session.codeVerifier,
projectId: session.projectId,
}),
instructions: buildLoopbackInstructions(definition, input.requestOrigin),
};
}
@@ -311,6 +392,32 @@ export async function handleOauthCallback(input: {
return { accountId: account.id, siteId: site.id };
}
export async function submitOauthManualCallback(input: {
state: string;
callbackUrl: string;
}) {
const session = getOauthSession(input.state);
if (!session) {
throw new Error('oauth session not found');
}
const parsed = parseManualCallbackUrl({
callbackUrl: input.callbackUrl,
provider: session.provider,
});
if (parsed.state !== input.state) {
throw new Error('oauth callback state mismatch');
}
await handleOauthCallback({
provider: session.provider,
state: parsed.state,
code: parsed.code,
error: parsed.error,
});
return { success: true };
}
export async function listOauthConnections(options: {
limit?: number;
offset?: number;