import { CODEX_CONFIG } from "../constants/oauth"; /** * OpenAI Codex Auth Info embedded in id_token JWT * The JWT claims contain a custom claim at "https://api.openai.com/auth" */ interface CodexAuthInfo { chatgpt_account_id: string; chatgpt_plan_type: string; chatgpt_user_id: string; user_id: string; organizations: Array<{ id: string; is_default: boolean; role: string; title: string; }>; } /** * Decode base64 string with proper UTF-8 handling. * atob() doesn't handle multi-byte UTF-8 characters correctly. */ function base64Decode(str: string): string { // Add padding if necessary let base64 = str; switch (base64.length % 4) { case 2: base64 += "=="; break; case 3: base64 += "="; break; } // Replace URL-safe characters with standard base64 characters base64 = base64.replace(/-/g, "+").replace(/_/g, "/"); // Decode using atob, then handle UTF-8 const binary = atob(base64); // Convert binary string to bytes, then to UTF-8 string const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } // Use TextDecoder for proper UTF-8 decoding return new TextDecoder("utf-8", { fatal: false }).decode(bytes); } /** * Parse the id_token JWT to extract Codex-specific auth information. * The workspace selection is embedded in the JWT after OAuth completion. * * Note: The OAuth flow already verified this token with OpenAI's server. * We only extract metadata (workspace info) from the already-validated token. */ function parseIdToken(idToken: string): { email: string | null; authInfo: CodexAuthInfo | null } { try { const parts = idToken.split("."); if (parts.length !== 3) { return { email: null, authInfo: null }; } // Decode payload with proper UTF-8 handling const decoded = JSON.parse(base64Decode(parts[1])); const email = decoded.email || null; // Extract Codex auth info from custom claim const authInfo = decoded["https://api.openai.com/auth"] || null; return { email, authInfo }; } catch (e) { return { email: null, authInfo: null }; } } export const codex = { config: CODEX_CONFIG, flowType: "authorization_code_pkce", fixedPort: 1455, callbackPath: "/auth/callback", buildAuthUrl: (config, redirectUri, state, codeChallenge) => { const params = { response_type: "code", client_id: config.clientId, redirect_uri: redirectUri, scope: config.scope, code_challenge: codeChallenge, code_challenge_method: config.codeChallengeMethod, ...config.extraParams, state: state, }; const queryString = Object.entries(params) .map(([key, value]) => `${key}=${encodeURIComponent(value as string)}`) .join("&"); return `${config.authorizeUrl}?${queryString}`; }, exchangeToken: async (config, code, redirectUri, codeVerifier) => { const response = await fetch(config.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: new URLSearchParams({ grant_type: "authorization_code", client_id: config.clientId, code: code, redirect_uri: redirectUri, code_verifier: codeVerifier, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Token exchange failed: ${error}`); } return await response.json(); }, /** * Post-exchange hook: Parse id_token to extract workspace info. * The workspace selected by the user during OAuth is embedded in the id_token. */ postExchange: async (tokens) => { if (!tokens.id_token) { return { authInfo: null }; } const { authInfo } = parseIdToken(tokens.id_token); return { authInfo }; }, mapTokens: (tokens, extra) => { // Parse id_token for email and auth info let email = null; let authInfo = extra?.authInfo || null; if (tokens.id_token) { const parsed = parseIdToken(tokens.id_token); email = parsed.email; // Use authInfo from postExchange if available, otherwise from parsing if (!authInfo && parsed.authInfo) { authInfo = parsed.authInfo; } } // Determine the correct workspace to use // // IMPORTANT: A user can have both Team and Personal workspaces. // The JWT's chatgpt_account_id may not always reflect the workspace // the user selected during OAuth. We need to be smart about selection. // // Selection logic: // 1. If plan_type indicates team/business, use chatgpt_account_id // 2. If plan_type is "free" but organizations has team workspace, use team // 3. Otherwise use chatgpt_account_id as fallback let workspaceId = authInfo?.chatgpt_account_id || null; let planType = (authInfo?.chatgpt_plan_type || "").toLowerCase(); // Check if we should use a team workspace instead const organizations = authInfo?.organizations || []; if (organizations.length > 0) { // Find team/business workspace (non-default usually means team) const teamOrg = organizations.find((org) => { const title = (org.title || "").toLowerCase(); const role = (org.role || "").toLowerCase(); // Team workspaces typically have role like "member" or "admin" and non-personal titles return ( !org.is_default && (title.includes("team") || title.includes("business") || title.includes("workspace") || title.includes("org") || role === "admin" || role === "member") ); }); // If user's plan_type is "team" or we found a team org, prefer it if (planType.includes("team") || planType.includes("chatgptteam")) { // User authenticated via Team, use the chatgpt_account_id from JWT } else if (teamOrg && (planType === "free" || planType === "")) { // User has a team org but plan_type shows free - use team org instead workspaceId = teamOrg.id; planType = "team"; } } const providerSpecificData = { workspaceId, workspacePlanType: planType, // Also store the full authInfo for future reference chatgptUserId: authInfo?.chatgpt_user_id || null, organizations: organizations.length > 0 ? organizations : null, }; return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, idToken: tokens.id_token, expiresIn: tokens.expires_in, email, // Persist workspace binding to prevent fallback to wrong workspace providerSpecificData, }; }, };