feat: implement Codex OAuth flow with loopback callback server and management UI
- Add localCallbackServer for handling OAuth callback requests. - Implement service functions for managing OAuth sessions and accounts. - Create CodexAdapter for platform-specific interactions. - Develop OAuthManagement page for managing OAuth connections and providers. - Add tests for localCallbackServer and OAuthManagement components.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { schema } from '../../db/index.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 = {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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 buildCodexOauthInfo(
|
||||
extraConfig?: string | null,
|
||||
patch: Partial<CodexOauthInfo> = {},
|
||||
): CodexOauthInfo {
|
||||
const current = getCodexOauthInfoFromExtraConfig(extraConfig);
|
||||
return {
|
||||
provider: 'codex',
|
||||
...(current || {}),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { fetch } from 'undici';
|
||||
import { createPkceChallenge } from './sessionStore.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_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';
|
||||
|
||||
type CodexJwtClaims = {
|
||||
email?: unknown;
|
||||
'https://api.openai.com/auth'?: {
|
||||
chatgpt_account_id?: unknown;
|
||||
chatgpt_plan_type?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type CodexTokenExchangeResult = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken: string;
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
planType?: string;
|
||||
tokenExpiresAt: number;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function parseJwtClaims(token: string): CodexJwtClaims | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown;
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
||||
return payload as CodexJwtClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexAuthorizationUrl(input: {
|
||||
state: string;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
}): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
response_type: 'code',
|
||||
redirect_uri: input.redirectUri,
|
||||
scope: 'openid email profile offline_access',
|
||||
state: input.state,
|
||||
code_challenge: createPkceChallenge(input.codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
prompt: 'login',
|
||||
id_token_add_organizations: 'true',
|
||||
codex_cli_simplified_flow: 'true',
|
||||
});
|
||||
return `${CODEX_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseTokenResponsePayload(payload: unknown): CodexTokenExchangeResult {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('codex token exchange returned invalid payload');
|
||||
}
|
||||
const body = payload as Record<string, unknown>;
|
||||
const accessToken = asTrimmedString(body.access_token);
|
||||
const refreshToken = asTrimmedString(body.refresh_token);
|
||||
const idToken = asTrimmedString(body.id_token);
|
||||
const expiresIn = typeof body.expires_in === 'number' && Number.isFinite(body.expires_in)
|
||||
? Math.trunc(body.expires_in)
|
||||
: (typeof body.expires_in === 'string' ? Number.parseInt(body.expires_in.trim(), 10) : NaN);
|
||||
if (!accessToken || !refreshToken || !idToken || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
||||
throw new Error('codex token exchange response missing required fields');
|
||||
}
|
||||
const claims = parseJwtClaims(idToken);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
accountId: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_account_id),
|
||||
email: asTrimmedString(claims?.email),
|
||||
planType: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_plan_type),
|
||||
tokenExpiresAt: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeCodexToken(form: URLSearchParams): Promise<CodexTokenExchangeResult> {
|
||||
const response = await fetch(CODEX_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: form.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `codex token exchange failed with status ${response.status}`);
|
||||
}
|
||||
return parseTokenResponsePayload(await response.json());
|
||||
}
|
||||
|
||||
export async function exchangeCodexAuthorizationCode(input: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<CodexTokenExchangeResult> {
|
||||
return exchangeCodexToken(new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
code: input.code,
|
||||
redirect_uri: input.redirectUri,
|
||||
code_verifier: input.codeVerifier,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function refreshCodexTokens(refreshToken: string): Promise<CodexTokenExchangeResult> {
|
||||
return exchangeCodexToken(new URLSearchParams({
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
scope: 'openid profile email',
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
startCodexLoopbackCallbackServer,
|
||||
stopCodexLoopbackCallbackServer,
|
||||
} from './localCallbackServer.js';
|
||||
|
||||
describe('codex loopback callback server', () => {
|
||||
afterEach(async () => {
|
||||
await stopCodexLoopbackCallbackServer();
|
||||
});
|
||||
|
||||
it('accepts 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,
|
||||
callbackHandler,
|
||||
});
|
||||
|
||||
const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`);
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(callbackHandler).toHaveBeenCalledWith({
|
||||
provider: 'codex',
|
||||
state: 'test-state',
|
||||
code: 'test-code',
|
||||
error: undefined,
|
||||
});
|
||||
expect(body).toContain('window.close()');
|
||||
});
|
||||
|
||||
it('renders an 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,
|
||||
callbackHandler,
|
||||
});
|
||||
|
||||
const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`);
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(body).toContain('oauth failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
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 { handleOauthCallback } from './service.js';
|
||||
|
||||
type CallbackHandler = typeof handleOauthCallback;
|
||||
|
||||
type StartCodexLoopbackCallbackServerOptions = {
|
||||
port?: number;
|
||||
host?: string;
|
||||
callbackHandler?: CallbackHandler;
|
||||
};
|
||||
|
||||
export type CodexLoopbackCallbackServerState = {
|
||||
attempted: boolean;
|
||||
ready: boolean;
|
||||
host?: string;
|
||||
port: number;
|
||||
origin: 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;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function renderCompletionPage(message: string): string {
|
||||
const safeMessage = escapeHtml(message);
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Codex OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.close();</script>
|
||||
${safeMessage}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function respondHtml(response: ServerResponse, statusCode: number, message: string) {
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
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}`;
|
||||
}
|
||||
if (host.includes(':') && !host.startsWith('[')) {
|
||||
return `http://[${host}]:${port}`;
|
||||
}
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
export function getCodexLoopbackCallbackServerState(): CodexLoopbackCallbackServerState {
|
||||
return { ...callbackServerState };
|
||||
}
|
||||
|
||||
export async function startCodexLoopbackCallbackServer(
|
||||
options: StartCodexLoopbackCallbackServerOptions = {},
|
||||
): Promise<CodexLoopbackCallbackServerState> {
|
||||
if (callbackServer) {
|
||||
return getCodexLoopbackCallbackServerState();
|
||||
}
|
||||
if (startPromise) {
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
const callbackHandler = options.callbackHandler || handleOauthCallback;
|
||||
const requestedPort = options.port ?? CODEX_LOOPBACK_CALLBACK_PORT;
|
||||
const requestedHost = options.host;
|
||||
|
||||
startPromise = new Promise<CodexLoopbackCallbackServerState>((resolve, reject) => {
|
||||
const server = createServer((request, response) => {
|
||||
void handleCallbackRequest(request, response, callbackHandler);
|
||||
});
|
||||
|
||||
const finalizeFailure = (error: Error) => {
|
||||
callbackServerState = {
|
||||
attempted: true,
|
||||
ready: false,
|
||||
host: requestedHost,
|
||||
port: requestedPort,
|
||||
origin: normalizeOrigin(requestedHost, requestedPort),
|
||||
error: error.message || 'failed to start codex oauth callback server',
|
||||
};
|
||||
callbackServer = null;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
server.once('error', (error) => {
|
||||
finalizeFailure(error as Error);
|
||||
});
|
||||
|
||||
server.listen(requestedPort, requestedHost, () => {
|
||||
server.removeAllListeners('error');
|
||||
callbackServer = server;
|
||||
const address = server.address() as AddressInfo | null;
|
||||
const port = address?.port || requestedPort;
|
||||
const host = address?.address || requestedHost;
|
||||
callbackServerState = {
|
||||
attempted: true,
|
||||
ready: true,
|
||||
host,
|
||||
port,
|
||||
origin: normalizeOrigin(host, port),
|
||||
};
|
||||
resolve(getCodexLoopbackCallbackServerState());
|
||||
});
|
||||
}).finally(() => {
|
||||
startPromise = null;
|
||||
});
|
||||
|
||||
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()));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { eq } 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';
|
||||
|
||||
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';
|
||||
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
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';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
));
|
||||
if (existing) return existing;
|
||||
|
||||
return db.insert(schema.sites).values({
|
||||
name: CODEX_SITE_NAME,
|
||||
url: CODEX_UPSTREAM_BASE_URL,
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
isPinned: false,
|
||||
globalWeight: 1,
|
||||
sortOrder: await getNextSiteSortOrder(),
|
||||
}).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) {
|
||||
return db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, rebindAccountId))
|
||||
.get();
|
||||
}
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
return accounts.find((account) => matchesCodexIdentity(account, exchange)) || null;
|
||||
}
|
||||
|
||||
async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAccountId?: number) {
|
||||
const site = await ensureCodexSite();
|
||||
const existing = await findExistingCodexAccount(exchange, rebindAccountId);
|
||||
const username = buildUsername(exchange);
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db.update(schema.accounts).set({
|
||||
siteId: site.id,
|
||||
username,
|
||||
accessToken: exchange.accessToken,
|
||||
apiToken: null,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
extraConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, existing.id)).run();
|
||||
return {
|
||||
account: await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(),
|
||||
site,
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
const created = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username,
|
||||
accessToken: exchange.accessToken,
|
||||
apiToken: null,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: await getNextAccountSortOrder(),
|
||||
}).returning().get();
|
||||
return { account: created, site, created: true };
|
||||
}
|
||||
|
||||
export function listOauthProviders() {
|
||||
return OAUTH_PROVIDER_LIST;
|
||||
}
|
||||
|
||||
export function startOauthProviderFlow(input: {
|
||||
provider: string;
|
||||
redirectOrigin: string;
|
||||
rebindAccountId?: number;
|
||||
}) {
|
||||
if (input.provider !== CODEX_OAUTH_PROVIDER) {
|
||||
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 = CODEX_LOOPBACK_REDIRECT_URI;
|
||||
const session = createOauthSession({
|
||||
provider: input.provider,
|
||||
redirectUri,
|
||||
rebindAccountId: input.rebindAccountId,
|
||||
});
|
||||
return {
|
||||
provider: input.provider,
|
||||
state: session.state,
|
||||
authorizationUrl: buildCodexAuthorizationUrl({
|
||||
state: session.state,
|
||||
redirectUri,
|
||||
codeVerifier: session.codeVerifier,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOauthSessionStatus(state: string) {
|
||||
const session = getOauthSession(state);
|
||||
if (!session) return null;
|
||||
return {
|
||||
provider: session.provider,
|
||||
state: session.state,
|
||||
status: session.status,
|
||||
accountId: session.accountId,
|
||||
siteId: session.siteId,
|
||||
error: session.error,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleOauthCallback(input: {
|
||||
provider: string;
|
||||
state: string;
|
||||
code?: string;
|
||||
error?: string;
|
||||
}) {
|
||||
const session = getOauthSession(input.state);
|
||||
if (!session || session.provider !== input.provider) {
|
||||
throw new Error('oauth session not found or provider mismatch');
|
||||
}
|
||||
if (input.error) {
|
||||
markOauthSessionError(input.state, input.error);
|
||||
throw new Error(input.error);
|
||||
}
|
||||
const code = asNonEmptyString(input.code);
|
||||
if (!code) {
|
||||
markOauthSessionError(input.state, 'missing oauth code');
|
||||
throw new Error('missing oauth code');
|
||||
}
|
||||
|
||||
const exchange = await exchangeCodexAuthorizationCode({
|
||||
code,
|
||||
codeVerifier: session.codeVerifier,
|
||||
redirectUri: session.redirectUri,
|
||||
});
|
||||
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');
|
||||
}
|
||||
|
||||
const refreshResult = await refreshModelsForAccount(account.id);
|
||||
if (refreshResult.status !== 'success') {
|
||||
if (created) {
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run();
|
||||
}
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
const errorMessage = refreshResult.errorMessage || 'codex model discovery failed';
|
||||
markOauthSessionError(input.state, errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
markOauthSessionSuccess(input.state, {
|
||||
accountId: account.id,
|
||||
siteId: site.id,
|
||||
});
|
||||
return { accountId: account.id, siteId: site.id };
|
||||
}
|
||||
|
||||
export async function listOauthConnections() {
|
||||
const rows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.all();
|
||||
const modelRows = await db.select({
|
||||
accountId: schema.modelAvailability.accountId,
|
||||
modelName: schema.modelAvailability.modelName,
|
||||
}).from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.available, true))
|
||||
.all();
|
||||
const modelMap = new Map<number, string[]>();
|
||||
for (const row of modelRows) {
|
||||
if (typeof row.accountId !== 'number') continue;
|
||||
const list = modelMap.get(row.accountId) || [];
|
||||
list.push(row.modelName);
|
||||
modelMap.set(row.accountId, list);
|
||||
}
|
||||
const routeChannelRows = await db.select({
|
||||
accountId: schema.routeChannels.accountId,
|
||||
}).from(schema.routeChannels).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);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOauthConnection(accountId: number) {
|
||||
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);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run();
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function startOauthRebindFlow(accountId: number, redirectOrigin: 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);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
return startOauthProviderFlow({
|
||||
provider: oauth.provider,
|
||||
redirectOrigin,
|
||||
rebindAccountId: accountId,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function refreshCodexOauthAccessToken(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');
|
||||
}
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth?.refreshToken) {
|
||||
throw new Error('codex oauth refresh token missing');
|
||||
}
|
||||
|
||||
const refreshed = await refreshCodexTokens(oauth.refreshToken);
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
await db.update(schema.accounts).set({
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig,
|
||||
status: 'active',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, accountId)).run();
|
||||
|
||||
return {
|
||||
accountId,
|
||||
accessToken: refreshed.accessToken,
|
||||
accountKey: refreshed.accountId || oauth.accountId,
|
||||
extraConfig,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
export type OAuthSessionStatus = 'pending' | 'success' | 'error';
|
||||
|
||||
export type OAuthSessionRecord = {
|
||||
provider: string;
|
||||
state: string;
|
||||
status: OAuthSessionStatus;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string;
|
||||
accountId?: number;
|
||||
siteId?: number;
|
||||
error?: string;
|
||||
rebindAccountId?: number;
|
||||
};
|
||||
|
||||
const SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
const oauthSessions = new Map<string, OAuthSessionRecord>();
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
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 function createOauthSession(input: {
|
||||
provider: string;
|
||||
redirectUri: string;
|
||||
rebindAccountId?: number;
|
||||
}): 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;
|
||||
}
|
||||
|
||||
export function getOauthSession(state: string): OAuthSessionRecord | null {
|
||||
pruneExpiredSessions();
|
||||
return oauthSessions.get(state) || null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user