| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243 |
- """OAuth helpers for ChatGPT registration."""
- from __future__ import annotations
- import base64
- from datetime import datetime
- import json
- import re
- import time
- import urllib.parse
- from typing import Any
- from .constants import OPENAI_API_ENDPOINTS, OPENAI_PAGE_TYPES
- from .http_client import OpenAIHTTPClient
- from .oauth import submit_callback_url
- from .token_refresh import TokenRefreshManager
- def _set_add_phone_trace_fields(self, **updates: Any) -> None:
- setter = getattr(self, "_set_add_phone_trace", None)
- if callable(setter):
- setter(**updates)
- def _append_add_phone_trace_attempt(self, payload: dict[str, Any]) -> None:
- appender = getattr(self, "_append_add_phone_attempt", None)
- if callable(appender):
- appender(payload)
- def _capture_add_phone_html_if_present(
- self,
- *,
- session: Any,
- url: str,
- label: str,
- referer: str | None = None,
- ) -> str:
- capturer = getattr(self, "_capture_add_phone_html", None)
- if not callable(capturer):
- return ""
- candidate_url = self._auth_url(url)
- if not candidate_url or "add-phone" not in candidate_url:
- return ""
- try:
- response = session.get(
- candidate_url,
- timeout=15,
- allow_redirects=True,
- headers={"referer": referer} if referer else None,
- )
- except Exception as exc:
- self._log(f"{label}: add-phone html capture failed: {exc}")
- return ""
- final_url = self._auth_url(str(getattr(response, "url", "") or candidate_url))
- html = str(getattr(response, "text", "") or "")
- if "add-phone" not in final_url and "add-phone" not in html:
- return ""
- return str(capturer(label=label, url=final_url, html=html) or "")
- def _extract_callback_url(self, value: str) -> str | None:
- candidate = str(value or "").strip()
- if not candidate:
- return None
- candidate = self._auth_url(candidate)
- parsed = urllib.parse.urlparse(candidate)
- query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
- code = str((query.get("code") or [""])[0] or "").strip()
- state = str((query.get("state") or [""])[0] or "").strip()
- if code and state:
- return candidate
- return None
- def _extract_callback_url_from_error(self, exc: Exception) -> str | None:
- matched = re.search(r"(https?://localhost[^\s'\"\\]+)", str(exc))
- if not matched:
- return None
- return self._extract_callback_url(matched.group(1))
- def _extract_session_token(self, session: Any | None = None) -> str | None:
- target_session = session or self.session
- if target_session is None:
- return None
- cookies = getattr(target_session, "cookies", None)
- if cookies is None:
- return None
- for cookie_name in ("__Secure-next-auth.session-token", "next-auth.session-token"):
- try:
- cookie_value = str(cookies.get(cookie_name) or "").strip()
- except Exception:
- cookie_value = ""
- if cookie_value:
- return cookie_value
- jar = getattr(cookies, "jar", None)
- if jar is None:
- return None
- for item in list(jar):
- name = str(getattr(item, "name", "") or "").strip()
- if name not in {"__Secure-next-auth.session-token", "next-auth.session-token"}:
- continue
- value = str(getattr(item, "value", "") or "").strip()
- if value:
- return value
- return None
- def _refresh_tokens_from_session_cookie(
- self,
- session: Any | None = None,
- *,
- label: str,
- ) -> dict[str, Any] | None:
- session_token = self._extract_session_token(session)
- if not session_token:
- self._log(f"{label}: session token missing")
- return None
- self._log(f"{label}: session token detected")
- refresh_result = TokenRefreshManager(proxy_url=self.proxy_url).refresh_by_session_token(session_token)
- if not refresh_result.success:
- self._log(f"{label}: session token refresh failed: {refresh_result.error_message}")
- return None
- self._log(f"{label}: session token refresh succeeded")
- expired = ""
- if refresh_result.expires_at is not None:
- expired = refresh_result.expires_at.strftime("%Y-%m-%dT%H:%M:%SZ")
- return {
- "access_token": refresh_result.access_token,
- "refresh_token": refresh_result.refresh_token,
- "id_token": "",
- "account_id": refresh_result.account_id,
- "email": refresh_result.email or str(self.email or "").strip(),
- "expired": expired,
- "last_refresh": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
- "session_token": refresh_result.session_token or session_token,
- }
- def _decode_oauth_session_cookie(self, session: Any | None = None) -> dict[str, Any] | None:
- target_session = session or self.session
- if target_session is None:
- return None
- cookies = getattr(target_session, "cookies", None)
- if cookies is None:
- return None
- jar = getattr(cookies, "jar", None)
- cookie_items = list(jar) if jar is not None else []
- raw_cookie = str(cookies.get("oai-client-auth-session") or "").strip()
- if raw_cookie:
- cookie_items.insert(0, type("CookieItem", (), {"name": "oai-client-auth-session", "value": raw_cookie})())
- for item in cookie_items:
- name = str(getattr(item, "name", "") or "").strip()
- if "oai-client-auth-session" not in name:
- continue
- raw_value = str(getattr(item, "value", "") or "").strip()
- if not raw_value:
- continue
- candidates = [raw_value]
- try:
- from urllib.parse import unquote
- decoded = unquote(raw_value)
- if decoded != raw_value:
- candidates.append(decoded)
- except Exception:
- pass
- for candidate in candidates:
- try:
- value = candidate
- if (value.startswith('"') and value.endswith('"')) or (
- value.startswith("'") and value.endswith("'")
- ):
- value = value[1:-1]
- part = value.split(".")[0] if "." in value else value
- pad = "=" * ((4 - (len(part) % 4)) % 4)
- decoded = base64.urlsafe_b64decode((part + pad).encode("ascii"))
- data = json.loads(decoded.decode("utf-8"))
- if isinstance(data, dict):
- return data
- except Exception:
- continue
- return None
- def _fetch_client_auth_session_dump(
- self,
- session: Any | None = None,
- *,
- referer: str = "https://auth.openai.com/add-phone",
- ) -> dict[str, Any] | None:
- target_session = session or self.session
- if target_session is None:
- return None
- cookies = getattr(target_session, "cookies", None)
- checksum_cookie = ""
- if cookies is not None:
- try:
- checksum_cookie = str(cookies.get("auth-session-minimized-client-checksum") or "").strip()
- except Exception:
- checksum_cookie = ""
- if not checksum_cookie:
- self._log("client auth session dump: minimized checksum cookie missing")
- try:
- response = target_session.get(
- "https://auth.openai.com/api/accounts/client_auth_session_dump",
- headers={
- "accept": "application/json",
- "referer": referer,
- "user-agent": self.http_client.default_headers.get("User-Agent", "Mozilla/5.0"),
- "sec-ch-ua": self.http_client.default_headers.get("sec-ch-ua", ""),
- "sec-ch-ua-mobile": self.http_client.default_headers.get("sec-ch-ua-mobile", ""),
- "sec-ch-ua-platform": self.http_client.default_headers.get("sec-ch-ua-platform", ""),
- },
- timeout=15,
- )
- except Exception as exc:
- self._log(f"client auth session dump failed: {exc}")
- return None
- self._log(f"client auth session dump status: {response.status_code}")
- if response.status_code != 200:
- self._log(f"client auth session dump body: {str(getattr(response, 'text', '') or '')[:240]}")
- _set_add_phone_trace_fields(self, auth_session_dump_status=response.status_code)
- return None
- try:
- payload = response.json()
- except Exception as exc:
- raw_text = str(getattr(response, "text", "") or "")
- parsed = None
- if raw_text:
- candidates = [raw_text]
- first_brace = raw_text.find("{")
- if first_brace > 0:
- candidates.append(raw_text[first_brace:])
- for candidate in candidates:
- try:
- parsed = json.loads(candidate)
- break
- except Exception:
- continue
- if parsed is None:
- self._log(f"client auth session dump parse failed: {exc}; body={raw_text[:240]}")
- return None
- payload = parsed
- session_payload = payload.get("client_auth_session") if isinstance(payload, dict) else None
- if not isinstance(session_payload, dict):
- self._log("client auth session dump missing client_auth_session payload")
- return None
- workspaces = session_payload.get("workspaces") or []
- _set_add_phone_trace_fields(
- self,
- auth_session_dump_status=response.status_code,
- auth_session_dump_keys=sorted(str(key) for key in payload.keys()) if isinstance(payload, dict) else [],
- auth_session_dump_workspace_count=len(workspaces) if isinstance(workspaces, list) else 0,
- auth_session_dump_session_id=str(payload.get("session_id") or "").strip() if isinstance(payload, dict) else "",
- )
- self._log(
- "client auth session dump workspace count: "
- f"{len(workspaces) if isinstance(workspaces, list) else 0}"
- )
- return session_payload
- def _load_oauth_session_payload(
- self,
- session: Any | None = None,
- *,
- referer: str = "https://auth.openai.com/add-phone",
- ) -> dict[str, Any] | None:
- payload = self._decode_oauth_session_cookie(session)
- workspaces = (payload or {}).get("workspaces") or []
- if payload and isinstance(workspaces, list) and workspaces:
- return payload
- dump_payload = self._fetch_client_auth_session_dump(session, referer=referer)
- if isinstance(dump_payload, dict):
- dump_workspaces = dump_payload.get("workspaces") or []
- if isinstance(dump_workspaces, list) and dump_workspaces:
- return dump_payload
- if not payload:
- return dump_payload
- return payload
- def _login_for_token(self) -> dict[str, Any] | None:
- """Login with email+password in a fresh session to obtain OAuth tokens."""
- if not self.email or not self.password:
- self._log("login_for_token: email or password missing")
- return None
- try:
- attempt_index = int(getattr(self, "_add_phone_oauth_attempt_counter", 0) or 0)
- attempt_trace: dict[str, Any] = {
- "attempt": attempt_index or 1,
- "authorize_continue_status": None,
- "password_verify_status": None,
- "otp_validate_status": None,
- "workspace_select_status": None,
- "organization_select_status": None,
- "final_continue_url": "",
- "final_page_type": "",
- "callback_found": False,
- "session_token_found": False,
- }
- login_client = OpenAIHTTPClient(proxy_url=self.proxy_url)
- login_session = login_client.session
- login_oauth = self.oauth_manager.start_oauth()
- self._log("login_for_token: new oauth flow started")
- authorize_params = dict(
- urllib.parse.parse_qsl(
- urllib.parse.urlparse(login_oauth.auth_url).query,
- keep_blank_values=True,
- )
- )
- def _refresh_login_session() -> Any:
- nonlocal login_client, login_session
- cookie_pairs: dict[str, str] = {}
- try:
- cookies = getattr(login_session, "cookies", None)
- if cookies is not None:
- jar = getattr(cookies, "jar", None)
- if jar is not None:
- for item in list(jar):
- name = str(getattr(item, "name", "") or "").strip()
- value = str(getattr(item, "value", "") or "").strip()
- if name:
- cookie_pairs[name] = value
- for key, value in dict(cookies).items():
- if key:
- cookie_pairs[str(key)] = str(value)
- except Exception:
- pass
- try:
- login_client.close()
- except Exception:
- pass
- login_client = OpenAIHTTPClient(proxy_url=self.proxy_url)
- login_session = login_client.session
- try:
- login_session.cookies.update(cookie_pairs)
- except Exception:
- pass
- return login_session
- def _bootstrap_oauth_session() -> str:
- nonlocal login_session
- response, login_session = self._session_request(
- session=login_session,
- method="GET",
- url=login_oauth.auth_url,
- label="login_for_token: oauth bootstrap",
- refresh_session=_refresh_login_session,
- timeout=15,
- allow_redirects=True,
- )
- final_url = str(getattr(response, "url", "") or login_oauth.auth_url)
- has_login_session = bool(str(login_session.cookies.get("login_session") or "").strip())
- if not has_login_session:
- response, login_session = self._session_request(
- session=login_session,
- method="GET",
- url="https://auth.openai.com/api/oauth/oauth2/auth",
- label="login_for_token: oauth bootstrap fallback",
- refresh_session=_refresh_login_session,
- params=authorize_params,
- timeout=15,
- allow_redirects=True,
- )
- final_url = str(getattr(response, "url", "") or final_url)
- return final_url
- def _resolve_callback_from_response(response: Any, referer: str) -> str | None:
- location = self._auth_url(str(response.headers.get("Location") or "").strip())
- if response.status_code in {301, 302, 303, 307, 308} and location:
- self._log(f"login_for_token: redirect -> {location[:120]}")
- return self._extract_callback_url(location) or self._follow_redirects_with_session(
- login_session,
- location,
- referer=referer,
- )
- return None
- authorize_final_url = _bootstrap_oauth_session()
- device_id = str(login_session.cookies.get("oai-did") or "").strip()
- self._log(f"login_for_token: device_id={'yes' if device_id else 'no'}")
- if not device_id:
- self._log("login_for_token: missing device_id after oauth bootstrap")
- return None
- authorize_sentinel = login_client.check_sentinel(device_id, flow="authorize_continue")
- if not authorize_sentinel:
- self._log("login_for_token: authorize sentinel unavailable")
- return None
- continue_referer = authorize_final_url if authorize_final_url.startswith("https://auth.openai.com") else "https://auth.openai.com/log-in"
- login_headers = self._oauth_json_headers(referer=continue_referer, device_id=device_id)
- login_headers["openai-sentinel-token"] = self._build_sentinel_header(
- authorize_sentinel,
- device_id,
- "authorize_continue",
- client=login_client,
- )
- login_resp, login_session = self._session_request(
- session=login_session,
- method="POST",
- url=OPENAI_API_ENDPOINTS["signup"],
- label="login_for_token: authorize continue",
- refresh_session=_refresh_login_session,
- headers=login_headers,
- json={"username": {"kind": "email", "value": self.email}},
- timeout=15,
- allow_redirects=False,
- )
- self._log(f"login_for_token: authorize continue status={login_resp.status_code}")
- attempt_trace["authorize_continue_status"] = login_resp.status_code
- if login_resp.status_code == 400 and "invalid_auth_step" in (login_resp.text or ""):
- authorize_final_url = _bootstrap_oauth_session()
- continue_referer = authorize_final_url if authorize_final_url.startswith("https://auth.openai.com") else "https://auth.openai.com/log-in"
- login_headers = self._oauth_json_headers(referer=continue_referer, device_id=device_id)
- login_headers["openai-sentinel-token"] = self._build_sentinel_header(
- authorize_sentinel,
- device_id,
- "authorize_continue",
- client=login_client,
- )
- login_resp, login_session = self._session_request(
- session=login_session,
- method="POST",
- url=OPENAI_API_ENDPOINTS["signup"],
- label="login_for_token: authorize continue retry",
- refresh_session=_refresh_login_session,
- headers=login_headers,
- json={"username": {"kind": "email", "value": self.email}},
- timeout=15,
- allow_redirects=False,
- )
- self._log(f"login_for_token: authorize continue retry status={login_resp.status_code}")
- attempt_trace["authorize_continue_status"] = login_resp.status_code
- if login_resp.status_code != 200:
- self._log(f"login_for_token: authorize continue body={login_resp.text[:240]}")
- _append_add_phone_trace_attempt(self, attempt_trace)
- return None
- try:
- login_data = login_resp.json()
- except Exception as exc:
- self._log(f"login_for_token: authorize continue parse failed: {exc}")
- return None
- continue_url = self._auth_url(str(login_data.get("continue_url") or "").strip())
- page_type = str(((login_data.get("page") or {}).get("type")) or "").strip()
- password_sentinel = login_client.check_sentinel(device_id, flow="password_verify")
- if not password_sentinel:
- self._log("login_for_token: password sentinel unavailable")
- return None
- oauth_otp_before_ids = self._capture_mailbox_ids()
- self._log(f"login_for_token: oauth otp baseline ids={len(oauth_otp_before_ids)}")
- password_headers = self._oauth_json_headers(
- referer="https://auth.openai.com/log-in/password",
- device_id=device_id,
- )
- password_headers["openai-sentinel-token"] = self._build_sentinel_header(
- password_sentinel,
- device_id,
- "password_verify",
- client=login_client,
- )
- pw_resp, login_session = self._session_request(
- session=login_session,
- method="POST",
- url=OPENAI_API_ENDPOINTS["password_verify"],
- label="login_for_token: password verify",
- refresh_session=_refresh_login_session,
- headers=password_headers,
- json={"password": self.password},
- timeout=15,
- allow_redirects=False,
- )
- self._log(f"login_for_token: password verify status={pw_resp.status_code}")
- attempt_trace["password_verify_status"] = pw_resp.status_code
- if pw_resp.status_code != 200:
- self._log(f"login_for_token: password verify body={pw_resp.text[:500]}")
- _append_add_phone_trace_attempt(self, attempt_trace)
- return None
- try:
- pw_data = pw_resp.json()
- except Exception as exc:
- self._log(f"login_for_token: password verify parse failed: {exc}")
- return None
- continue_url = self._auth_url(str(pw_data.get("continue_url") or continue_url or "").strip())
- page_type = str(((pw_data.get("page") or {}).get("type")) or page_type or "").strip()
- self._log(
- "login_for_token: continue_url="
- f"{continue_url[:120] if continue_url else 'none'}, page={page_type or 'unknown'}"
- )
- need_oauth_otp = (
- page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]
- or "email-verification" in (continue_url or "")
- or "email-otp" in (continue_url or "")
- )
- if need_oauth_otp:
- self._log("login_for_token: oauth email verification required")
- # Solution C: Try session cookie refresh BEFORE OTP wait.
- # After password_verify, the login session may already have a
- # usable session cookie, letting us skip the second OTP entirely.
- try:
- pre_otp_token = self._refresh_tokens_from_session_cookie(
- login_session, label="login_for_token:pre-otp-session"
- )
- if pre_otp_token:
- self._log("login_for_token: session cookie refresh bypassed OTP requirement")
- return pre_otp_token
- except Exception as exc:
- self._log(f"login_for_token: pre-otp session refresh failed: {exc}")
- otp_code = self._wait_for_mailbox_code(
- before_ids=oauth_otp_before_ids,
- timeout=self._add_phone_oauth_otp_timeout_seconds,
- keyword="openai",
- )
- mailbox, _acct = self._mailbox_context()
- if mailbox is not None:
- diag = dict(getattr(mailbox, "last_wait_diagnostics", {}) or {})
- self._log(
- f"login_for_token: oauth otp diagnostics: "
- f"polls={diag.get('poll_count', '?')} "
- f"scanned={diag.get('message_scan_count', '?')} "
- f"first_seen_after={diag.get('first_message_seen_at', '-')}"
- )
- if not otp_code:
- self._log("login_for_token: oauth email verification code not received")
- return None
- self._log(f"login_for_token: oauth otp received {otp_code}")
- otp_headers = self._oauth_json_headers(
- referer="https://auth.openai.com/email-verification",
- device_id=device_id,
- )
- otp_resp, login_session = self._session_request(
- session=login_session,
- method="POST",
- url=OPENAI_API_ENDPOINTS["validate_otp"],
- label="login_for_token: oauth otp validate",
- refresh_session=_refresh_login_session,
- headers=otp_headers,
- json={"code": otp_code},
- timeout=15,
- allow_redirects=False,
- )
- self._log(f"login_for_token: oauth otp validate status={otp_resp.status_code}")
- attempt_trace["otp_validate_status"] = otp_resp.status_code
- if otp_resp.status_code != 200:
- self._log(f"login_for_token: oauth otp validate body={otp_resp.text[:500]}")
- _append_add_phone_trace_attempt(self, attempt_trace)
- return None
- try:
- otp_data = otp_resp.json()
- except Exception as exc:
- self._log(f"login_for_token: oauth otp parse failed: {exc}")
- return None
- continue_url = self._auth_url(str(otp_data.get("continue_url") or continue_url or "").strip())
- page_type = str(((otp_data.get("page") or {}).get("type")) or page_type or "").strip()
- self._log(
- "login_for_token: oauth otp continue_url="
- f"{continue_url[:120] if continue_url else 'none'}, page={page_type or 'unknown'}"
- )
- attempt_trace["final_continue_url"] = continue_url
- attempt_trace["final_page_type"] = page_type
- callback_url = self._extract_callback_url(continue_url)
- if not callback_url and continue_url:
- callback_url = self._follow_redirects_with_session(
- login_session,
- continue_url,
- referer="https://auth.openai.com/log-in/password",
- )
- consent_hint = any(
- hint
- for hint in (
- "consent" in (continue_url or ""),
- "workspace" in (continue_url or ""),
- "organization" in (continue_url or ""),
- "consent" in page_type,
- "organization" in page_type,
- )
- )
- if not callback_url and consent_hint:
- session_data = self._load_oauth_session_payload(
- login_session,
- referer=continue_url or "https://auth.openai.com/add-phone",
- ) or {}
- workspaces = session_data.get("workspaces") or []
- workspace_id = ""
- if workspaces and isinstance(workspaces, list):
- workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
- if not workspace_id:
- self._log("login_for_token: workspace id missing in oauth session cookie")
- else:
- workspace_referer = continue_url or "https://auth.openai.com/sign-in-with-chatgpt/codex/consent"
- workspace_headers = self._oauth_json_headers(
- referer=workspace_referer,
- device_id=device_id,
- )
- ws_resp, login_session = self._session_request(
- session=login_session,
- method="POST",
- url=OPENAI_API_ENDPOINTS["select_workspace"],
- label="login_for_token: workspace select",
- refresh_session=_refresh_login_session,
- headers=workspace_headers,
- json={"workspace_id": workspace_id},
- timeout=15,
- allow_redirects=False,
- )
- self._log(f"login_for_token: workspace select status={ws_resp.status_code}")
- attempt_trace["workspace_select_status"] = ws_resp.status_code
- callback_url = _resolve_callback_from_response(ws_resp, workspace_referer)
- if not callback_url and ws_resp.status_code == 200:
- try:
- ws_data = ws_resp.json()
- except Exception as exc:
- self._log(f"login_for_token: workspace select parse failed: {exc}")
- ws_data = {}
- ws_next = self._auth_url(str(ws_data.get("continue_url") or "").strip())
- orgs = ((ws_data.get("data") or {}).get("orgs") or []) if isinstance(ws_data, dict) else []
- self._log(
- "login_for_token: workspace select continue_url="
- f"{ws_next[:120] if ws_next else 'none'}, org_count={len(orgs) if isinstance(orgs, list) else 0}"
- )
- if orgs and isinstance(orgs, list):
- first_org = orgs[0] or {}
- org_id = str(first_org.get("id") or "").strip()
- projects = first_org.get("projects") or []
- project_id = ""
- if projects and isinstance(projects, list):
- project_id = str((projects[0] or {}).get("id") or "").strip()
- if org_id:
- org_body = {"org_id": org_id}
- if project_id:
- org_body["project_id"] = project_id
- org_referer = ws_next or workspace_referer
- org_headers = self._oauth_json_headers(
- referer=org_referer,
- device_id=device_id,
- )
- org_resp, login_session = self._session_request(
- session=login_session,
- method="POST",
- url=OPENAI_API_ENDPOINTS["select_organization"],
- label="login_for_token: organization select",
- refresh_session=_refresh_login_session,
- headers=org_headers,
- json=org_body,
- timeout=15,
- allow_redirects=False,
- )
- self._log(f"login_for_token: organization select status={org_resp.status_code}")
- attempt_trace["organization_select_status"] = org_resp.status_code
- callback_url = _resolve_callback_from_response(org_resp, org_referer)
- if not callback_url and org_resp.status_code == 200:
- try:
- org_data = org_resp.json()
- except Exception as exc:
- self._log(f"login_for_token: organization select parse failed: {exc}")
- org_data = {}
- org_next = self._auth_url(str(org_data.get("continue_url") or "").strip())
- self._log(
- "login_for_token: organization select continue_url="
- f"{org_next[:120] if org_next else 'none'}"
- )
- if org_next:
- callback_url = self._extract_callback_url(org_next) or self._follow_redirects_with_session(
- login_session,
- org_next,
- referer=org_referer,
- )
- elif org_resp.status_code != 200:
- self._log(
- "login_for_token: organization select body="
- f"{str(getattr(org_resp, 'text', '') or '')[:320]}"
- )
- if not callback_url and ws_next:
- callback_url = self._extract_callback_url(ws_next) or self._follow_redirects_with_session(
- login_session,
- ws_next,
- referer=workspace_referer,
- )
- elif ws_resp.status_code != 200:
- self._log(
- "login_for_token: workspace select body="
- f"{str(getattr(ws_resp, 'text', '') or '')[:320]}"
- )
- if not callback_url:
- html_path = _capture_add_phone_html_if_present(
- self,
- session=login_session,
- url=continue_url,
- label=f"fresh-login-attempt-{attempt_trace['attempt']}",
- referer="https://auth.openai.com/log-in/password",
- )
- session_token_info = self._refresh_tokens_from_session_cookie(login_session, label="login_for_token")
- attempt_trace["session_token_found"] = bool(session_token_info)
- attempt_trace["callback_found"] = False
- if html_path:
- attempt_trace["html_path"] = html_path
- if session_token_info:
- _append_add_phone_trace_attempt(self, attempt_trace)
- return session_token_info
- self._log("login_for_token: could not obtain callback url")
- _append_add_phone_trace_attempt(self, attempt_trace)
- return None
- self._log(f"login_for_token: callback obtained, exchanging for tokens")
- attempt_trace["callback_found"] = True
- try:
- token_resp = submit_callback_url(
- callback_url=callback_url,
- expected_state=login_oauth.state,
- code_verifier=login_oauth.code_verifier,
- redirect_uri=login_oauth.redirect_uri,
- proxy_url=self.proxy_url,
- )
- self._log("login_for_token: token exchange successful")
- parsed = self._parse_token_response(token_resp)
- if parsed is not None:
- session_token = self._extract_session_token(login_session)
- if session_token:
- parsed["session_token"] = session_token
- attempt_trace["session_token_found"] = bool(session_token)
- _append_add_phone_trace_attempt(self, attempt_trace)
- return parsed
- except Exception as exc:
- self._log(f"login_for_token: token exchange failed: {exc}")
- _append_add_phone_trace_attempt(self, attempt_trace)
- return None
- except Exception as exc:
- self._log(f"login_for_token failed: {exc}")
- return None
- def _follow_redirects_with_session(
- self,
- session: Any,
- url: str,
- max_hops: int = 12,
- referer: str | None = None,
- ) -> str | None:
- """Follow redirect chain with a specific session, looking for callback URL with code=."""
- current_url = self._auth_url(url)
- current_referer = referer
- for hop in range(1, max_hops + 1):
- direct_callback = self._extract_callback_url(current_url)
- if direct_callback:
- return direct_callback
- try:
- headers = {"referer": current_referer} if current_referer else None
- resp = session.get(
- current_url,
- timeout=15,
- allow_redirects=False,
- headers=headers,
- )
- response_url = self._auth_url(str(getattr(resp, "url", "") or current_url))
- direct_callback = self._extract_callback_url(response_url)
- if direct_callback:
- return direct_callback
- location = self._auth_url(str(resp.headers.get("Location") or "").strip())
- if resp.status_code in {301, 302, 303, 307, 308} and location:
- self._log(f"login redirect {hop}: {location[:100]}")
- direct_callback = self._extract_callback_url(location)
- if direct_callback:
- return direct_callback
- current_referer = response_url or current_url
- current_url = location
- else:
- body_preview = ""
- try:
- body_preview = str(getattr(resp, "text", "") or "")[:320]
- except Exception:
- body_preview = ""
- self._log(
- "login redirect chain ended at hop "
- f"{hop} with status {resp.status_code}, url={response_url[:120]}, body={body_preview}"
- )
- _capture_add_phone_html_if_present(
- self,
- session=session,
- url=response_url,
- label=f"redirect-terminal-hop-{hop}",
- referer=current_referer,
- )
- return None
- except Exception as exc:
- callback_url = self._extract_callback_url_from_error(exc)
- if callback_url:
- return callback_url
- self._log(f"login redirect {hop} failed: {exc}")
- return None
- self._log("login redirect chain exceeded max hops")
- return None
- def _parse_token_response(self, raw: Any) -> dict[str, Any] | None:
- """Parse token exchange response into standardized dict."""
- if not raw or not isinstance(raw, (dict, str)):
- return None
- if isinstance(raw, str):
- try:
- raw = json.loads(raw)
- except Exception:
- return None
- access_token = str(raw.get("access_token") or "").strip()
- if not access_token:
- return None
- return {
- "access_token": access_token,
- "refresh_token": str(raw.get("refresh_token") or "").strip(),
- "id_token": str(raw.get("id_token") or "").strip(),
- "account_id": str(raw.get("account_id") or "").strip(),
- "email": str(raw.get("email") or "").strip(),
- "expired": str(raw.get("expired") or "").strip(),
- "last_refresh": str(raw.get("last_refresh") or "").strip(),
- }
- def _parse_workspace_from_cookie(self, session: Any | None = None) -> str | None:
- """Extract workspace id from oai-client-auth-session JWT cookie."""
- payload = self._load_oauth_session_payload(session)
- if not payload:
- _set_add_phone_trace_fields(self, auth_session_payload_keys=[], auth_session_workspace_count=0)
- self._log("oai-client-auth-session cookie missing or malformed")
- return None
- workspaces = payload.get("workspaces") or []
- _set_add_phone_trace_fields(
- self,
- auth_session_payload_keys=sorted(str(key) for key in payload.keys()),
- auth_session_workspace_count=len(workspaces) if isinstance(workspaces, list) else 0,
- )
- if not workspaces:
- self._log(f"workspace list missing in auth session payload (keys: {list(payload.keys())})")
- return None
- workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
- if not workspace_id:
- self._log("workspace id missing in auth session payload")
- return None
- self._log(f"workspace id acquired: {workspace_id}")
- return workspace_id
- def _get_workspace_id(self) -> str | None:
- if self.session is None:
- return None
- try:
- workspace_id = self._parse_workspace_from_cookie()
- if workspace_id:
- return workspace_id
- self._log("workspace fallback: triggering authorize/continue")
- try:
- resp2 = self.session.get(
- OPENAI_API_ENDPOINTS["signup"],
- headers={
- "referer": "https://auth.openai.com/create-account",
- "accept": "application/json",
- },
- timeout=15,
- allow_redirects=True,
- )
- self._log(f"authorize/continue status: {resp2.status_code}")
- except Exception:
- pass
- time.sleep(1)
- # Try cookie one more time after authorize
- workspace_id = self._parse_workspace_from_cookie()
- if workspace_id:
- return workspace_id
- self._log("all workspace retrieval methods exhausted")
- return None
- except Exception as exc:
- self._log(f"get_workspace_id failed: {exc}")
- return None
- def _select_workspace(self, workspace_id: str) -> str | None:
- if self.session is None:
- return None
- try:
- response = self.session.post(
- OPENAI_API_ENDPOINTS["select_workspace"],
- headers={
- "referer": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
- "content-type": "application/json",
- },
- data=json.dumps({"workspace_id": workspace_id}),
- )
- self._log(f"select workspace status: {response.status_code}")
- if response.status_code != 200:
- self._log(f"select workspace body: {response.text[:240]}")
- return None
- continue_url = str((response.json() or {}).get("continue_url") or "").strip()
- if continue_url:
- self._log("continue_url acquired")
- return continue_url
- self._log("continue_url missing from workspace selection")
- return None
- except Exception as exc:
- self._log(f"select_workspace failed: {exc}")
- return None
- # ── Solution D: direct session token extraction ──────────────────
- def _try_create_account_callback_session_token(self, continue_url: str) -> dict[str, Any] | None:
- """Complete ChatGPT session directly from create_account callback/openai URL."""
- if self.session is None:
- return None
- callback_url = self._extract_callback_url(continue_url)
- if not callback_url:
- # continue_url may need redirect tracking to reach the callback
- redirect_callback = self._follow_redirects_with_session(
- self.session, continue_url, referer="https://auth.openai.com/about-you"
- ) if continue_url and "auth.openai.com" in continue_url else None
- if redirect_callback:
- callback_url = redirect_callback
- else:
- return None
- parsed = urllib.parse.urlparse(callback_url)
- if parsed.netloc != "chatgpt.com" or not parsed.path.startswith("/api/auth/callback/openai"):
- return None
- try:
- self._log("create-account callback session: attempting direct callback session exchange")
- response = self.session.get(
- callback_url,
- allow_redirects=True,
- timeout=15,
- headers={
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "referer": "https://chatgpt.com/",
- },
- )
- self._log(f"create-account callback session: callback status={response.status_code}")
- cookie_names: list[str] = []
- try:
- jar = getattr(self.session.cookies, "jar", None)
- if jar:
- cookie_names = [str(getattr(cookie, "name", "")) for cookie in list(jar)]
- except Exception:
- cookie_names = []
- self._log(f"callback session: cookies after callback: {cookie_names[:20]}")
- except Exception as exc:
- self._log(f"create-account callback session: callback request failed: {exc}")
- return None
- try:
- session_url = "https://chatgpt.com/api/auth/session"
- session_resp = self.session.get(
- session_url,
- headers={
- "accept": "application/json",
- "referer": "https://chatgpt.com/",
- },
- timeout=15,
- )
- self._log(f"create-account callback session: session status={session_resp.status_code}")
- if session_resp.status_code != 200:
- return None
- try:
- session_data = session_resp.json()
- except Exception:
- session_data = {}
- _set_add_phone_trace_fields(
- self,
- create_account_callback_session_status=session_resp.status_code,
- create_account_callback_session_keys=sorted(str(key) for key in session_data.keys()),
- )
- access_token = str(session_data.get("accessToken") or "").strip()
- if not access_token:
- self._log(
- "create-account callback session: accessToken missing, "
- f"keys={list(session_data.keys())}"
- )
- return None
- token_info = self._parse_session_jwt(access_token, session_data)
- token_info["source"] = "create_account_callback_session"
- self._log("create-account callback session: ✅ token acquired from callback/session path")
- return token_info
- except Exception as exc:
- self._log(f"create-account callback session: session extraction failed: {exc}")
- return None
- def _try_direct_session_token(self) -> dict[str, Any] | None:
- """Try to extract accessToken by leveraging the existing auth session.
- After create_account, even though add-phone is returned, the user
- account exists and auth cookies may allow completing the OAuth flow
- without triggering a fresh login (which causes add-phone gate).
- Strategy:
- 1. Log cookie diagnostics for debugging
- 2. Try authorize flow using existing session to get code
- 3. Exchange code at chatgpt.com callback
- 4. GET chatgpt.com/api/auth/session for accessToken
- """
- if self.session is None:
- return None
- try:
- self._log("solution D: attempting direct session token extraction")
- # ── Step 0: cookie diagnostics ──
- try:
- cookie_jar = self.session.cookies
- # curl_cffi uses a dict-like cookie jar
- cookie_items = []
- try:
- # Try standard jar iteration
- for cookie in cookie_jar.jar:
- cookie_items.append(f"{getattr(cookie, 'domain', '?')}:{getattr(cookie, 'name', '?')}")
- except Exception:
- try:
- # Fallback: cookie jar as dict
- for name, value in cookie_jar.items():
- cookie_items.append(f"{name}={str(value)[:20]}")
- except Exception:
- cookie_items.append(f"jar_type={type(cookie_jar).__name__}")
- self._log(f"solution D: cookies ({len(cookie_items)}): {cookie_items[:15]}")
- except Exception as exc:
- self._log(f"solution D: cookie diagnostics error: {exc}")
- # Re-use the already-built auth_url from oauth_start but remove
- # prompt=login to prevent re-authentication trigger.
- if self.oauth_start:
- try:
- parsed_auth = urllib.parse.urlparse(self.oauth_start.auth_url)
- auth_params = dict(urllib.parse.parse_qsl(parsed_auth.query))
- # Remove prompt=login to skip forced re-auth
- auth_params.pop("prompt", None)
- authorize_url = f"{parsed_auth.scheme}://{parsed_auth.netloc}{parsed_auth.path}?{urllib.parse.urlencode(auth_params)}"
- self._log("solution D: attempting authorize with existing session")
- auth_resp = self.session.get(
- authorize_url,
- allow_redirects=False,
- timeout=15,
- )
- self._log(
- f"solution D: authorize status={auth_resp.status_code}, "
- f"location={str(auth_resp.headers.get('Location', ''))[:120]}"
- )
- # Follow redirect chain to find callback URL with code=
- callback_url = None
- current_url = str(auth_resp.headers.get("Location", "")).strip()
- if auth_resp.status_code in {301, 302, 303, 307, 308} and current_url:
- for hop in range(8):
- self._log(f"solution D: redirect hop {hop + 1}: {current_url[:120]}")
- if "code=" in current_url and "state=" in current_url:
- callback_url = current_url
- self._log("solution D: ✅ callback URL extracted from authorize redirect")
- break
- try:
- hop_resp = self.session.get(
- current_url,
- allow_redirects=False,
- timeout=15,
- )
- next_loc = str(hop_resp.headers.get("Location", "")).strip()
- if hop_resp.status_code not in {301, 302, 303, 307, 308} or not next_loc:
- self._log(
- f"solution D: redirect chain ended at hop {hop + 1} "
- f"status={hop_resp.status_code}"
- )
- # Check if the final URL itself contains code=
- final_url = str(hop_resp.url or current_url)
- if "code=" in final_url and "state=" in final_url:
- callback_url = final_url
- self._log("solution D: ✅ callback URL found in final redirect URL")
- break
- current_url = urllib.parse.urljoin(current_url, next_loc)
- except Exception as exc:
- self._log(f"solution D: redirect hop {hop + 1} error: {exc}")
- break
- elif auth_resp.status_code == 200:
- # Might be a page that needs interaction (e.g., consent)
- body_preview = str(auth_resp.text or "")[:200]
- self._log(f"solution D: authorize returned 200, body={body_preview}")
- html_path = _capture_add_phone_html_if_present(
- self,
- session=self.session,
- url=str(getattr(auth_resp, "url", "") or authorize_url),
- label="direct-authorize",
- )
- if html_path:
- _set_add_phone_trace_fields(self, direct_authorize_html_path=html_path)
- # If we got a callback URL, exchange it
- if callback_url:
- self._log("solution D: exchanging callback code via oauth handler")
- token_info = self._handle_oauth_callback(callback_url)
- if token_info:
- self._log("solution D: ✅ token obtained via authorize → callback exchange")
- token_info["source"] = "direct_authorize"
- return token_info
- else:
- self._log("solution D: callback exchange failed")
- except Exception as exc:
- self._log(f"solution D: authorize flow error: {exc}")
- # ── Step 2: try chatgpt.com/api/auth/session directly ──
- try:
- session_url = "https://chatgpt.com/api/auth/session"
- resp = self.session.get(
- session_url,
- headers={
- "accept": "application/json",
- "referer": "https://chatgpt.com/",
- },
- timeout=15,
- )
- self._log(f"solution D: session endpoint status={resp.status_code}")
- if resp.status_code == 200:
- try:
- session_data = resp.json()
- except Exception:
- session_data = {}
- _set_add_phone_trace_fields(
- self,
- direct_session_status=resp.status_code,
- direct_session_keys=sorted(str(key) for key in session_data.keys()),
- )
- body_summary = str(resp.text or "")[:200]
- self._log(f"solution D: session body summary: {body_summary}")
- access_token = str(session_data.get("accessToken") or "").strip()
- if access_token:
- self._log(f"solution D: accessToken obtained (len={len(access_token)})")
- token_info = self._parse_session_jwt(access_token, session_data)
- return token_info
- else:
- self._log(
- f"solution D: no accessToken in session response, "
- f"keys={list(session_data.keys())}"
- )
- except Exception as exc:
- self._log(f"solution D: session endpoint error: {exc}")
- self._log("solution D: all extraction attempts failed")
- return None
- except Exception as exc:
- self._log(f"solution D: direct session token extraction failed: {exc}")
- return None
- def _parse_session_jwt(self, access_token: str, session_data: dict[str, Any]) -> dict[str, Any]:
- """Parse accessToken JWT and build token_info dict."""
- token_info: dict[str, Any] = {
- "access_token": access_token,
- "id_token": access_token,
- "source": "direct_session",
- }
- for key in ("user", "expires"):
- if key in session_data:
- token_info[key] = session_data[key]
- try:
- parts = access_token.split(".")
- if len(parts) >= 2:
- payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
- payload = json.loads(base64.urlsafe_b64decode(payload_b64))
- if isinstance(payload, dict):
- auth_claims = payload.get("https://api.openai.com/auth") or {}
- chatgpt_account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
- for jwt_key, info_key in [
- ("sub", "account_id"),
- ("email", "email"),
- ]:
- if jwt_key in payload:
- token_info[info_key] = str(payload[jwt_key])
- exp_val = payload.get("exp")
- if exp_val is not None:
- try:
- token_info["expired"] = time.strftime(
- "%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(exp_val))
- )
- except (ValueError, TypeError, OSError):
- token_info["expired"] = str(exp_val)
- if chatgpt_account_id:
- token_info["account_id"] = chatgpt_account_id
- scope = payload.get("scope") or payload.get("scp")
- if scope:
- token_info["scope"] = scope if isinstance(scope, str) else " ".join(scope)
- self._log(
- f"solution D: JWT decoded - account_id={token_info.get('account_id', 'N/A')}, "
- f"email={token_info.get('email', 'N/A')}, "
- f"scope={str(token_info.get('scope', 'N/A'))[:80]}"
- )
- except Exception as exc:
- self._log(f"solution D: JWT decode warning: {exc}")
- self._log("solution D: ✅ direct session token extraction succeeded")
- return token_info
- def _follow_redirects(self, start_url: str) -> str | None:
- if self.session is None:
- return None
- current_url = start_url
- try:
- for index in range(12):
- self._log(f"follow redirect {index + 1}: {current_url[:120]}")
- response = self.session.get(
- current_url,
- allow_redirects=False,
- timeout=15,
- )
- location = str(response.headers.get("Location") or "").strip()
- if response.status_code not in {301, 302, 303, 307, 308}:
- self._log(f"redirect chain ended with status {response.status_code}")
- break
- if not location:
- self._log("redirect location missing")
- break
- next_url = urllib.parse.urljoin(current_url, location)
- if "code=" in next_url and "state=" in next_url:
- self._log("oauth callback url reached")
- return next_url
- current_url = next_url
- self._log("callback url not reached in redirect chain")
- return None
- except Exception as exc:
- self._log(f"follow_redirects failed: {exc}")
- return None
- def _handle_oauth_callback(self, callback_url: str) -> dict[str, Any] | None:
- if not self.oauth_start:
- return None
- try:
- self._log("handling oauth callback")
- token_info = self.oauth_manager.handle_callback(
- callback_url=callback_url,
- expected_state=self.oauth_start.state,
- code_verifier=self.oauth_start.code_verifier,
- )
- self._log("oauth callback exchanged successfully")
- return token_info
- except Exception as exc:
- self._log(f"handle_oauth_callback failed: {exc}")
- return None
|