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.
- codex_backend_sdk/__init__.py +72 -0
- codex_backend_sdk/codex_client.py +1084 -0
- codex_backend_sdk/oauth.py +299 -0
- codex_backend_sdk/pkce.py +29 -0
- codex_backend_sdk/storage.py +166 -0
- codex_backend_sdk-0.1.1.dist-info/METADATA +369 -0
- codex_backend_sdk-0.1.1.dist-info/RECORD +9 -0
- codex_backend_sdk-0.1.1.dist-info/WHEEL +4 -0
- codex_backend_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|