File size: 6,751 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
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,
    };
  },
};