feat: align oauth loopback flow with cliproxyapi

This commit is contained in:
ciat-777
2026-03-17 17:56:30 +08:00
parent 35f5bd3464
commit ebae2846b2
7 changed files with 440 additions and 52 deletions
@@ -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;