Logs show curl(28)/ReadTimeout on direct mint while registration browser still works; skip slow requests fallback, shorten HTTP step timeout, and mint via page first when available.
913 lines
29 KiB
Python
913 lines
29 KiB
Python
"""xAI OAuth Authorization Code + PKCE (SSO cookie → CPA token).
|
||
|
||
对齐最新可用流程:authorize / consent 必须带 referrer=grok-build,
|
||
否则 access_token JWT 缺少 referrer 字段,cli-chat-proxy / grok-build 不可用。
|
||
|
||
参考实现:sso -> oauth2/authorize(referrer=grok-build) -> consent allow
|
||
-> oauth2/token (authorization_code + PKCE)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import secrets
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Any, Callable
|
||
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
|
||
|
||
from .proxyutil import resolve_proxy
|
||
|
||
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||
ISSUER = "https://auth.x.ai"
|
||
TOKEN_URL = f"{ISSUER}/oauth2/token"
|
||
AUTHORIZE_URL = f"{ISSUER}/oauth2/authorize"
|
||
REDIRECT_URI = "http://127.0.0.1:56121/callback"
|
||
# 比旧 device-code scope 多 conversations:*,对齐 grok-build
|
||
SCOPE = (
|
||
"openid profile email offline_access "
|
||
"grok-cli:access api:access conversations:read conversations:write"
|
||
)
|
||
GROK_REFERRER = "grok-build"
|
||
GROK_VERSION = "0.2.93"
|
||
GROK_TOKEN_UA = (
|
||
f"grok-pager/{GROK_VERSION} grok-shell/{GROK_VERSION} (linux; x86_64)"
|
||
)
|
||
# Next.js Server Action id(consent 页 POST 需要)
|
||
NEXT_ACTION_ID = "4005315a1d7e426de592990bb54bb37471f39dd6d2"
|
||
BROWSER_UA = (
|
||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||
"(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||
)
|
||
|
||
LogFn = Callable[[str], None]
|
||
|
||
|
||
def _noop_log(_: str) -> None:
|
||
return None
|
||
|
||
|
||
class OAuthCodeError(RuntimeError):
|
||
pass
|
||
|
||
|
||
@dataclass
|
||
class AuthCodeFlow:
|
||
state: str
|
||
nonce: str
|
||
code_verifier: str
|
||
code_challenge: str
|
||
|
||
|
||
@dataclass
|
||
class TokenResult:
|
||
access_token: str
|
||
refresh_token: str
|
||
id_token: str | None
|
||
token_type: str
|
||
expires_in: int
|
||
raw: dict[str, Any]
|
||
referrer: str = ""
|
||
|
||
|
||
def _b64url(data: bytes) -> str:
|
||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||
|
||
|
||
def new_auth_code_flow() -> AuthCodeFlow:
|
||
verifier = _b64url(secrets.token_bytes(32))
|
||
state = _b64url(secrets.token_bytes(16))
|
||
nonce = _b64url(secrets.token_bytes(16))
|
||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||
return AuthCodeFlow(
|
||
state=state,
|
||
nonce=nonce,
|
||
code_verifier=verifier,
|
||
code_challenge=challenge,
|
||
)
|
||
|
||
|
||
def normalize_sso_cookie(raw: str) -> str:
|
||
token = (raw or "").strip()
|
||
if token.lower().startswith("sso="):
|
||
token = token[4:].strip()
|
||
return token
|
||
|
||
|
||
def jwt_payload(token: str) -> dict[str, Any]:
|
||
parts = (token or "").split(".")
|
||
if len(parts) < 2:
|
||
raise ValueError("invalid JWT")
|
||
seg = parts[1]
|
||
seg += "=" * (-len(seg) % 4)
|
||
return json.loads(base64.urlsafe_b64decode(seg.encode("ascii")))
|
||
|
||
|
||
def _short(value: str, limit: int = 240) -> str:
|
||
value = value or ""
|
||
return value if len(value) <= limit else value[:limit]
|
||
|
||
|
||
def _is_curl_tls_broken(exc: BaseException | str) -> bool:
|
||
"""识别 curl_cffi / libcurl OpenSSL 损坏类错误(常见于部分 Windows 环境)。"""
|
||
text = str(exc or "").lower()
|
||
needles = (
|
||
"openssl_internal:invalid library",
|
||
"tls connect error",
|
||
"curl: (35)",
|
||
"failed to perform, curl: (35)",
|
||
"ssl_error_syscall",
|
||
"ssl connect error",
|
||
"wrong version number",
|
||
)
|
||
return any(n in text for n in needles)
|
||
|
||
|
||
def _make_std_session(proxy: str | None = None):
|
||
try:
|
||
import requests as std_requests
|
||
except ImportError as e: # pragma: no cover
|
||
raise OAuthCodeError(
|
||
"需要 curl_cffi 或 requests 才能执行 SSO→OAuth 转换"
|
||
) from e
|
||
resolved = resolve_proxy(proxy)
|
||
proxies = {"http": resolved, "https": resolved} if resolved else None
|
||
sess = std_requests.Session()
|
||
if proxies:
|
||
sess.proxies.update(proxies)
|
||
try:
|
||
sess._cpa_http_backend = "requests" # type: ignore[attr-defined]
|
||
except Exception:
|
||
pass
|
||
return sess
|
||
|
||
|
||
def _make_curl_session(proxy: str | None = None):
|
||
from curl_cffi import requests as crequests
|
||
|
||
resolved = resolve_proxy(proxy)
|
||
proxies = {"http": resolved, "https": resolved} if resolved else None
|
||
last_err: Exception | None = None
|
||
for impersonate in ("chrome131", "chrome124", "chrome120", "chrome110", None):
|
||
try:
|
||
if impersonate:
|
||
sess = crequests.Session(impersonate=impersonate, proxies=proxies)
|
||
else:
|
||
sess = crequests.Session(proxies=proxies)
|
||
try:
|
||
sess._cpa_http_backend = f"curl_cffi:{impersonate or 'default'}" # type: ignore[attr-defined]
|
||
except Exception:
|
||
pass
|
||
return sess
|
||
except Exception as exc: # noqa: BLE001
|
||
last_err = exc
|
||
continue
|
||
if last_err:
|
||
raise last_err
|
||
raise OAuthCodeError("curl_cffi Session 创建失败")
|
||
|
||
|
||
def _make_session(proxy: str | None = None, *, prefer: str = "curl"):
|
||
"""优先 curl_cffi(Chrome TLS);prefer=requests 时直接标准库。"""
|
||
prefer = (prefer or "curl").strip().lower()
|
||
errors: list[str] = []
|
||
if prefer != "requests":
|
||
try:
|
||
return _make_curl_session(proxy)
|
||
except ImportError:
|
||
errors.append("curl_cffi 未安装")
|
||
except Exception as exc: # noqa: BLE001
|
||
errors.append(f"curl_cffi: {exc}")
|
||
try:
|
||
return _make_std_session(proxy)
|
||
except Exception as exc: # noqa: BLE001
|
||
errors.append(f"requests: {exc}")
|
||
raise OAuthCodeError(
|
||
"无法创建 HTTP Session: " + " | ".join(errors)
|
||
) from exc
|
||
|
||
|
||
def _set_sso_cookies(session: Any, sso: str) -> None:
|
||
sso = normalize_sso_cookie(sso)
|
||
if not sso:
|
||
raise OAuthCodeError("sso cookie 为空")
|
||
# curl_cffi / requests cookie jar
|
||
for domain in ("accounts.x.ai", "auth.x.ai", ".x.ai"):
|
||
for name in ("sso", "sso-rw"):
|
||
try:
|
||
session.cookies.set(name, sso, domain=domain, path="/")
|
||
except Exception:
|
||
try:
|
||
session.cookies.set(name, sso)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _browser_headers(method: str, url: str, next_action: str = "") -> dict[str, str]:
|
||
headers = {
|
||
"User-Agent": BROWSER_UA,
|
||
"Sec-CH-UA": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"',
|
||
"Sec-CH-UA-Mobile": "?0",
|
||
"Sec-CH-UA-Platform": '"Linux"',
|
||
"Accept-Language": "en-US,en;q=0.9",
|
||
}
|
||
if method.upper() == "POST":
|
||
headers.update(
|
||
{
|
||
"Accept": "text/x-component",
|
||
"Content-Type": "text/plain;charset=UTF-8",
|
||
"Origin": "https://accounts.x.ai",
|
||
"Referer": url,
|
||
"Sec-Fetch-Site": "same-origin",
|
||
"Sec-Fetch-Mode": "cors",
|
||
"Sec-Fetch-Dest": "empty",
|
||
}
|
||
)
|
||
if next_action:
|
||
headers["Next-Action"] = next_action
|
||
else:
|
||
headers.update(
|
||
{
|
||
"Accept": (
|
||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||
"application/json;q=0.8,*/*;q=0.7"
|
||
),
|
||
"Upgrade-Insecure-Requests": "1",
|
||
"Sec-Fetch-Site": "none",
|
||
"Sec-Fetch-Mode": "navigate",
|
||
"Sec-Fetch-Dest": "document",
|
||
}
|
||
)
|
||
return headers
|
||
|
||
|
||
def _token_headers() -> dict[str, str]:
|
||
return {
|
||
"User-Agent": GROK_TOKEN_UA,
|
||
"Accept": "*/*",
|
||
"X-Grok-Client-Version": GROK_VERSION,
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
}
|
||
|
||
|
||
def _final_url(resp: Any) -> str:
|
||
try:
|
||
return str(getattr(resp, "url", "") or "")
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
# HTTP 铸造单步超时:直连/跨境链路差时不要卡 30s,尽快让上层走浏览器
|
||
HTTP_STEP_TIMEOUT = 12
|
||
|
||
|
||
def open_authorize_page(session: Any, flow: AuthCodeFlow) -> str:
|
||
params = {
|
||
"response_type": "code",
|
||
"client_id": CLIENT_ID,
|
||
"redirect_uri": REDIRECT_URI,
|
||
"scope": SCOPE,
|
||
"code_challenge": flow.code_challenge,
|
||
"code_challenge_method": "S256",
|
||
"state": flow.state,
|
||
"nonce": flow.nonce,
|
||
"referrer": GROK_REFERRER,
|
||
}
|
||
url = f"{AUTHORIZE_URL}?{urlencode(params)}"
|
||
resp = session.get(
|
||
url,
|
||
headers=_browser_headers("GET", url),
|
||
allow_redirects=True,
|
||
timeout=HTTP_STEP_TIMEOUT,
|
||
)
|
||
body = resp.text or ""
|
||
final = _final_url(resp)
|
||
if resp.status_code < 200 or resp.status_code >= 300:
|
||
raise OAuthCodeError(
|
||
f"authorize HTTP {resp.status_code}: {_short(body)}"
|
||
)
|
||
if "sign-in" in final or "sign-up" in final:
|
||
raise OAuthCodeError("sso 无效(authorize 跳转登录页)")
|
||
if "/oauth2/consent" not in final:
|
||
# 少数情况 consent 在 body 的 redirect 里
|
||
m = re.search(r'https?://[^"\']+/oauth2/consent[^"\']*', body)
|
||
if m:
|
||
final = m.group(0)
|
||
else:
|
||
raise OAuthCodeError(f"authorize 未进入 consent: {final or _short(body)}")
|
||
return final
|
||
|
||
|
||
def parse_consent_code(body: str) -> str:
|
||
"""从 Next.js RSC / text-x-component 响应中解析 code。"""
|
||
text = body or ""
|
||
# 1) 逐行 JSON(Go 实现路径)
|
||
for line in text.splitlines():
|
||
idx = line.find("{")
|
||
if idx < 0:
|
||
continue
|
||
try:
|
||
obj = json.loads(line[idx:])
|
||
except Exception:
|
||
continue
|
||
if isinstance(obj, dict) and obj.get("code"):
|
||
if obj.get("success") is False:
|
||
raise OAuthCodeError(
|
||
f"consent 失败: {obj.get('error') or obj.get('action')}"
|
||
)
|
||
return str(obj["code"]).strip()
|
||
if isinstance(obj, list):
|
||
for item in obj:
|
||
if isinstance(item, dict) and item.get("code"):
|
||
return str(item["code"]).strip()
|
||
|
||
# 2) 宽松正则
|
||
m = re.search(r'"code"\s*:\s*"([A-Za-z0-9._~\-]+)"', text)
|
||
if m:
|
||
return m.group(1)
|
||
|
||
# 3) redirect 里带 code=
|
||
m = re.search(r"[?&]code=([A-Za-z0-9._~\-]+)", text)
|
||
if m:
|
||
return m.group(1)
|
||
|
||
raise OAuthCodeError(f"consent 响应缺少 code: {_short(text, 300)}")
|
||
|
||
|
||
def approve_authorization(session: Any, consent_url: str, flow: AuthCodeFlow) -> str:
|
||
payload = [
|
||
{
|
||
"action": "allow",
|
||
"clientId": CLIENT_ID,
|
||
"redirectUri": REDIRECT_URI,
|
||
"scope": SCOPE,
|
||
"state": flow.state,
|
||
"codeChallenge": flow.code_challenge,
|
||
"codeChallengeMethod": "S256",
|
||
"nonce": flow.nonce,
|
||
"principalType": "User",
|
||
"principalId": "",
|
||
"referrer": GROK_REFERRER,
|
||
}
|
||
]
|
||
body = json.dumps(payload, separators=(",", ":"))
|
||
resp = session.post(
|
||
consent_url,
|
||
data=body.encode("utf-8"),
|
||
headers=_browser_headers("POST", consent_url, NEXT_ACTION_ID),
|
||
allow_redirects=True,
|
||
timeout=HTTP_STEP_TIMEOUT,
|
||
)
|
||
text = resp.text or ""
|
||
if resp.status_code < 200 or resp.status_code >= 300:
|
||
raise OAuthCodeError(f"consent HTTP {resp.status_code}: {_short(text, 300)}")
|
||
|
||
# 有时 302 到 redirect_uri?code=
|
||
final = _final_url(resp)
|
||
if "code=" in final:
|
||
qs = parse_qs(urlparse(final).query)
|
||
code = (qs.get("code") or [""])[0]
|
||
if code:
|
||
return code
|
||
|
||
return parse_consent_code(text)
|
||
|
||
|
||
def exchange_auth_code(session: Any, code: str, flow: AuthCodeFlow) -> TokenResult:
|
||
form = {
|
||
"grant_type": "authorization_code",
|
||
"code": code,
|
||
"redirect_uri": REDIRECT_URI,
|
||
"client_id": CLIENT_ID,
|
||
"code_verifier": flow.code_verifier,
|
||
}
|
||
resp = session.post(
|
||
TOKEN_URL,
|
||
data=urlencode(form),
|
||
headers=_token_headers(),
|
||
timeout=HTTP_STEP_TIMEOUT,
|
||
)
|
||
text = resp.text or ""
|
||
if resp.status_code < 200 or resp.status_code >= 300:
|
||
raise OAuthCodeError(f"token HTTP {resp.status_code}: {_short(text, 300)}")
|
||
try:
|
||
data = resp.json()
|
||
except Exception as e:
|
||
raise OAuthCodeError(f"token 响应非 JSON: {_short(text)}") from e
|
||
if not isinstance(data, dict) or not data.get("access_token"):
|
||
raise OAuthCodeError(f"token 响应缺少 access_token: {data!r}")
|
||
|
||
access = str(data["access_token"]).strip()
|
||
refresh = str(data.get("refresh_token") or "").strip()
|
||
if not refresh:
|
||
raise OAuthCodeError("token 响应缺少 refresh_token")
|
||
|
||
referrer = ""
|
||
try:
|
||
pl = jwt_payload(access)
|
||
referrer = str(pl.get("referrer") or "")
|
||
except Exception:
|
||
pl = {}
|
||
|
||
expires_in = int(data.get("expires_in") or 21600)
|
||
return TokenResult(
|
||
access_token=access,
|
||
refresh_token=refresh,
|
||
id_token=(str(data["id_token"]).strip() if data.get("id_token") else None),
|
||
token_type=str(data.get("token_type") or "Bearer"),
|
||
expires_in=expires_in,
|
||
raw=data,
|
||
referrer=referrer,
|
||
)
|
||
|
||
|
||
def _run_sso_flow(
|
||
sso: str,
|
||
*,
|
||
session: Any,
|
||
log: LogFn,
|
||
require_referrer: bool,
|
||
) -> TokenResult:
|
||
flow = new_auth_code_flow()
|
||
backend = getattr(session, "_cpa_http_backend", "unknown")
|
||
log(f"Authorization Code Flow referrer={GROK_REFERRER} http={backend}")
|
||
_set_sso_cookies(session, sso)
|
||
consent_url = open_authorize_page(session, flow)
|
||
log(f"authorize -> consent: {_short(consent_url, 120)}")
|
||
code = approve_authorization(session, consent_url, flow)
|
||
log("consent allow ok")
|
||
token = exchange_auth_code(session, code, flow)
|
||
if token.referrer != GROK_REFERRER:
|
||
msg = f"access_token 未包含预期 referrer(got={token.referrer!r})"
|
||
if require_referrer:
|
||
raise OAuthCodeError(msg)
|
||
log(f"WARN {msg}")
|
||
else:
|
||
log("access_token referrer=grok-build ok")
|
||
log(f"token ok expires_in={token.expires_in} refresh=yes")
|
||
return token
|
||
|
||
|
||
def sso_to_token(
|
||
sso_cookie: str,
|
||
*,
|
||
proxy: str | None = None,
|
||
log: LogFn | None = None,
|
||
require_referrer: bool = True,
|
||
) -> TokenResult:
|
||
"""SSO cookie → 带 referrer=grok-build 的 OAuth token。
|
||
|
||
仅用 curl_cffi(Chrome TLS)。不再回退 std requests:
|
||
实测 requests 访问 auth.x.ai / accounts.x.ai 常 ReadTimeout,比 curl 更差。
|
||
连接/TLS/超时由上层切到浏览器 PKCE。
|
||
"""
|
||
log = log or _noop_log
|
||
sso = normalize_sso_cookie(sso_cookie)
|
||
if not sso:
|
||
raise OAuthCodeError("sso cookie 为空")
|
||
|
||
session = _make_session(proxy, prefer="curl")
|
||
try:
|
||
return _run_sso_flow(
|
||
sso, session=session, log=log, require_referrer=require_referrer
|
||
)
|
||
finally:
|
||
try:
|
||
session.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def mint_from_sso(
|
||
sso_cookie: str,
|
||
*,
|
||
proxy: str | None = None,
|
||
log: LogFn | None = None,
|
||
require_referrer: bool = True,
|
||
) -> dict[str, Any]:
|
||
"""上层统一返回 dict,兼容 cpa_export。"""
|
||
tr = sso_to_token(
|
||
sso_cookie,
|
||
proxy=proxy,
|
||
log=log,
|
||
require_referrer=require_referrer,
|
||
)
|
||
return {
|
||
"access_token": tr.access_token,
|
||
"refresh_token": tr.refresh_token,
|
||
"id_token": tr.id_token,
|
||
"token_type": tr.token_type,
|
||
"expires_in": tr.expires_in,
|
||
"referrer": tr.referrer,
|
||
"sso": normalize_sso_cookie(sso_cookie),
|
||
}
|
||
|
||
|
||
def _is_http_tls_failure(exc: BaseException | str) -> bool:
|
||
"""HTTP 层 TLS/连接/超时失败:适合改走浏览器铸造。"""
|
||
text = str(exc or "").lower()
|
||
needles = (
|
||
"unexpected_eof_while_reading",
|
||
"sslerror",
|
||
"ssleoferror",
|
||
"max retries exceeded",
|
||
"openssl_internal:invalid library",
|
||
"tls connect error",
|
||
"curl: (35)",
|
||
"curl: (28)",
|
||
"failed to perform, curl: (35)",
|
||
"failed to perform, curl: (28)",
|
||
"connection timed out",
|
||
"ssl_error_syscall",
|
||
"connection reset",
|
||
"connection aborted",
|
||
"name resolution",
|
||
"readtimeout",
|
||
"connecttimeout",
|
||
"timed out",
|
||
"timeout",
|
||
)
|
||
return any(n in text for n in needles)
|
||
|
||
|
||
def _page_eval(page: Any, js: str, *args: Any) -> Any:
|
||
"""兼容 DrissionPage page.run_js / page.run_js_loaded。"""
|
||
if page is None:
|
||
raise OAuthCodeError("page 为空,无法浏览器铸造")
|
||
last_err: Exception | None = None
|
||
for name in ("run_js", "run_js_loaded", "run_async_js"):
|
||
fn = getattr(page, name, None)
|
||
if not callable(fn):
|
||
continue
|
||
try:
|
||
if args:
|
||
return fn(js, *args)
|
||
return fn(js)
|
||
except TypeError:
|
||
# 某些签名不接受额外参数
|
||
try:
|
||
return fn(js)
|
||
except Exception as exc: # noqa: BLE001
|
||
last_err = exc
|
||
except Exception as exc: # noqa: BLE001
|
||
last_err = exc
|
||
continue
|
||
raise OAuthCodeError(f"page 无法执行 JS: {last_err or 'no run_js'}")
|
||
|
||
|
||
def _ensure_sso_on_page(page: Any, sso: str, log: LogFn) -> None:
|
||
sso = normalize_sso_cookie(sso)
|
||
if not sso:
|
||
raise OAuthCodeError("sso cookie 为空")
|
||
# 先落到 accounts 域,再写 cookie,避免 set 失败
|
||
try:
|
||
page.get("https://accounts.x.ai/")
|
||
time.sleep(0.4)
|
||
except Exception as exc: # noqa: BLE001
|
||
log(f"open accounts.x.ai warn: {exc}")
|
||
set_js = r"""
|
||
(sso) => {
|
||
try {
|
||
const maxAge = 60 * 60 * 24 * 30;
|
||
const base = `; path=/; max-age=${maxAge}; SameSite=Lax`;
|
||
document.cookie = `sso=${sso}${base}`;
|
||
document.cookie = `sso-rw=${sso}${base}`;
|
||
// 兼容 secure 场景
|
||
document.cookie = `sso=${sso}${base}; Secure`;
|
||
document.cookie = `sso-rw=${sso}${base}; Secure`;
|
||
return document.cookie.includes('sso=');
|
||
} catch (e) {
|
||
return String(e);
|
||
}
|
||
}
|
||
"""
|
||
try:
|
||
ok = _page_eval(page, set_js, sso)
|
||
log(f"browser sso cookie set: {ok!r}")
|
||
except Exception:
|
||
# 退而求其次:DrissionPage set.cookies
|
||
try:
|
||
setter = getattr(page, "set", None)
|
||
cookies = getattr(setter, "cookies", None) if setter is not None else None
|
||
if callable(cookies):
|
||
for domain in ("accounts.x.ai", "auth.x.ai", ".x.ai"):
|
||
cookies({"name": "sso", "value": sso, "domain": domain, "path": "/"})
|
||
cookies({"name": "sso-rw", "value": sso, "domain": domain, "path": "/"})
|
||
log("browser sso cookie set via page.set.cookies")
|
||
else:
|
||
raise OAuthCodeError("无法写入 sso cookie")
|
||
except Exception as exc: # noqa: BLE001
|
||
raise OAuthCodeError(f"写入 sso cookie 失败: {exc}") from exc
|
||
|
||
|
||
def _browser_click_allow(page: Any, log: LogFn) -> bool:
|
||
js = r"""
|
||
() => {
|
||
function isVisible(node) {
|
||
if (!node) return false;
|
||
const style = window.getComputedStyle(node);
|
||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||
const rect = node.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
}
|
||
function textOf(node) {
|
||
return [node.innerText, node.textContent, node.getAttribute('aria-label'), node.getAttribute('value')]
|
||
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
const nodes = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"], a'));
|
||
const prefer = [];
|
||
const weak = [];
|
||
for (const n of nodes) {
|
||
if (!isVisible(n) || n.disabled || n.getAttribute('aria-disabled') === 'true') continue;
|
||
const t = textOf(n);
|
||
const compact = t.replace(/\s+/g, '');
|
||
const lower = compact.toLowerCase();
|
||
if (!compact) continue;
|
||
// 精确允许,排除“全部允许”
|
||
if (compact === '允许' || lower === 'allow' || lower === 'authorize' || compact === '授权') {
|
||
prefer.push(n);
|
||
continue;
|
||
}
|
||
if ((compact.includes('允许') || lower.includes('allow') || lower.includes('authorize'))
|
||
&& !compact.includes('全部') && !lower.includes('all')) {
|
||
weak.push(n);
|
||
}
|
||
}
|
||
const target = prefer[0] || weak[0];
|
||
if (!target) return {clicked:false, texts: nodes.slice(0,8).map(textOf)};
|
||
target.focus();
|
||
target.click();
|
||
return {clicked:true, text: textOf(target)};
|
||
}
|
||
"""
|
||
try:
|
||
ret = _page_eval(page, js)
|
||
except Exception as exc: # noqa: BLE001
|
||
log(f"click allow js failed: {exc}")
|
||
return False
|
||
if isinstance(ret, dict) and ret.get("clicked"):
|
||
log(f"browser clicked allow: {ret.get('text')!r}")
|
||
return True
|
||
log(f"browser allow button not found: {ret!r}")
|
||
return False
|
||
|
||
|
||
def _browser_fetch_token(page: Any, code: str, flow: AuthCodeFlow, log: LogFn) -> TokenResult:
|
||
"""在浏览器上下文用 fetch 换 token,绕开 Python TLS 对 auth.x.ai 的 EOF。"""
|
||
form = {
|
||
"grant_type": "authorization_code",
|
||
"code": code,
|
||
"redirect_uri": REDIRECT_URI,
|
||
"client_id": CLIENT_ID,
|
||
"code_verifier": flow.code_verifier,
|
||
}
|
||
body = urlencode(form)
|
||
js = r"""
|
||
(tokenUrl, body, ua, ver) => {
|
||
return fetch(tokenUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
'Accept': '*/*',
|
||
'User-Agent': ua,
|
||
'X-Grok-Client-Version': ver,
|
||
},
|
||
body: body,
|
||
credentials: 'include',
|
||
}).then(async (r) => {
|
||
const text = await r.text();
|
||
return {status: r.status, text: text};
|
||
}).catch((e) => ({status: 0, text: String(e)}));
|
||
}
|
||
"""
|
||
# 先到 auth 域,减少跨站限制
|
||
try:
|
||
page.get(ISSUER + "/")
|
||
time.sleep(0.3)
|
||
except Exception:
|
||
pass
|
||
ret = None
|
||
# DrissionPage 对 Promise 支持不一,做短轮询包装
|
||
wrap = r"""
|
||
(tokenUrl, body, ua, ver) => {
|
||
const key = '__cpa_token_result_' + Date.now();
|
||
window[key] = null;
|
||
fetch(tokenUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
'Accept': '*/*',
|
||
'User-Agent': ua,
|
||
'X-Grok-Client-Version': ver,
|
||
},
|
||
body: body,
|
||
credentials: 'include',
|
||
}).then(async (r) => {
|
||
const text = await r.text();
|
||
window[key] = {status: r.status, text: text};
|
||
}).catch((e) => {
|
||
window[key] = {status: 0, text: String(e)};
|
||
});
|
||
return key;
|
||
}
|
||
"""
|
||
try:
|
||
key = _page_eval(page, wrap, TOKEN_URL, body, GROK_TOKEN_UA, GROK_VERSION)
|
||
except Exception:
|
||
# 无参回退:把参数内联
|
||
key = _page_eval(
|
||
page,
|
||
f"""
|
||
(() => {{
|
||
const key = '__cpa_token_result_' + Date.now();
|
||
window[key] = null;
|
||
fetch({TOKEN_URL!r}, {{
|
||
method: 'POST',
|
||
headers: {{
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
'Accept': '*/*',
|
||
'User-Agent': {GROK_TOKEN_UA!r},
|
||
'X-Grok-Client-Version': {GROK_VERSION!r},
|
||
}},
|
||
body: {body!r},
|
||
credentials: 'include',
|
||
}}).then(async (r) => {{
|
||
const text = await r.text();
|
||
window[key] = {{status: r.status, text: text}};
|
||
}}).catch((e) => {{
|
||
window[key] = {{status: 0, text: String(e)}};
|
||
}});
|
||
return key;
|
||
}})()
|
||
""",
|
||
)
|
||
deadline = time.time() + 30
|
||
while time.time() < deadline:
|
||
try:
|
||
ret = _page_eval(page, f"() => window[{key!r}]")
|
||
except Exception:
|
||
try:
|
||
ret = _page_eval(page, f"window[{key!r}]")
|
||
except Exception as exc:
|
||
raise OAuthCodeError(f"读取 browser token 结果失败: {exc}") from exc
|
||
if ret:
|
||
break
|
||
time.sleep(0.2)
|
||
if not isinstance(ret, dict):
|
||
raise OAuthCodeError(f"browser token 无响应: {ret!r}")
|
||
status = int(ret.get("status") or 0)
|
||
text = str(ret.get("text") or "")
|
||
if status < 200 or status >= 300:
|
||
raise OAuthCodeError(f"browser token HTTP {status}: {_short(text, 300)}")
|
||
try:
|
||
data = json.loads(text)
|
||
except Exception as e:
|
||
raise OAuthCodeError(f"browser token 非 JSON: {_short(text)}") from e
|
||
if not isinstance(data, dict) or not data.get("access_token"):
|
||
raise OAuthCodeError(f"browser token 缺少 access_token: {data!r}")
|
||
access = str(data["access_token"]).strip()
|
||
refresh = str(data.get("refresh_token") or "").strip()
|
||
if not refresh:
|
||
raise OAuthCodeError("browser token 缺少 refresh_token")
|
||
referrer = ""
|
||
try:
|
||
referrer = str(jwt_payload(access).get("referrer") or "")
|
||
except Exception:
|
||
pass
|
||
return TokenResult(
|
||
access_token=access,
|
||
refresh_token=refresh,
|
||
id_token=(str(data["id_token"]).strip() if data.get("id_token") else None),
|
||
token_type=str(data.get("token_type") or "Bearer"),
|
||
expires_in=int(data.get("expires_in") or 21600),
|
||
raw=data,
|
||
referrer=referrer,
|
||
)
|
||
|
||
|
||
def mint_from_sso_browser(
|
||
sso_cookie: str,
|
||
page: Any,
|
||
*,
|
||
log: LogFn | None = None,
|
||
require_referrer: bool = True,
|
||
timeout_sec: float = 90.0,
|
||
) -> dict[str, Any]:
|
||
"""用注册浏览器完成 SSO→PKCE(绕开 Python TLS 访问 auth.x.ai 失败)。
|
||
|
||
流程:
|
||
1. 写入 sso cookie
|
||
2. 打开 authorize(referrer=grok-build)
|
||
3. 在 consent 页点击允许 / 或解析 callback code
|
||
4. 浏览器 fetch oauth2/token
|
||
"""
|
||
log = log or _noop_log
|
||
sso = normalize_sso_cookie(sso_cookie)
|
||
if not sso:
|
||
raise OAuthCodeError("sso cookie 为空")
|
||
if page is None:
|
||
raise OAuthCodeError("page 为空")
|
||
|
||
flow = new_auth_code_flow()
|
||
params = {
|
||
"response_type": "code",
|
||
"client_id": CLIENT_ID,
|
||
"redirect_uri": REDIRECT_URI,
|
||
"scope": SCOPE,
|
||
"code_challenge": flow.code_challenge,
|
||
"code_challenge_method": "S256",
|
||
"state": flow.state,
|
||
"nonce": flow.nonce,
|
||
"referrer": GROK_REFERRER,
|
||
}
|
||
auth_url = f"{AUTHORIZE_URL}?{urlencode(params)}"
|
||
log(f"browser PKCE authorize referrer={GROK_REFERRER}")
|
||
_ensure_sso_on_page(page, sso, log)
|
||
|
||
try:
|
||
page.get(auth_url)
|
||
except Exception as exc: # noqa: BLE001
|
||
raise OAuthCodeError(f"browser 打开 authorize 失败: {exc}") from exc
|
||
|
||
code = ""
|
||
deadline = time.time() + max(20.0, float(timeout_sec))
|
||
last_url = ""
|
||
while time.time() < deadline:
|
||
try:
|
||
url = str(getattr(page, "url", "") or "")
|
||
except Exception:
|
||
url = ""
|
||
if url and url != last_url:
|
||
log(f"browser url: {_short(url, 140)}")
|
||
last_url = url
|
||
|
||
# callback 已跳到 redirect_uri?code=
|
||
if "code=" in url and ("127.0.0.1" in url or "callback" in url or "localhost" in url):
|
||
qs = parse_qs(urlparse(url).query)
|
||
code = (qs.get("code") or [""])[0].strip()
|
||
if code:
|
||
log("browser got code from redirect")
|
||
break
|
||
|
||
# consent 页
|
||
if "/oauth2/consent" in url or "consent" in url:
|
||
_browser_click_allow(page, log)
|
||
time.sleep(0.8)
|
||
# 有时 consent 响应是 RSC,不跳转;尝试从 HTML 抽 code
|
||
try:
|
||
html = ""
|
||
try:
|
||
html = str(getattr(page, "html", "") or "")
|
||
except Exception:
|
||
html = str(_page_eval(page, "() => document.documentElement.outerHTML") or "")
|
||
if html:
|
||
try:
|
||
code = parse_consent_code(html)
|
||
if code:
|
||
log("browser got code from consent html")
|
||
break
|
||
except OAuthCodeError:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
continue
|
||
|
||
if "sign-in" in url or "sign-up" in url:
|
||
# cookie 可能没带上,重写一次
|
||
_ensure_sso_on_page(page, sso, log)
|
||
try:
|
||
page.get(auth_url)
|
||
except Exception:
|
||
pass
|
||
time.sleep(0.8)
|
||
continue
|
||
|
||
time.sleep(0.5)
|
||
|
||
if not code:
|
||
raise OAuthCodeError(
|
||
f"browser PKCE 超时未拿到 code(last_url={_short(last_url, 160)})"
|
||
)
|
||
|
||
token = _browser_fetch_token(page, code, flow, log)
|
||
if token.referrer != GROK_REFERRER:
|
||
msg = f"access_token 未包含预期 referrer(got={token.referrer!r})"
|
||
if require_referrer:
|
||
raise OAuthCodeError(msg)
|
||
log(f"WARN {msg}")
|
||
else:
|
||
log("browser access_token referrer=grok-build ok")
|
||
log(f"browser token ok expires_in={token.expires_in}")
|
||
return {
|
||
"access_token": token.access_token,
|
||
"refresh_token": token.refresh_token,
|
||
"id_token": token.id_token,
|
||
"token_type": token.token_type,
|
||
"expires_in": token.expires_in,
|
||
"referrer": token.referrer,
|
||
"sso": sso,
|
||
}
|