register_oauth.py 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243
  1. """OAuth helpers for ChatGPT registration."""
  2. from __future__ import annotations
  3. import base64
  4. from datetime import datetime
  5. import json
  6. import re
  7. import time
  8. import urllib.parse
  9. from typing import Any
  10. from .constants import OPENAI_API_ENDPOINTS, OPENAI_PAGE_TYPES
  11. from .http_client import OpenAIHTTPClient
  12. from .oauth import submit_callback_url
  13. from .token_refresh import TokenRefreshManager
  14. def _set_add_phone_trace_fields(self, **updates: Any) -> None:
  15. setter = getattr(self, "_set_add_phone_trace", None)
  16. if callable(setter):
  17. setter(**updates)
  18. def _append_add_phone_trace_attempt(self, payload: dict[str, Any]) -> None:
  19. appender = getattr(self, "_append_add_phone_attempt", None)
  20. if callable(appender):
  21. appender(payload)
  22. def _capture_add_phone_html_if_present(
  23. self,
  24. *,
  25. session: Any,
  26. url: str,
  27. label: str,
  28. referer: str | None = None,
  29. ) -> str:
  30. capturer = getattr(self, "_capture_add_phone_html", None)
  31. if not callable(capturer):
  32. return ""
  33. candidate_url = self._auth_url(url)
  34. if not candidate_url or "add-phone" not in candidate_url:
  35. return ""
  36. try:
  37. response = session.get(
  38. candidate_url,
  39. timeout=15,
  40. allow_redirects=True,
  41. headers={"referer": referer} if referer else None,
  42. )
  43. except Exception as exc:
  44. self._log(f"{label}: add-phone html capture failed: {exc}")
  45. return ""
  46. final_url = self._auth_url(str(getattr(response, "url", "") or candidate_url))
  47. html = str(getattr(response, "text", "") or "")
  48. if "add-phone" not in final_url and "add-phone" not in html:
  49. return ""
  50. return str(capturer(label=label, url=final_url, html=html) or "")
  51. def _extract_callback_url(self, value: str) -> str | None:
  52. candidate = str(value or "").strip()
  53. if not candidate:
  54. return None
  55. candidate = self._auth_url(candidate)
  56. parsed = urllib.parse.urlparse(candidate)
  57. query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
  58. code = str((query.get("code") or [""])[0] or "").strip()
  59. state = str((query.get("state") or [""])[0] or "").strip()
  60. if code and state:
  61. return candidate
  62. return None
  63. def _extract_callback_url_from_error(self, exc: Exception) -> str | None:
  64. matched = re.search(r"(https?://localhost[^\s'\"\\]+)", str(exc))
  65. if not matched:
  66. return None
  67. return self._extract_callback_url(matched.group(1))
  68. def _extract_session_token(self, session: Any | None = None) -> str | None:
  69. target_session = session or self.session
  70. if target_session is None:
  71. return None
  72. cookies = getattr(target_session, "cookies", None)
  73. if cookies is None:
  74. return None
  75. for cookie_name in ("__Secure-next-auth.session-token", "next-auth.session-token"):
  76. try:
  77. cookie_value = str(cookies.get(cookie_name) or "").strip()
  78. except Exception:
  79. cookie_value = ""
  80. if cookie_value:
  81. return cookie_value
  82. jar = getattr(cookies, "jar", None)
  83. if jar is None:
  84. return None
  85. for item in list(jar):
  86. name = str(getattr(item, "name", "") or "").strip()
  87. if name not in {"__Secure-next-auth.session-token", "next-auth.session-token"}:
  88. continue
  89. value = str(getattr(item, "value", "") or "").strip()
  90. if value:
  91. return value
  92. return None
  93. def _refresh_tokens_from_session_cookie(
  94. self,
  95. session: Any | None = None,
  96. *,
  97. label: str,
  98. ) -> dict[str, Any] | None:
  99. session_token = self._extract_session_token(session)
  100. if not session_token:
  101. self._log(f"{label}: session token missing")
  102. return None
  103. self._log(f"{label}: session token detected")
  104. refresh_result = TokenRefreshManager(proxy_url=self.proxy_url).refresh_by_session_token(session_token)
  105. if not refresh_result.success:
  106. self._log(f"{label}: session token refresh failed: {refresh_result.error_message}")
  107. return None
  108. self._log(f"{label}: session token refresh succeeded")
  109. expired = ""
  110. if refresh_result.expires_at is not None:
  111. expired = refresh_result.expires_at.strftime("%Y-%m-%dT%H:%M:%SZ")
  112. return {
  113. "access_token": refresh_result.access_token,
  114. "refresh_token": refresh_result.refresh_token,
  115. "id_token": "",
  116. "account_id": refresh_result.account_id,
  117. "email": refresh_result.email or str(self.email or "").strip(),
  118. "expired": expired,
  119. "last_refresh": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
  120. "session_token": refresh_result.session_token or session_token,
  121. }
  122. def _decode_oauth_session_cookie(self, session: Any | None = None) -> dict[str, Any] | None:
  123. target_session = session or self.session
  124. if target_session is None:
  125. return None
  126. cookies = getattr(target_session, "cookies", None)
  127. if cookies is None:
  128. return None
  129. jar = getattr(cookies, "jar", None)
  130. cookie_items = list(jar) if jar is not None else []
  131. raw_cookie = str(cookies.get("oai-client-auth-session") or "").strip()
  132. if raw_cookie:
  133. cookie_items.insert(0, type("CookieItem", (), {"name": "oai-client-auth-session", "value": raw_cookie})())
  134. for item in cookie_items:
  135. name = str(getattr(item, "name", "") or "").strip()
  136. if "oai-client-auth-session" not in name:
  137. continue
  138. raw_value = str(getattr(item, "value", "") or "").strip()
  139. if not raw_value:
  140. continue
  141. candidates = [raw_value]
  142. try:
  143. from urllib.parse import unquote
  144. decoded = unquote(raw_value)
  145. if decoded != raw_value:
  146. candidates.append(decoded)
  147. except Exception:
  148. pass
  149. for candidate in candidates:
  150. try:
  151. value = candidate
  152. if (value.startswith('"') and value.endswith('"')) or (
  153. value.startswith("'") and value.endswith("'")
  154. ):
  155. value = value[1:-1]
  156. part = value.split(".")[0] if "." in value else value
  157. pad = "=" * ((4 - (len(part) % 4)) % 4)
  158. decoded = base64.urlsafe_b64decode((part + pad).encode("ascii"))
  159. data = json.loads(decoded.decode("utf-8"))
  160. if isinstance(data, dict):
  161. return data
  162. except Exception:
  163. continue
  164. return None
  165. def _fetch_client_auth_session_dump(
  166. self,
  167. session: Any | None = None,
  168. *,
  169. referer: str = "https://auth.openai.com/add-phone",
  170. ) -> dict[str, Any] | None:
  171. target_session = session or self.session
  172. if target_session is None:
  173. return None
  174. cookies = getattr(target_session, "cookies", None)
  175. checksum_cookie = ""
  176. if cookies is not None:
  177. try:
  178. checksum_cookie = str(cookies.get("auth-session-minimized-client-checksum") or "").strip()
  179. except Exception:
  180. checksum_cookie = ""
  181. if not checksum_cookie:
  182. self._log("client auth session dump: minimized checksum cookie missing")
  183. try:
  184. response = target_session.get(
  185. "https://auth.openai.com/api/accounts/client_auth_session_dump",
  186. headers={
  187. "accept": "application/json",
  188. "referer": referer,
  189. "user-agent": self.http_client.default_headers.get("User-Agent", "Mozilla/5.0"),
  190. "sec-ch-ua": self.http_client.default_headers.get("sec-ch-ua", ""),
  191. "sec-ch-ua-mobile": self.http_client.default_headers.get("sec-ch-ua-mobile", ""),
  192. "sec-ch-ua-platform": self.http_client.default_headers.get("sec-ch-ua-platform", ""),
  193. },
  194. timeout=15,
  195. )
  196. except Exception as exc:
  197. self._log(f"client auth session dump failed: {exc}")
  198. return None
  199. self._log(f"client auth session dump status: {response.status_code}")
  200. if response.status_code != 200:
  201. self._log(f"client auth session dump body: {str(getattr(response, 'text', '') or '')[:240]}")
  202. _set_add_phone_trace_fields(self, auth_session_dump_status=response.status_code)
  203. return None
  204. try:
  205. payload = response.json()
  206. except Exception as exc:
  207. raw_text = str(getattr(response, "text", "") or "")
  208. parsed = None
  209. if raw_text:
  210. candidates = [raw_text]
  211. first_brace = raw_text.find("{")
  212. if first_brace > 0:
  213. candidates.append(raw_text[first_brace:])
  214. for candidate in candidates:
  215. try:
  216. parsed = json.loads(candidate)
  217. break
  218. except Exception:
  219. continue
  220. if parsed is None:
  221. self._log(f"client auth session dump parse failed: {exc}; body={raw_text[:240]}")
  222. return None
  223. payload = parsed
  224. session_payload = payload.get("client_auth_session") if isinstance(payload, dict) else None
  225. if not isinstance(session_payload, dict):
  226. self._log("client auth session dump missing client_auth_session payload")
  227. return None
  228. workspaces = session_payload.get("workspaces") or []
  229. _set_add_phone_trace_fields(
  230. self,
  231. auth_session_dump_status=response.status_code,
  232. auth_session_dump_keys=sorted(str(key) for key in payload.keys()) if isinstance(payload, dict) else [],
  233. auth_session_dump_workspace_count=len(workspaces) if isinstance(workspaces, list) else 0,
  234. auth_session_dump_session_id=str(payload.get("session_id") or "").strip() if isinstance(payload, dict) else "",
  235. )
  236. self._log(
  237. "client auth session dump workspace count: "
  238. f"{len(workspaces) if isinstance(workspaces, list) else 0}"
  239. )
  240. return session_payload
  241. def _load_oauth_session_payload(
  242. self,
  243. session: Any | None = None,
  244. *,
  245. referer: str = "https://auth.openai.com/add-phone",
  246. ) -> dict[str, Any] | None:
  247. payload = self._decode_oauth_session_cookie(session)
  248. workspaces = (payload or {}).get("workspaces") or []
  249. if payload and isinstance(workspaces, list) and workspaces:
  250. return payload
  251. dump_payload = self._fetch_client_auth_session_dump(session, referer=referer)
  252. if isinstance(dump_payload, dict):
  253. dump_workspaces = dump_payload.get("workspaces") or []
  254. if isinstance(dump_workspaces, list) and dump_workspaces:
  255. return dump_payload
  256. if not payload:
  257. return dump_payload
  258. return payload
  259. def _login_for_token(self) -> dict[str, Any] | None:
  260. """Login with email+password in a fresh session to obtain OAuth tokens."""
  261. if not self.email or not self.password:
  262. self._log("login_for_token: email or password missing")
  263. return None
  264. try:
  265. attempt_index = int(getattr(self, "_add_phone_oauth_attempt_counter", 0) or 0)
  266. attempt_trace: dict[str, Any] = {
  267. "attempt": attempt_index or 1,
  268. "authorize_continue_status": None,
  269. "password_verify_status": None,
  270. "otp_validate_status": None,
  271. "workspace_select_status": None,
  272. "organization_select_status": None,
  273. "final_continue_url": "",
  274. "final_page_type": "",
  275. "callback_found": False,
  276. "session_token_found": False,
  277. }
  278. login_client = OpenAIHTTPClient(proxy_url=self.proxy_url)
  279. login_session = login_client.session
  280. login_oauth = self.oauth_manager.start_oauth()
  281. self._log("login_for_token: new oauth flow started")
  282. authorize_params = dict(
  283. urllib.parse.parse_qsl(
  284. urllib.parse.urlparse(login_oauth.auth_url).query,
  285. keep_blank_values=True,
  286. )
  287. )
  288. def _refresh_login_session() -> Any:
  289. nonlocal login_client, login_session
  290. cookie_pairs: dict[str, str] = {}
  291. try:
  292. cookies = getattr(login_session, "cookies", None)
  293. if cookies is not None:
  294. jar = getattr(cookies, "jar", None)
  295. if jar is not None:
  296. for item in list(jar):
  297. name = str(getattr(item, "name", "") or "").strip()
  298. value = str(getattr(item, "value", "") or "").strip()
  299. if name:
  300. cookie_pairs[name] = value
  301. for key, value in dict(cookies).items():
  302. if key:
  303. cookie_pairs[str(key)] = str(value)
  304. except Exception:
  305. pass
  306. try:
  307. login_client.close()
  308. except Exception:
  309. pass
  310. login_client = OpenAIHTTPClient(proxy_url=self.proxy_url)
  311. login_session = login_client.session
  312. try:
  313. login_session.cookies.update(cookie_pairs)
  314. except Exception:
  315. pass
  316. return login_session
  317. def _bootstrap_oauth_session() -> str:
  318. nonlocal login_session
  319. response, login_session = self._session_request(
  320. session=login_session,
  321. method="GET",
  322. url=login_oauth.auth_url,
  323. label="login_for_token: oauth bootstrap",
  324. refresh_session=_refresh_login_session,
  325. timeout=15,
  326. allow_redirects=True,
  327. )
  328. final_url = str(getattr(response, "url", "") or login_oauth.auth_url)
  329. has_login_session = bool(str(login_session.cookies.get("login_session") or "").strip())
  330. if not has_login_session:
  331. response, login_session = self._session_request(
  332. session=login_session,
  333. method="GET",
  334. url="https://auth.openai.com/api/oauth/oauth2/auth",
  335. label="login_for_token: oauth bootstrap fallback",
  336. refresh_session=_refresh_login_session,
  337. params=authorize_params,
  338. timeout=15,
  339. allow_redirects=True,
  340. )
  341. final_url = str(getattr(response, "url", "") or final_url)
  342. return final_url
  343. def _resolve_callback_from_response(response: Any, referer: str) -> str | None:
  344. location = self._auth_url(str(response.headers.get("Location") or "").strip())
  345. if response.status_code in {301, 302, 303, 307, 308} and location:
  346. self._log(f"login_for_token: redirect -> {location[:120]}")
  347. return self._extract_callback_url(location) or self._follow_redirects_with_session(
  348. login_session,
  349. location,
  350. referer=referer,
  351. )
  352. return None
  353. authorize_final_url = _bootstrap_oauth_session()
  354. device_id = str(login_session.cookies.get("oai-did") or "").strip()
  355. self._log(f"login_for_token: device_id={'yes' if device_id else 'no'}")
  356. if not device_id:
  357. self._log("login_for_token: missing device_id after oauth bootstrap")
  358. return None
  359. authorize_sentinel = login_client.check_sentinel(device_id, flow="authorize_continue")
  360. if not authorize_sentinel:
  361. self._log("login_for_token: authorize sentinel unavailable")
  362. return None
  363. continue_referer = authorize_final_url if authorize_final_url.startswith("https://auth.openai.com") else "https://auth.openai.com/log-in"
  364. login_headers = self._oauth_json_headers(referer=continue_referer, device_id=device_id)
  365. login_headers["openai-sentinel-token"] = self._build_sentinel_header(
  366. authorize_sentinel,
  367. device_id,
  368. "authorize_continue",
  369. client=login_client,
  370. )
  371. login_resp, login_session = self._session_request(
  372. session=login_session,
  373. method="POST",
  374. url=OPENAI_API_ENDPOINTS["signup"],
  375. label="login_for_token: authorize continue",
  376. refresh_session=_refresh_login_session,
  377. headers=login_headers,
  378. json={"username": {"kind": "email", "value": self.email}},
  379. timeout=15,
  380. allow_redirects=False,
  381. )
  382. self._log(f"login_for_token: authorize continue status={login_resp.status_code}")
  383. attempt_trace["authorize_continue_status"] = login_resp.status_code
  384. if login_resp.status_code == 400 and "invalid_auth_step" in (login_resp.text or ""):
  385. authorize_final_url = _bootstrap_oauth_session()
  386. continue_referer = authorize_final_url if authorize_final_url.startswith("https://auth.openai.com") else "https://auth.openai.com/log-in"
  387. login_headers = self._oauth_json_headers(referer=continue_referer, device_id=device_id)
  388. login_headers["openai-sentinel-token"] = self._build_sentinel_header(
  389. authorize_sentinel,
  390. device_id,
  391. "authorize_continue",
  392. client=login_client,
  393. )
  394. login_resp, login_session = self._session_request(
  395. session=login_session,
  396. method="POST",
  397. url=OPENAI_API_ENDPOINTS["signup"],
  398. label="login_for_token: authorize continue retry",
  399. refresh_session=_refresh_login_session,
  400. headers=login_headers,
  401. json={"username": {"kind": "email", "value": self.email}},
  402. timeout=15,
  403. allow_redirects=False,
  404. )
  405. self._log(f"login_for_token: authorize continue retry status={login_resp.status_code}")
  406. attempt_trace["authorize_continue_status"] = login_resp.status_code
  407. if login_resp.status_code != 200:
  408. self._log(f"login_for_token: authorize continue body={login_resp.text[:240]}")
  409. _append_add_phone_trace_attempt(self, attempt_trace)
  410. return None
  411. try:
  412. login_data = login_resp.json()
  413. except Exception as exc:
  414. self._log(f"login_for_token: authorize continue parse failed: {exc}")
  415. return None
  416. continue_url = self._auth_url(str(login_data.get("continue_url") or "").strip())
  417. page_type = str(((login_data.get("page") or {}).get("type")) or "").strip()
  418. password_sentinel = login_client.check_sentinel(device_id, flow="password_verify")
  419. if not password_sentinel:
  420. self._log("login_for_token: password sentinel unavailable")
  421. return None
  422. oauth_otp_before_ids = self._capture_mailbox_ids()
  423. self._log(f"login_for_token: oauth otp baseline ids={len(oauth_otp_before_ids)}")
  424. password_headers = self._oauth_json_headers(
  425. referer="https://auth.openai.com/log-in/password",
  426. device_id=device_id,
  427. )
  428. password_headers["openai-sentinel-token"] = self._build_sentinel_header(
  429. password_sentinel,
  430. device_id,
  431. "password_verify",
  432. client=login_client,
  433. )
  434. pw_resp, login_session = self._session_request(
  435. session=login_session,
  436. method="POST",
  437. url=OPENAI_API_ENDPOINTS["password_verify"],
  438. label="login_for_token: password verify",
  439. refresh_session=_refresh_login_session,
  440. headers=password_headers,
  441. json={"password": self.password},
  442. timeout=15,
  443. allow_redirects=False,
  444. )
  445. self._log(f"login_for_token: password verify status={pw_resp.status_code}")
  446. attempt_trace["password_verify_status"] = pw_resp.status_code
  447. if pw_resp.status_code != 200:
  448. self._log(f"login_for_token: password verify body={pw_resp.text[:500]}")
  449. _append_add_phone_trace_attempt(self, attempt_trace)
  450. return None
  451. try:
  452. pw_data = pw_resp.json()
  453. except Exception as exc:
  454. self._log(f"login_for_token: password verify parse failed: {exc}")
  455. return None
  456. continue_url = self._auth_url(str(pw_data.get("continue_url") or continue_url or "").strip())
  457. page_type = str(((pw_data.get("page") or {}).get("type")) or page_type or "").strip()
  458. self._log(
  459. "login_for_token: continue_url="
  460. f"{continue_url[:120] if continue_url else 'none'}, page={page_type or 'unknown'}"
  461. )
  462. need_oauth_otp = (
  463. page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]
  464. or "email-verification" in (continue_url or "")
  465. or "email-otp" in (continue_url or "")
  466. )
  467. if need_oauth_otp:
  468. self._log("login_for_token: oauth email verification required")
  469. # Solution C: Try session cookie refresh BEFORE OTP wait.
  470. # After password_verify, the login session may already have a
  471. # usable session cookie, letting us skip the second OTP entirely.
  472. try:
  473. pre_otp_token = self._refresh_tokens_from_session_cookie(
  474. login_session, label="login_for_token:pre-otp-session"
  475. )
  476. if pre_otp_token:
  477. self._log("login_for_token: session cookie refresh bypassed OTP requirement")
  478. return pre_otp_token
  479. except Exception as exc:
  480. self._log(f"login_for_token: pre-otp session refresh failed: {exc}")
  481. otp_code = self._wait_for_mailbox_code(
  482. before_ids=oauth_otp_before_ids,
  483. timeout=self._add_phone_oauth_otp_timeout_seconds,
  484. keyword="openai",
  485. )
  486. mailbox, _acct = self._mailbox_context()
  487. if mailbox is not None:
  488. diag = dict(getattr(mailbox, "last_wait_diagnostics", {}) or {})
  489. self._log(
  490. f"login_for_token: oauth otp diagnostics: "
  491. f"polls={diag.get('poll_count', '?')} "
  492. f"scanned={diag.get('message_scan_count', '?')} "
  493. f"first_seen_after={diag.get('first_message_seen_at', '-')}"
  494. )
  495. if not otp_code:
  496. self._log("login_for_token: oauth email verification code not received")
  497. return None
  498. self._log(f"login_for_token: oauth otp received {otp_code}")
  499. otp_headers = self._oauth_json_headers(
  500. referer="https://auth.openai.com/email-verification",
  501. device_id=device_id,
  502. )
  503. otp_resp, login_session = self._session_request(
  504. session=login_session,
  505. method="POST",
  506. url=OPENAI_API_ENDPOINTS["validate_otp"],
  507. label="login_for_token: oauth otp validate",
  508. refresh_session=_refresh_login_session,
  509. headers=otp_headers,
  510. json={"code": otp_code},
  511. timeout=15,
  512. allow_redirects=False,
  513. )
  514. self._log(f"login_for_token: oauth otp validate status={otp_resp.status_code}")
  515. attempt_trace["otp_validate_status"] = otp_resp.status_code
  516. if otp_resp.status_code != 200:
  517. self._log(f"login_for_token: oauth otp validate body={otp_resp.text[:500]}")
  518. _append_add_phone_trace_attempt(self, attempt_trace)
  519. return None
  520. try:
  521. otp_data = otp_resp.json()
  522. except Exception as exc:
  523. self._log(f"login_for_token: oauth otp parse failed: {exc}")
  524. return None
  525. continue_url = self._auth_url(str(otp_data.get("continue_url") or continue_url or "").strip())
  526. page_type = str(((otp_data.get("page") or {}).get("type")) or page_type or "").strip()
  527. self._log(
  528. "login_for_token: oauth otp continue_url="
  529. f"{continue_url[:120] if continue_url else 'none'}, page={page_type or 'unknown'}"
  530. )
  531. attempt_trace["final_continue_url"] = continue_url
  532. attempt_trace["final_page_type"] = page_type
  533. callback_url = self._extract_callback_url(continue_url)
  534. if not callback_url and continue_url:
  535. callback_url = self._follow_redirects_with_session(
  536. login_session,
  537. continue_url,
  538. referer="https://auth.openai.com/log-in/password",
  539. )
  540. consent_hint = any(
  541. hint
  542. for hint in (
  543. "consent" in (continue_url or ""),
  544. "workspace" in (continue_url or ""),
  545. "organization" in (continue_url or ""),
  546. "consent" in page_type,
  547. "organization" in page_type,
  548. )
  549. )
  550. if not callback_url and consent_hint:
  551. session_data = self._load_oauth_session_payload(
  552. login_session,
  553. referer=continue_url or "https://auth.openai.com/add-phone",
  554. ) or {}
  555. workspaces = session_data.get("workspaces") or []
  556. workspace_id = ""
  557. if workspaces and isinstance(workspaces, list):
  558. workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
  559. if not workspace_id:
  560. self._log("login_for_token: workspace id missing in oauth session cookie")
  561. else:
  562. workspace_referer = continue_url or "https://auth.openai.com/sign-in-with-chatgpt/codex/consent"
  563. workspace_headers = self._oauth_json_headers(
  564. referer=workspace_referer,
  565. device_id=device_id,
  566. )
  567. ws_resp, login_session = self._session_request(
  568. session=login_session,
  569. method="POST",
  570. url=OPENAI_API_ENDPOINTS["select_workspace"],
  571. label="login_for_token: workspace select",
  572. refresh_session=_refresh_login_session,
  573. headers=workspace_headers,
  574. json={"workspace_id": workspace_id},
  575. timeout=15,
  576. allow_redirects=False,
  577. )
  578. self._log(f"login_for_token: workspace select status={ws_resp.status_code}")
  579. attempt_trace["workspace_select_status"] = ws_resp.status_code
  580. callback_url = _resolve_callback_from_response(ws_resp, workspace_referer)
  581. if not callback_url and ws_resp.status_code == 200:
  582. try:
  583. ws_data = ws_resp.json()
  584. except Exception as exc:
  585. self._log(f"login_for_token: workspace select parse failed: {exc}")
  586. ws_data = {}
  587. ws_next = self._auth_url(str(ws_data.get("continue_url") or "").strip())
  588. orgs = ((ws_data.get("data") or {}).get("orgs") or []) if isinstance(ws_data, dict) else []
  589. self._log(
  590. "login_for_token: workspace select continue_url="
  591. f"{ws_next[:120] if ws_next else 'none'}, org_count={len(orgs) if isinstance(orgs, list) else 0}"
  592. )
  593. if orgs and isinstance(orgs, list):
  594. first_org = orgs[0] or {}
  595. org_id = str(first_org.get("id") or "").strip()
  596. projects = first_org.get("projects") or []
  597. project_id = ""
  598. if projects and isinstance(projects, list):
  599. project_id = str((projects[0] or {}).get("id") or "").strip()
  600. if org_id:
  601. org_body = {"org_id": org_id}
  602. if project_id:
  603. org_body["project_id"] = project_id
  604. org_referer = ws_next or workspace_referer
  605. org_headers = self._oauth_json_headers(
  606. referer=org_referer,
  607. device_id=device_id,
  608. )
  609. org_resp, login_session = self._session_request(
  610. session=login_session,
  611. method="POST",
  612. url=OPENAI_API_ENDPOINTS["select_organization"],
  613. label="login_for_token: organization select",
  614. refresh_session=_refresh_login_session,
  615. headers=org_headers,
  616. json=org_body,
  617. timeout=15,
  618. allow_redirects=False,
  619. )
  620. self._log(f"login_for_token: organization select status={org_resp.status_code}")
  621. attempt_trace["organization_select_status"] = org_resp.status_code
  622. callback_url = _resolve_callback_from_response(org_resp, org_referer)
  623. if not callback_url and org_resp.status_code == 200:
  624. try:
  625. org_data = org_resp.json()
  626. except Exception as exc:
  627. self._log(f"login_for_token: organization select parse failed: {exc}")
  628. org_data = {}
  629. org_next = self._auth_url(str(org_data.get("continue_url") or "").strip())
  630. self._log(
  631. "login_for_token: organization select continue_url="
  632. f"{org_next[:120] if org_next else 'none'}"
  633. )
  634. if org_next:
  635. callback_url = self._extract_callback_url(org_next) or self._follow_redirects_with_session(
  636. login_session,
  637. org_next,
  638. referer=org_referer,
  639. )
  640. elif org_resp.status_code != 200:
  641. self._log(
  642. "login_for_token: organization select body="
  643. f"{str(getattr(org_resp, 'text', '') or '')[:320]}"
  644. )
  645. if not callback_url and ws_next:
  646. callback_url = self._extract_callback_url(ws_next) or self._follow_redirects_with_session(
  647. login_session,
  648. ws_next,
  649. referer=workspace_referer,
  650. )
  651. elif ws_resp.status_code != 200:
  652. self._log(
  653. "login_for_token: workspace select body="
  654. f"{str(getattr(ws_resp, 'text', '') or '')[:320]}"
  655. )
  656. if not callback_url:
  657. html_path = _capture_add_phone_html_if_present(
  658. self,
  659. session=login_session,
  660. url=continue_url,
  661. label=f"fresh-login-attempt-{attempt_trace['attempt']}",
  662. referer="https://auth.openai.com/log-in/password",
  663. )
  664. session_token_info = self._refresh_tokens_from_session_cookie(login_session, label="login_for_token")
  665. attempt_trace["session_token_found"] = bool(session_token_info)
  666. attempt_trace["callback_found"] = False
  667. if html_path:
  668. attempt_trace["html_path"] = html_path
  669. if session_token_info:
  670. _append_add_phone_trace_attempt(self, attempt_trace)
  671. return session_token_info
  672. self._log("login_for_token: could not obtain callback url")
  673. _append_add_phone_trace_attempt(self, attempt_trace)
  674. return None
  675. self._log(f"login_for_token: callback obtained, exchanging for tokens")
  676. attempt_trace["callback_found"] = True
  677. try:
  678. token_resp = submit_callback_url(
  679. callback_url=callback_url,
  680. expected_state=login_oauth.state,
  681. code_verifier=login_oauth.code_verifier,
  682. redirect_uri=login_oauth.redirect_uri,
  683. proxy_url=self.proxy_url,
  684. )
  685. self._log("login_for_token: token exchange successful")
  686. parsed = self._parse_token_response(token_resp)
  687. if parsed is not None:
  688. session_token = self._extract_session_token(login_session)
  689. if session_token:
  690. parsed["session_token"] = session_token
  691. attempt_trace["session_token_found"] = bool(session_token)
  692. _append_add_phone_trace_attempt(self, attempt_trace)
  693. return parsed
  694. except Exception as exc:
  695. self._log(f"login_for_token: token exchange failed: {exc}")
  696. _append_add_phone_trace_attempt(self, attempt_trace)
  697. return None
  698. except Exception as exc:
  699. self._log(f"login_for_token failed: {exc}")
  700. return None
  701. def _follow_redirects_with_session(
  702. self,
  703. session: Any,
  704. url: str,
  705. max_hops: int = 12,
  706. referer: str | None = None,
  707. ) -> str | None:
  708. """Follow redirect chain with a specific session, looking for callback URL with code=."""
  709. current_url = self._auth_url(url)
  710. current_referer = referer
  711. for hop in range(1, max_hops + 1):
  712. direct_callback = self._extract_callback_url(current_url)
  713. if direct_callback:
  714. return direct_callback
  715. try:
  716. headers = {"referer": current_referer} if current_referer else None
  717. resp = session.get(
  718. current_url,
  719. timeout=15,
  720. allow_redirects=False,
  721. headers=headers,
  722. )
  723. response_url = self._auth_url(str(getattr(resp, "url", "") or current_url))
  724. direct_callback = self._extract_callback_url(response_url)
  725. if direct_callback:
  726. return direct_callback
  727. location = self._auth_url(str(resp.headers.get("Location") or "").strip())
  728. if resp.status_code in {301, 302, 303, 307, 308} and location:
  729. self._log(f"login redirect {hop}: {location[:100]}")
  730. direct_callback = self._extract_callback_url(location)
  731. if direct_callback:
  732. return direct_callback
  733. current_referer = response_url or current_url
  734. current_url = location
  735. else:
  736. body_preview = ""
  737. try:
  738. body_preview = str(getattr(resp, "text", "") or "")[:320]
  739. except Exception:
  740. body_preview = ""
  741. self._log(
  742. "login redirect chain ended at hop "
  743. f"{hop} with status {resp.status_code}, url={response_url[:120]}, body={body_preview}"
  744. )
  745. _capture_add_phone_html_if_present(
  746. self,
  747. session=session,
  748. url=response_url,
  749. label=f"redirect-terminal-hop-{hop}",
  750. referer=current_referer,
  751. )
  752. return None
  753. except Exception as exc:
  754. callback_url = self._extract_callback_url_from_error(exc)
  755. if callback_url:
  756. return callback_url
  757. self._log(f"login redirect {hop} failed: {exc}")
  758. return None
  759. self._log("login redirect chain exceeded max hops")
  760. return None
  761. def _parse_token_response(self, raw: Any) -> dict[str, Any] | None:
  762. """Parse token exchange response into standardized dict."""
  763. if not raw or not isinstance(raw, (dict, str)):
  764. return None
  765. if isinstance(raw, str):
  766. try:
  767. raw = json.loads(raw)
  768. except Exception:
  769. return None
  770. access_token = str(raw.get("access_token") or "").strip()
  771. if not access_token:
  772. return None
  773. return {
  774. "access_token": access_token,
  775. "refresh_token": str(raw.get("refresh_token") or "").strip(),
  776. "id_token": str(raw.get("id_token") or "").strip(),
  777. "account_id": str(raw.get("account_id") or "").strip(),
  778. "email": str(raw.get("email") or "").strip(),
  779. "expired": str(raw.get("expired") or "").strip(),
  780. "last_refresh": str(raw.get("last_refresh") or "").strip(),
  781. }
  782. def _parse_workspace_from_cookie(self, session: Any | None = None) -> str | None:
  783. """Extract workspace id from oai-client-auth-session JWT cookie."""
  784. payload = self._load_oauth_session_payload(session)
  785. if not payload:
  786. _set_add_phone_trace_fields(self, auth_session_payload_keys=[], auth_session_workspace_count=0)
  787. self._log("oai-client-auth-session cookie missing or malformed")
  788. return None
  789. workspaces = payload.get("workspaces") or []
  790. _set_add_phone_trace_fields(
  791. self,
  792. auth_session_payload_keys=sorted(str(key) for key in payload.keys()),
  793. auth_session_workspace_count=len(workspaces) if isinstance(workspaces, list) else 0,
  794. )
  795. if not workspaces:
  796. self._log(f"workspace list missing in auth session payload (keys: {list(payload.keys())})")
  797. return None
  798. workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
  799. if not workspace_id:
  800. self._log("workspace id missing in auth session payload")
  801. return None
  802. self._log(f"workspace id acquired: {workspace_id}")
  803. return workspace_id
  804. def _get_workspace_id(self) -> str | None:
  805. if self.session is None:
  806. return None
  807. try:
  808. workspace_id = self._parse_workspace_from_cookie()
  809. if workspace_id:
  810. return workspace_id
  811. self._log("workspace fallback: triggering authorize/continue")
  812. try:
  813. resp2 = self.session.get(
  814. OPENAI_API_ENDPOINTS["signup"],
  815. headers={
  816. "referer": "https://auth.openai.com/create-account",
  817. "accept": "application/json",
  818. },
  819. timeout=15,
  820. allow_redirects=True,
  821. )
  822. self._log(f"authorize/continue status: {resp2.status_code}")
  823. except Exception:
  824. pass
  825. time.sleep(1)
  826. # Try cookie one more time after authorize
  827. workspace_id = self._parse_workspace_from_cookie()
  828. if workspace_id:
  829. return workspace_id
  830. self._log("all workspace retrieval methods exhausted")
  831. return None
  832. except Exception as exc:
  833. self._log(f"get_workspace_id failed: {exc}")
  834. return None
  835. def _select_workspace(self, workspace_id: str) -> str | None:
  836. if self.session is None:
  837. return None
  838. try:
  839. response = self.session.post(
  840. OPENAI_API_ENDPOINTS["select_workspace"],
  841. headers={
  842. "referer": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
  843. "content-type": "application/json",
  844. },
  845. data=json.dumps({"workspace_id": workspace_id}),
  846. )
  847. self._log(f"select workspace status: {response.status_code}")
  848. if response.status_code != 200:
  849. self._log(f"select workspace body: {response.text[:240]}")
  850. return None
  851. continue_url = str((response.json() or {}).get("continue_url") or "").strip()
  852. if continue_url:
  853. self._log("continue_url acquired")
  854. return continue_url
  855. self._log("continue_url missing from workspace selection")
  856. return None
  857. except Exception as exc:
  858. self._log(f"select_workspace failed: {exc}")
  859. return None
  860. # ── Solution D: direct session token extraction ──────────────────
  861. def _try_create_account_callback_session_token(self, continue_url: str) -> dict[str, Any] | None:
  862. """Complete ChatGPT session directly from create_account callback/openai URL."""
  863. if self.session is None:
  864. return None
  865. callback_url = self._extract_callback_url(continue_url)
  866. if not callback_url:
  867. # continue_url may need redirect tracking to reach the callback
  868. redirect_callback = self._follow_redirects_with_session(
  869. self.session, continue_url, referer="https://auth.openai.com/about-you"
  870. ) if continue_url and "auth.openai.com" in continue_url else None
  871. if redirect_callback:
  872. callback_url = redirect_callback
  873. else:
  874. return None
  875. parsed = urllib.parse.urlparse(callback_url)
  876. if parsed.netloc != "chatgpt.com" or not parsed.path.startswith("/api/auth/callback/openai"):
  877. return None
  878. try:
  879. self._log("create-account callback session: attempting direct callback session exchange")
  880. response = self.session.get(
  881. callback_url,
  882. allow_redirects=True,
  883. timeout=15,
  884. headers={
  885. "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
  886. "referer": "https://chatgpt.com/",
  887. },
  888. )
  889. self._log(f"create-account callback session: callback status={response.status_code}")
  890. cookie_names: list[str] = []
  891. try:
  892. jar = getattr(self.session.cookies, "jar", None)
  893. if jar:
  894. cookie_names = [str(getattr(cookie, "name", "")) for cookie in list(jar)]
  895. except Exception:
  896. cookie_names = []
  897. self._log(f"callback session: cookies after callback: {cookie_names[:20]}")
  898. except Exception as exc:
  899. self._log(f"create-account callback session: callback request failed: {exc}")
  900. return None
  901. try:
  902. session_url = "https://chatgpt.com/api/auth/session"
  903. session_resp = self.session.get(
  904. session_url,
  905. headers={
  906. "accept": "application/json",
  907. "referer": "https://chatgpt.com/",
  908. },
  909. timeout=15,
  910. )
  911. self._log(f"create-account callback session: session status={session_resp.status_code}")
  912. if session_resp.status_code != 200:
  913. return None
  914. try:
  915. session_data = session_resp.json()
  916. except Exception:
  917. session_data = {}
  918. _set_add_phone_trace_fields(
  919. self,
  920. create_account_callback_session_status=session_resp.status_code,
  921. create_account_callback_session_keys=sorted(str(key) for key in session_data.keys()),
  922. )
  923. access_token = str(session_data.get("accessToken") or "").strip()
  924. if not access_token:
  925. self._log(
  926. "create-account callback session: accessToken missing, "
  927. f"keys={list(session_data.keys())}"
  928. )
  929. return None
  930. token_info = self._parse_session_jwt(access_token, session_data)
  931. token_info["source"] = "create_account_callback_session"
  932. self._log("create-account callback session: ✅ token acquired from callback/session path")
  933. return token_info
  934. except Exception as exc:
  935. self._log(f"create-account callback session: session extraction failed: {exc}")
  936. return None
  937. def _try_direct_session_token(self) -> dict[str, Any] | None:
  938. """Try to extract accessToken by leveraging the existing auth session.
  939. After create_account, even though add-phone is returned, the user
  940. account exists and auth cookies may allow completing the OAuth flow
  941. without triggering a fresh login (which causes add-phone gate).
  942. Strategy:
  943. 1. Log cookie diagnostics for debugging
  944. 2. Try authorize flow using existing session to get code
  945. 3. Exchange code at chatgpt.com callback
  946. 4. GET chatgpt.com/api/auth/session for accessToken
  947. """
  948. if self.session is None:
  949. return None
  950. try:
  951. self._log("solution D: attempting direct session token extraction")
  952. # ── Step 0: cookie diagnostics ──
  953. try:
  954. cookie_jar = self.session.cookies
  955. # curl_cffi uses a dict-like cookie jar
  956. cookie_items = []
  957. try:
  958. # Try standard jar iteration
  959. for cookie in cookie_jar.jar:
  960. cookie_items.append(f"{getattr(cookie, 'domain', '?')}:{getattr(cookie, 'name', '?')}")
  961. except Exception:
  962. try:
  963. # Fallback: cookie jar as dict
  964. for name, value in cookie_jar.items():
  965. cookie_items.append(f"{name}={str(value)[:20]}")
  966. except Exception:
  967. cookie_items.append(f"jar_type={type(cookie_jar).__name__}")
  968. self._log(f"solution D: cookies ({len(cookie_items)}): {cookie_items[:15]}")
  969. except Exception as exc:
  970. self._log(f"solution D: cookie diagnostics error: {exc}")
  971. # Re-use the already-built auth_url from oauth_start but remove
  972. # prompt=login to prevent re-authentication trigger.
  973. if self.oauth_start:
  974. try:
  975. parsed_auth = urllib.parse.urlparse(self.oauth_start.auth_url)
  976. auth_params = dict(urllib.parse.parse_qsl(parsed_auth.query))
  977. # Remove prompt=login to skip forced re-auth
  978. auth_params.pop("prompt", None)
  979. authorize_url = f"{parsed_auth.scheme}://{parsed_auth.netloc}{parsed_auth.path}?{urllib.parse.urlencode(auth_params)}"
  980. self._log("solution D: attempting authorize with existing session")
  981. auth_resp = self.session.get(
  982. authorize_url,
  983. allow_redirects=False,
  984. timeout=15,
  985. )
  986. self._log(
  987. f"solution D: authorize status={auth_resp.status_code}, "
  988. f"location={str(auth_resp.headers.get('Location', ''))[:120]}"
  989. )
  990. # Follow redirect chain to find callback URL with code=
  991. callback_url = None
  992. current_url = str(auth_resp.headers.get("Location", "")).strip()
  993. if auth_resp.status_code in {301, 302, 303, 307, 308} and current_url:
  994. for hop in range(8):
  995. self._log(f"solution D: redirect hop {hop + 1}: {current_url[:120]}")
  996. if "code=" in current_url and "state=" in current_url:
  997. callback_url = current_url
  998. self._log("solution D: ✅ callback URL extracted from authorize redirect")
  999. break
  1000. try:
  1001. hop_resp = self.session.get(
  1002. current_url,
  1003. allow_redirects=False,
  1004. timeout=15,
  1005. )
  1006. next_loc = str(hop_resp.headers.get("Location", "")).strip()
  1007. if hop_resp.status_code not in {301, 302, 303, 307, 308} or not next_loc:
  1008. self._log(
  1009. f"solution D: redirect chain ended at hop {hop + 1} "
  1010. f"status={hop_resp.status_code}"
  1011. )
  1012. # Check if the final URL itself contains code=
  1013. final_url = str(hop_resp.url or current_url)
  1014. if "code=" in final_url and "state=" in final_url:
  1015. callback_url = final_url
  1016. self._log("solution D: ✅ callback URL found in final redirect URL")
  1017. break
  1018. current_url = urllib.parse.urljoin(current_url, next_loc)
  1019. except Exception as exc:
  1020. self._log(f"solution D: redirect hop {hop + 1} error: {exc}")
  1021. break
  1022. elif auth_resp.status_code == 200:
  1023. # Might be a page that needs interaction (e.g., consent)
  1024. body_preview = str(auth_resp.text or "")[:200]
  1025. self._log(f"solution D: authorize returned 200, body={body_preview}")
  1026. html_path = _capture_add_phone_html_if_present(
  1027. self,
  1028. session=self.session,
  1029. url=str(getattr(auth_resp, "url", "") or authorize_url),
  1030. label="direct-authorize",
  1031. )
  1032. if html_path:
  1033. _set_add_phone_trace_fields(self, direct_authorize_html_path=html_path)
  1034. # If we got a callback URL, exchange it
  1035. if callback_url:
  1036. self._log("solution D: exchanging callback code via oauth handler")
  1037. token_info = self._handle_oauth_callback(callback_url)
  1038. if token_info:
  1039. self._log("solution D: ✅ token obtained via authorize → callback exchange")
  1040. token_info["source"] = "direct_authorize"
  1041. return token_info
  1042. else:
  1043. self._log("solution D: callback exchange failed")
  1044. except Exception as exc:
  1045. self._log(f"solution D: authorize flow error: {exc}")
  1046. # ── Step 2: try chatgpt.com/api/auth/session directly ──
  1047. try:
  1048. session_url = "https://chatgpt.com/api/auth/session"
  1049. resp = self.session.get(
  1050. session_url,
  1051. headers={
  1052. "accept": "application/json",
  1053. "referer": "https://chatgpt.com/",
  1054. },
  1055. timeout=15,
  1056. )
  1057. self._log(f"solution D: session endpoint status={resp.status_code}")
  1058. if resp.status_code == 200:
  1059. try:
  1060. session_data = resp.json()
  1061. except Exception:
  1062. session_data = {}
  1063. _set_add_phone_trace_fields(
  1064. self,
  1065. direct_session_status=resp.status_code,
  1066. direct_session_keys=sorted(str(key) for key in session_data.keys()),
  1067. )
  1068. body_summary = str(resp.text or "")[:200]
  1069. self._log(f"solution D: session body summary: {body_summary}")
  1070. access_token = str(session_data.get("accessToken") or "").strip()
  1071. if access_token:
  1072. self._log(f"solution D: accessToken obtained (len={len(access_token)})")
  1073. token_info = self._parse_session_jwt(access_token, session_data)
  1074. return token_info
  1075. else:
  1076. self._log(
  1077. f"solution D: no accessToken in session response, "
  1078. f"keys={list(session_data.keys())}"
  1079. )
  1080. except Exception as exc:
  1081. self._log(f"solution D: session endpoint error: {exc}")
  1082. self._log("solution D: all extraction attempts failed")
  1083. return None
  1084. except Exception as exc:
  1085. self._log(f"solution D: direct session token extraction failed: {exc}")
  1086. return None
  1087. def _parse_session_jwt(self, access_token: str, session_data: dict[str, Any]) -> dict[str, Any]:
  1088. """Parse accessToken JWT and build token_info dict."""
  1089. token_info: dict[str, Any] = {
  1090. "access_token": access_token,
  1091. "id_token": access_token,
  1092. "source": "direct_session",
  1093. }
  1094. for key in ("user", "expires"):
  1095. if key in session_data:
  1096. token_info[key] = session_data[key]
  1097. try:
  1098. parts = access_token.split(".")
  1099. if len(parts) >= 2:
  1100. payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
  1101. payload = json.loads(base64.urlsafe_b64decode(payload_b64))
  1102. if isinstance(payload, dict):
  1103. auth_claims = payload.get("https://api.openai.com/auth") or {}
  1104. chatgpt_account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
  1105. for jwt_key, info_key in [
  1106. ("sub", "account_id"),
  1107. ("email", "email"),
  1108. ]:
  1109. if jwt_key in payload:
  1110. token_info[info_key] = str(payload[jwt_key])
  1111. exp_val = payload.get("exp")
  1112. if exp_val is not None:
  1113. try:
  1114. token_info["expired"] = time.strftime(
  1115. "%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(exp_val))
  1116. )
  1117. except (ValueError, TypeError, OSError):
  1118. token_info["expired"] = str(exp_val)
  1119. if chatgpt_account_id:
  1120. token_info["account_id"] = chatgpt_account_id
  1121. scope = payload.get("scope") or payload.get("scp")
  1122. if scope:
  1123. token_info["scope"] = scope if isinstance(scope, str) else " ".join(scope)
  1124. self._log(
  1125. f"solution D: JWT decoded - account_id={token_info.get('account_id', 'N/A')}, "
  1126. f"email={token_info.get('email', 'N/A')}, "
  1127. f"scope={str(token_info.get('scope', 'N/A'))[:80]}"
  1128. )
  1129. except Exception as exc:
  1130. self._log(f"solution D: JWT decode warning: {exc}")
  1131. self._log("solution D: ✅ direct session token extraction succeeded")
  1132. return token_info
  1133. def _follow_redirects(self, start_url: str) -> str | None:
  1134. if self.session is None:
  1135. return None
  1136. current_url = start_url
  1137. try:
  1138. for index in range(12):
  1139. self._log(f"follow redirect {index + 1}: {current_url[:120]}")
  1140. response = self.session.get(
  1141. current_url,
  1142. allow_redirects=False,
  1143. timeout=15,
  1144. )
  1145. location = str(response.headers.get("Location") or "").strip()
  1146. if response.status_code not in {301, 302, 303, 307, 308}:
  1147. self._log(f"redirect chain ended with status {response.status_code}")
  1148. break
  1149. if not location:
  1150. self._log("redirect location missing")
  1151. break
  1152. next_url = urllib.parse.urljoin(current_url, location)
  1153. if "code=" in next_url and "state=" in next_url:
  1154. self._log("oauth callback url reached")
  1155. return next_url
  1156. current_url = next_url
  1157. self._log("callback url not reached in redirect chain")
  1158. return None
  1159. except Exception as exc:
  1160. self._log(f"follow_redirects failed: {exc}")
  1161. return None
  1162. def _handle_oauth_callback(self, callback_url: str) -> dict[str, Any] | None:
  1163. if not self.oauth_start:
  1164. return None
  1165. try:
  1166. self._log("handling oauth callback")
  1167. token_info = self.oauth_manager.handle_callback(
  1168. callback_url=callback_url,
  1169. expected_state=self.oauth_start.state,
  1170. code_verifier=self.oauth_start.code_verifier,
  1171. )
  1172. self._log("oauth callback exchanged successfully")
  1173. return token_info
  1174. except Exception as exc:
  1175. self._log(f"handle_oauth_callback failed: {exc}")
  1176. return None