codex-backend-sdk 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ """
2
+ ChatGPT OAuth flow — mirrors codex-rs/login/src/server.rs.
3
+
4
+ Flow:
5
+ 1. Generate PKCE + state
6
+ 2. Build authorization URL → open browser
7
+ 3. Listen on localhost:1455 for the OAuth callback
8
+ 4. Exchange authorization code for tokens
9
+ 5. Optionally exchange id_token for an OpenAI API key
10
+ 6. Persist tokens to ~/.codex/auth.json
11
+ """
12
+
13
+ import threading
14
+ import urllib.parse
15
+ import webbrowser
16
+ from http.server import BaseHTTPRequestHandler, HTTPServer
17
+ from typing import Optional
18
+
19
+ import requests
20
+
21
+ from .pkce import PkceCodes, generate_pkce, generate_state
22
+ from .storage import TokenStore, save_tokens
23
+
24
+ ISSUER = "https://auth.openai.com"
25
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
26
+ CALLBACK_PORT = 1455
27
+ REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}/auth/callback"
28
+ SCOPES = "openid profile email offline_access api.connectors.read api.connectors.invoke"
29
+ ORIGINATOR = "codex_cli_rs"
30
+
31
+ # Result container shared between the HTTP handler and the caller
32
+ _oauth_result: dict = {}
33
+ _oauth_event = threading.Event() # set when code received
34
+ _server_done_event = threading.Event() # set when /success is served
35
+
36
+
37
+ def _build_authorize_url(
38
+ pkce: PkceCodes,
39
+ state: str,
40
+ workspace_id: Optional[str] = None,
41
+ scopes: Optional[str] = None,
42
+ ) -> str:
43
+ params = {
44
+ "response_type": "code",
45
+ "client_id": CLIENT_ID,
46
+ "redirect_uri": REDIRECT_URI,
47
+ "scope": scopes or SCOPES,
48
+ "code_challenge": pkce.code_challenge,
49
+ "code_challenge_method": "S256",
50
+ "id_token_add_organizations": "true",
51
+ "codex_cli_simplified_flow": "true",
52
+ "state": state,
53
+ "originator": ORIGINATOR,
54
+ }
55
+ if workspace_id:
56
+ params["allowed_workspace_id"] = workspace_id
57
+ # Use quote (RFC 3986, spaces → %20) not quote_plus (spaces → +),
58
+ # matching the urlencoding::encode behaviour in codex-rs/login/src/server.rs.
59
+ qs = "&".join(f"{k}={urllib.parse.quote(v, safe='')}" for k, v in params.items())
60
+ return f"{ISSUER}/oauth/authorize?{qs}"
61
+
62
+
63
+ def _make_handler(pkce: PkceCodes, state: str):
64
+ """Factory that returns an HTTP handler class closed over pkce/state."""
65
+
66
+ class _CallbackHandler(BaseHTTPRequestHandler):
67
+ def log_message(self, fmt, *args): # suppress default access log
68
+ pass
69
+
70
+ def do_GET(self):
71
+ parsed = urllib.parse.urlparse(self.path)
72
+ params = dict(urllib.parse.parse_qsl(parsed.query))
73
+
74
+ if parsed.path == "/auth/callback":
75
+ self._handle_callback(params)
76
+ elif parsed.path == "/success":
77
+ self._send_html(200, (
78
+ "<html><body style='font-family:sans-serif;padding:2em'>"
79
+ "<h2>✓ Login successful</h2>"
80
+ "<p>You can close this tab and return to the terminal.</p>"
81
+ "</body></html>"
82
+ ))
83
+ _server_done_event.set()
84
+ else:
85
+ self._send_text(404, "Not found")
86
+
87
+ def _handle_callback(self, params: dict):
88
+ if params.get("state") != state:
89
+ self._send_text(400, "State mismatch — possible CSRF attack.")
90
+ _oauth_result["error"] = "state_mismatch"
91
+ _oauth_event.set()
92
+ return
93
+
94
+ if error := params.get("error"):
95
+ desc = params.get("error_description", error)
96
+ self._send_html(400, f"<h2>Login failed</h2><p>{desc}</p>")
97
+ _oauth_result["error"] = desc
98
+ _oauth_event.set()
99
+ return
100
+
101
+ code = params.get("code", "")
102
+ if not code:
103
+ self._send_text(400, "Missing authorization code.")
104
+ _oauth_result["error"] = "missing_code"
105
+ _oauth_event.set()
106
+ return
107
+
108
+ # Redirect browser to /success while we process in background
109
+ self.send_response(302)
110
+ self.send_header("Location", f"http://localhost:{CALLBACK_PORT}/success")
111
+ self.end_headers()
112
+
113
+ _oauth_result["code"] = code
114
+ _oauth_event.set()
115
+
116
+ def _send_text(self, status: int, body: str):
117
+ data = body.encode()
118
+ self.send_response(status)
119
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
120
+ self.send_header("Content-Length", str(len(data)))
121
+ self.end_headers()
122
+ self.wfile.write(data)
123
+
124
+ def _send_html(self, status: int, body: str):
125
+ data = body.encode()
126
+ self.send_response(status)
127
+ self.send_header("Content-Type", "text/html; charset=utf-8")
128
+ self.send_header("Content-Length", str(len(data)))
129
+ self.end_headers()
130
+ self.wfile.write(data)
131
+
132
+ return _CallbackHandler
133
+
134
+
135
+ def _exchange_code(code: str, pkce: PkceCodes) -> dict:
136
+ """POST authorization code to token endpoint, return raw JSON."""
137
+ resp = requests.post(
138
+ f"{ISSUER}/oauth/token",
139
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
140
+ data={
141
+ "grant_type": "authorization_code",
142
+ "code": code,
143
+ "redirect_uri": REDIRECT_URI,
144
+ "client_id": CLIENT_ID,
145
+ "code_verifier": pkce.code_verifier,
146
+ },
147
+ timeout=30,
148
+ )
149
+ resp.raise_for_status()
150
+ return resp.json()
151
+
152
+
153
+ def obtain_api_key(id_token: str, access_token: Optional[str] = None) -> Optional[str]:
154
+ """
155
+ Exchange an id_token (or access_token fallback) for an OpenAI API key.
156
+ Returns the API key string, or None on failure.
157
+
158
+ Some accounts (personal API Platform orgs) don't embed organization_id in
159
+ the id_token. We first try with the id_token; if that fails with an org
160
+ error, we retry using the access_token (already scoped to api.openai.com/v1).
161
+ """
162
+ def _exchange(subject: str, token_type: str) -> Optional[str]:
163
+ resp = requests.post(
164
+ f"{ISSUER}/oauth/token",
165
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
166
+ data={
167
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
168
+ "client_id": CLIENT_ID,
169
+ "requested_token": "openai-api-key",
170
+ "subject_token": subject,
171
+ "subject_token_type": token_type,
172
+ },
173
+ timeout=30,
174
+ )
175
+ if resp.ok:
176
+ return resp.json().get("access_token")
177
+ short_type = token_type.split(":")[-1]
178
+ print(f"[auth] {short_type} exchange → {resp.status_code}: {resp.text}")
179
+ return None
180
+
181
+ try:
182
+ key = _exchange(id_token, "urn:ietf:params:oauth:token-type:id_token")
183
+ if key:
184
+ return key
185
+
186
+ if access_token:
187
+ print("[auth] retrying with access_token…")
188
+ key = _exchange(access_token, "urn:ietf:params:oauth:token-type:access_token")
189
+ if key:
190
+ return key
191
+
192
+ except Exception as exc:
193
+ print(f"[auth] API key exchange error: {exc}")
194
+ return None
195
+
196
+
197
+ def refresh_access_token(refresh_token: str) -> dict:
198
+ """
199
+ Use a refresh token to get a new access_token (and optionally a new refresh_token).
200
+ Returns the raw JSON response from the token endpoint.
201
+ """
202
+ resp = requests.post(
203
+ f"{ISSUER}/oauth/token",
204
+ headers={"Content-Type": "application/json"},
205
+ json={
206
+ "client_id": CLIENT_ID,
207
+ "grant_type": "refresh_token",
208
+ "refresh_token": refresh_token,
209
+ "scope": "openid profile email offline_access",
210
+ },
211
+ timeout=30,
212
+ )
213
+ resp.raise_for_status()
214
+ return resp.json()
215
+
216
+
217
+ def run_oauth_flow(
218
+ *,
219
+ open_browser: bool = True,
220
+ request_api_key: bool = True,
221
+ persist: bool = True,
222
+ workspace_id: Optional[str] = None,
223
+ scopes: Optional[str] = None,
224
+ ) -> TokenStore:
225
+ """
226
+ Run the full OAuth authorization-code + PKCE flow.
227
+
228
+ Opens the browser (unless open_browser=False), waits for the callback,
229
+ exchanges the code for tokens, optionally fetches an API key,
230
+ and saves everything to ~/.codex/auth.json.
231
+
232
+ Returns a TokenStore with all credentials.
233
+ """
234
+ global _oauth_result, _oauth_event, _server_done_event
235
+ _oauth_result = {}
236
+ _oauth_event = threading.Event()
237
+ _server_done_event = threading.Event()
238
+
239
+ pkce = generate_pkce()
240
+ state = generate_state()
241
+
242
+ handler_cls = _make_handler(pkce, state)
243
+ try:
244
+ server = HTTPServer(("127.0.0.1", CALLBACK_PORT), handler_cls)
245
+ except OSError as exc:
246
+ raise RuntimeError(
247
+ f"Cannot bind to localhost:{CALLBACK_PORT} — "
248
+ "another process may still be holding that port from a previous login attempt. "
249
+ f"(OS error: {exc})"
250
+ ) from exc
251
+ server.timeout = 1 # allow periodic _oauth_event checks
252
+
253
+ auth_url = _build_authorize_url(pkce, state, workspace_id=workspace_id, scopes=scopes)
254
+ print(f"\n[auth] Opening browser for login:\n {auth_url}\n")
255
+
256
+ if open_browser:
257
+ webbrowser.open(auth_url)
258
+ else:
259
+ print("[auth] open_browser=False — open the URL above manually.")
260
+
261
+ print("[auth] Waiting for OAuth callback on localhost:1455 …")
262
+ # Keep serving until /success is served (happy path) or callback signals an error
263
+ while not _server_done_event.is_set():
264
+ server.handle_request()
265
+ if _oauth_event.is_set() and _oauth_result.get("error"):
266
+ break
267
+
268
+ server.server_close()
269
+
270
+ if error := _oauth_result.get("error"):
271
+ raise RuntimeError(f"OAuth callback error: {error}")
272
+
273
+ code = _oauth_result["code"]
274
+ print("[auth] Authorization code received — exchanging for tokens …")
275
+
276
+ raw = _exchange_code(code, pkce)
277
+ access_token = raw["access_token"]
278
+ refresh_token = raw["refresh_token"]
279
+ id_token = raw["id_token"]
280
+
281
+ api_key: Optional[str] = None
282
+ if request_api_key:
283
+ print("[auth] Exchanging for OpenAI API key …")
284
+ api_key = obtain_api_key(id_token, access_token)
285
+ if api_key:
286
+ print(f"[auth] API key obtained: {api_key[:8]}…")
287
+
288
+ store = TokenStore.from_exchange(
289
+ access_token=access_token,
290
+ refresh_token=refresh_token,
291
+ id_token=id_token,
292
+ api_key=api_key,
293
+ )
294
+
295
+ if persist:
296
+ save_tokens(store)
297
+ print("[auth] Tokens saved to ~/.codex/auth.json")
298
+
299
+ return store
@@ -0,0 +1,29 @@
1
+ """PKCE (Proof Key for Code Exchange) helpers — mirrors codex-rs/login/src/pkce.rs."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class PkceCodes:
11
+ code_verifier: str
12
+ code_challenge: str
13
+
14
+
15
+ def generate_pkce() -> PkceCodes:
16
+ """Generate a PKCE pair using S256 method."""
17
+ raw = os.urandom(64)
18
+ code_verifier = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
19
+
20
+ digest = hashlib.sha256(code_verifier.encode()).digest()
21
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
22
+
23
+ return PkceCodes(code_verifier=code_verifier, code_challenge=code_challenge)
24
+
25
+
26
+ def generate_state() -> str:
27
+ """Generate a cryptographically random state parameter."""
28
+ raw = os.urandom(32)
29
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
@@ -0,0 +1,166 @@
1
+ """Token persistence — mirrors codex-rs/login/src/auth/storage.rs.
2
+
3
+ Stores tokens in ~/.codex/auth.json (same format as the Codex CLI).
4
+ """
5
+
6
+ import base64
7
+ import json
8
+ import os
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ REFRESH_EXPIRY_MARGIN_S = 5 * 60 # refresh if exp within 5 minutes
15
+ REFRESH_INTERVAL_S = 55 * 60 # refresh at least every 55 minutes
16
+
17
+
18
+ def _codex_home() -> Path:
19
+ if env := os.environ.get("CODEX_HOME"):
20
+ return Path(env)
21
+ return Path.home() / ".codex"
22
+
23
+
24
+ def _auth_path() -> Path:
25
+ return _codex_home() / "auth.json"
26
+
27
+
28
+ def _decode_jwt_payload(token: str) -> dict:
29
+ """Decode the payload section of a JWT without verification."""
30
+ parts = token.split(".")
31
+ if len(parts) < 2:
32
+ return {}
33
+ payload_b64 = parts[1]
34
+ # Add padding if needed
35
+ padding = 4 - len(payload_b64) % 4
36
+ if padding != 4:
37
+ payload_b64 += "=" * padding
38
+ try:
39
+ raw = base64.urlsafe_b64decode(payload_b64)
40
+ return json.loads(raw)
41
+ except Exception:
42
+ return {}
43
+
44
+
45
+ def _extract_openai_claims(token: str) -> dict:
46
+ """Extract the nested 'https://api.openai.com/auth' claims from a JWT."""
47
+ payload = _decode_jwt_payload(token)
48
+ return payload.get("https://api.openai.com/auth", {})
49
+
50
+
51
+ @dataclass
52
+ class TokenStore:
53
+ access_token: str
54
+ refresh_token: str
55
+ id_token_raw: str
56
+ account_id: Optional[str] = None
57
+ openai_api_key: Optional[str] = None
58
+ last_refresh: Optional[str] = None
59
+ # Decoded claims from id_token for convenience
60
+ email: Optional[str] = None
61
+ plan_type: Optional[str] = None
62
+
63
+ @classmethod
64
+ def from_exchange(
65
+ cls,
66
+ access_token: str,
67
+ refresh_token: str,
68
+ id_token: str,
69
+ api_key: Optional[str] = None,
70
+ ) -> "TokenStore":
71
+ claims = _extract_openai_claims(id_token)
72
+ access_claims = _extract_openai_claims(access_token)
73
+
74
+ return cls(
75
+ access_token=access_token,
76
+ refresh_token=refresh_token,
77
+ id_token_raw=id_token,
78
+ account_id=claims.get("chatgpt_account_id"),
79
+ openai_api_key=api_key,
80
+ last_refresh=datetime.now(timezone.utc).isoformat(),
81
+ email=_decode_jwt_payload(id_token).get("email"),
82
+ plan_type=access_claims.get("chatgpt_plan_type"),
83
+ )
84
+
85
+
86
+ def token_needs_refresh(store: TokenStore) -> bool:
87
+ """
88
+ Return True if the access token should be refreshed before use.
89
+
90
+ Mirrors openai-oauth auth.ts shouldRefreshAccessToken:
91
+ - exp claim within REFRESH_EXPIRY_MARGIN_S seconds → refresh
92
+ - last_refresh older than REFRESH_INTERVAL_S → refresh
93
+ """
94
+ now = datetime.now(timezone.utc).timestamp()
95
+
96
+ claims = _decode_jwt_payload(store.access_token)
97
+ exp = claims.get("exp")
98
+ if isinstance(exp, (int, float)):
99
+ if exp <= now + REFRESH_EXPIRY_MARGIN_S:
100
+ return True
101
+ # Token has a valid exp and it's not near expiry — trust it
102
+ return False
103
+
104
+ # No exp claim — fall back to last_refresh timestamp
105
+ if store.last_refresh:
106
+ try:
107
+ refreshed_at = datetime.fromisoformat(store.last_refresh)
108
+ if (datetime.now(timezone.utc) - refreshed_at).total_seconds() >= REFRESH_INTERVAL_S:
109
+ return True
110
+ except Exception:
111
+ pass
112
+
113
+ return False
114
+
115
+
116
+ def save_tokens(store: TokenStore) -> None:
117
+ path = _auth_path()
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+
120
+ payload = {
121
+ "auth_mode": "chatgpt",
122
+ "openai_api_key": store.openai_api_key,
123
+ "tokens": {
124
+ "access_token": store.access_token,
125
+ "refresh_token": store.refresh_token,
126
+ "id_token": store.id_token_raw,
127
+ "account_id": store.account_id,
128
+ },
129
+ "last_refresh": store.last_refresh,
130
+ }
131
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
132
+
133
+ # Restrict permissions on Unix
134
+ try:
135
+ os.chmod(path, 0o600)
136
+ except OSError:
137
+ pass
138
+
139
+
140
+ def load_tokens() -> Optional[TokenStore]:
141
+ path = _auth_path()
142
+ if not path.exists():
143
+ return None
144
+ try:
145
+ data = json.loads(path.read_text(encoding="utf-8"))
146
+ except Exception:
147
+ return None
148
+
149
+ tokens = data.get("tokens") or {}
150
+ access_token = tokens.get("access_token", "")
151
+ refresh_token = tokens.get("refresh_token", "")
152
+ id_token = tokens.get("id_token", "")
153
+
154
+ if not access_token:
155
+ return None
156
+
157
+ return TokenStore(
158
+ access_token=access_token,
159
+ refresh_token=refresh_token,
160
+ id_token_raw=id_token,
161
+ account_id=tokens.get("account_id"),
162
+ openai_api_key=data.get("openai_api_key"),
163
+ last_refresh=data.get("last_refresh"),
164
+ email=_decode_jwt_payload(id_token).get("email"),
165
+ plan_type=_extract_openai_claims(access_token).get("chatgpt_plan_type"),
166
+ )