oauth.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. """OAuth helpers for the zhuce6 ChatGPT platform."""
  2. from __future__ import annotations
  3. import base64
  4. import hashlib
  5. import json
  6. import secrets
  7. import time
  8. import urllib.parse
  9. from dataclasses import dataclass
  10. from typing import Any
  11. from curl_cffi import requests as cffi_requests
  12. from .constants import (
  13. OAUTH_AUTH_URL,
  14. OAUTH_CLIENT_ID,
  15. OPENAI_IMPERSONATE,
  16. OPENAI_SEC_CH_UA,
  17. OPENAI_SEC_CH_UA_MOBILE,
  18. OPENAI_SEC_CH_UA_PLATFORM,
  19. OPENAI_USER_AGENT,
  20. OAUTH_REDIRECT_URI,
  21. OAUTH_SCOPE,
  22. OAUTH_TOKEN_URL,
  23. )
  24. def _b64url_no_pad(raw: bytes) -> str:
  25. return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
  26. def _sha256_b64url_no_pad(value: str) -> str:
  27. return _b64url_no_pad(hashlib.sha256(value.encode("ascii")).digest())
  28. def _random_state(nbytes: int = 16) -> str:
  29. return secrets.token_urlsafe(nbytes)
  30. def _pkce_verifier() -> str:
  31. return secrets.token_urlsafe(64)
  32. def _parse_callback_url(callback_url: str) -> dict[str, str]:
  33. candidate = callback_url.strip()
  34. if not candidate:
  35. return {"code": "", "state": "", "error": "", "error_description": ""}
  36. if "://" not in candidate:
  37. if candidate.startswith("?"):
  38. candidate = f"http://localhost{candidate}"
  39. elif "=" in candidate:
  40. candidate = f"http://localhost/?{candidate}"
  41. else:
  42. candidate = f"http://{candidate}"
  43. parsed = urllib.parse.urlparse(candidate)
  44. query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
  45. fragment = urllib.parse.parse_qs(parsed.fragment, keep_blank_values=True)
  46. for key, values in fragment.items():
  47. if key not in query or not query[key]:
  48. query[key] = values
  49. def get1(key: str) -> str:
  50. return str((query.get(key, [""])[0] or "")).strip()
  51. return {
  52. "code": get1("code"),
  53. "state": get1("state"),
  54. "error": get1("error"),
  55. "error_description": get1("error_description"),
  56. }
  57. def _jwt_claims_no_verify(id_token: str) -> dict[str, Any]:
  58. if not id_token or id_token.count(".") < 2:
  59. return {}
  60. payload_b64 = id_token.split(".")[1]
  61. pad = "=" * ((4 - (len(payload_b64) % 4)) % 4)
  62. try:
  63. payload = base64.urlsafe_b64decode((payload_b64 + pad).encode("ascii"))
  64. return json.loads(payload.decode("utf-8"))
  65. except Exception:
  66. return {}
  67. def _to_int(value: Any) -> int:
  68. try:
  69. return int(value)
  70. except (TypeError, ValueError):
  71. return 0
  72. def _post_form(
  73. url: str,
  74. data: dict[str, str],
  75. timeout: int = 30,
  76. proxy_url: str | None = None,
  77. ) -> dict[str, Any]:
  78. proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
  79. response = cffi_requests.post(
  80. url,
  81. data=data,
  82. headers={
  83. "Content-Type": "application/x-www-form-urlencoded",
  84. "Accept": "application/json",
  85. "User-Agent": OPENAI_USER_AGENT,
  86. "sec-ch-ua": OPENAI_SEC_CH_UA,
  87. "sec-ch-ua-mobile": OPENAI_SEC_CH_UA_MOBILE,
  88. "sec-ch-ua-platform": OPENAI_SEC_CH_UA_PLATFORM,
  89. },
  90. timeout=timeout,
  91. proxies=proxies,
  92. impersonate=OPENAI_IMPERSONATE,
  93. )
  94. if response.status_code != 200:
  95. raise RuntimeError(f"token exchange failed: {response.status_code}: {response.text}")
  96. return response.json()
  97. @dataclass(frozen=True)
  98. class OAuthStart:
  99. auth_url: str
  100. state: str
  101. code_verifier: str
  102. redirect_uri: str
  103. def generate_oauth_url(
  104. *,
  105. redirect_uri: str = OAUTH_REDIRECT_URI,
  106. scope: str = OAUTH_SCOPE,
  107. client_id: str = OAUTH_CLIENT_ID,
  108. ) -> OAuthStart:
  109. state = _random_state()
  110. code_verifier = _pkce_verifier()
  111. code_challenge = _sha256_b64url_no_pad(code_verifier)
  112. import uuid as _uuid
  113. device_id = str(_uuid.uuid4())
  114. params = {
  115. "client_id": client_id,
  116. "scope": scope,
  117. "response_type": "code",
  118. "redirect_uri": redirect_uri,
  119. "audience": "https://api.openai.com/v1",
  120. "device_id": device_id,
  121. "prompt": "login",
  122. "ext-oai-did": device_id,
  123. "ext-passkey-client-capabilities": "1111",
  124. "screen_hint": "signup",
  125. "state": state,
  126. "code_challenge": code_challenge,
  127. "code_challenge_method": "S256",
  128. }
  129. auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
  130. return OAuthStart(
  131. auth_url=auth_url,
  132. state=state,
  133. code_verifier=code_verifier,
  134. redirect_uri=redirect_uri,
  135. )
  136. def submit_callback_url(
  137. *,
  138. callback_url: str,
  139. expected_state: str,
  140. code_verifier: str,
  141. redirect_uri: str = OAUTH_REDIRECT_URI,
  142. client_id: str = OAUTH_CLIENT_ID,
  143. token_url: str = OAUTH_TOKEN_URL,
  144. proxy_url: str | None = None,
  145. ) -> str:
  146. callback = _parse_callback_url(callback_url)
  147. if callback["error"]:
  148. raise RuntimeError(f"oauth error: {callback['error']}: {callback['error_description']}".strip())
  149. if not callback["code"]:
  150. raise ValueError("callback url missing ?code=")
  151. if not callback["state"]:
  152. raise ValueError("callback url missing ?state=")
  153. if callback["state"] != expected_state:
  154. raise ValueError("state mismatch")
  155. token_resp = _post_form(
  156. token_url,
  157. {
  158. "grant_type": "authorization_code",
  159. "client_id": client_id,
  160. "code": callback["code"],
  161. "redirect_uri": redirect_uri,
  162. "code_verifier": code_verifier,
  163. },
  164. proxy_url=proxy_url,
  165. )
  166. access_token = str(token_resp.get("access_token") or "").strip()
  167. refresh_token = str(token_resp.get("refresh_token") or "").strip()
  168. id_token = str(token_resp.get("id_token") or "").strip()
  169. expires_in = _to_int(token_resp.get("expires_in"))
  170. claims = _jwt_claims_no_verify(id_token)
  171. email = str(claims.get("email") or "").strip()
  172. auth_claims = claims.get("https://api.openai.com/auth") or {}
  173. account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
  174. now = int(time.time())
  175. expired_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0)))
  176. now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
  177. config = {
  178. "id_token": id_token,
  179. "access_token": access_token,
  180. "refresh_token": refresh_token,
  181. "account_id": account_id,
  182. "last_refresh": now_rfc3339,
  183. "email": email,
  184. "type": "codex",
  185. "expired": expired_rfc3339,
  186. }
  187. return json.dumps(config, ensure_ascii=False, separators=(",", ":"))
  188. class OAuthManager:
  189. def __init__(
  190. self,
  191. client_id: str = OAUTH_CLIENT_ID,
  192. auth_url: str = OAUTH_AUTH_URL,
  193. token_url: str = OAUTH_TOKEN_URL,
  194. redirect_uri: str = OAUTH_REDIRECT_URI,
  195. scope: str = OAUTH_SCOPE,
  196. proxy_url: str | None = None,
  197. ) -> None:
  198. self.client_id = client_id
  199. self.auth_url = auth_url
  200. self.token_url = token_url
  201. self.redirect_uri = redirect_uri
  202. self.scope = scope
  203. self.proxy_url = proxy_url
  204. def start_oauth(self) -> OAuthStart:
  205. return generate_oauth_url(
  206. redirect_uri=self.redirect_uri,
  207. scope=self.scope,
  208. client_id=self.client_id,
  209. )
  210. def handle_callback(self, callback_url: str, expected_state: str, code_verifier: str) -> dict[str, Any]:
  211. return json.loads(
  212. submit_callback_url(
  213. callback_url=callback_url,
  214. expected_state=expected_state,
  215. code_verifier=code_verifier,
  216. redirect_uri=self.redirect_uri,
  217. client_id=self.client_id,
  218. token_url=self.token_url,
  219. proxy_url=self.proxy_url,
  220. )
  221. )