From 8b399277e7fe39e182abd2e6e7387f35e7f78745 Mon Sep 17 00:00:00 2001 From: Chaos Date: Tue, 14 Jul 2026 10:04:55 +0800 Subject: [PATCH] Fallback SSO OAuth mint to browser PKCE when Python TLS fails. HTTP mint often dies on auth.x.ai (curl OpenSSL / SSLEOFError); reuse the registration browser to set SSO, complete authorize+consent, and fetch tokens so CPA export/push to cpa.nopj.cn can continue. --- cpa_export.py | 48 ++++- grok_register_ttk.py | 1 + oidc_mint/__init__.py | 2 + oidc_mint/oauth_code.py | 401 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 443 insertions(+), 9 deletions(-) diff --git a/cpa_export.py b/cpa_export.py index 9a359d4..357d215 100644 --- a/cpa_export.py +++ b/cpa_export.py @@ -72,13 +72,22 @@ def _mint_tokens( log: Callable[[str], None], proxy: str | None, ) -> dict[str, Any]: - """优先 SSO 授权码;可选回退设备码。""" - from oidc_mint.oauth_code import OAuthCodeError, mint_from_sso, normalize_sso_cookie + """优先 HTTP SSO 授权码;TLS 失败时用注册浏览器 PKCE;可选设备码。""" + from oidc_mint.oauth_code import ( + OAuthCodeError, + _is_http_tls_failure, + mint_from_sso, + mint_from_sso_browser, + normalize_sso_cookie, + ) sso_token = normalize_sso_cookie(sso or "") prefer_sso = bool(cfg.get("cpa_prefer_sso_oauth", True)) + allow_browser = bool(cfg.get("cpa_allow_browser_fallback", True)) allow_device = bool(cfg.get("cpa_allow_device_fallback", False)) timeout = float(cfg.get("mint_timeout_sec", 300) or 300) + require_ref = bool(cfg.get("cpa_require_referrer", True)) + http_err: Exception | None = None if prefer_sso and sso_token: log("[cpa] 使用 SSO→OAuth(PKCE, referrer=grok-build)") @@ -87,21 +96,42 @@ def _mint_tokens( sso_token, proxy=proxy, log=lambda m: log(f"[Debug] {m}"), - require_referrer=bool(cfg.get("cpa_require_referrer", True)), + require_referrer=require_ref, ) except OAuthCodeError as exc: + http_err = exc log(f"[!] SSO→OAuth 失败: {exc}") - if not allow_device: - raise - log("[cpa] 回退设备码铸造(可能缺 referrer)") except Exception as exc: # noqa: BLE001 + http_err = exc log(f"[!] SSO→OAuth 异常: {exc}") - if not allow_device: - raise - log("[cpa] 回退设备码铸造(可能缺 referrer)") + + # HTTP 失败后:有 page+sso 就优先浏览器 PKCE(Chrome TLS 通常正常,且保留 referrer) + if allow_browser and page is not None and sso_token and http_err is not None: + why = "TLS/连接" if _is_http_tls_failure(http_err) else "HTTP" + log(f"[cpa] 回退浏览器 PKCE 铸造({why}失败,绕开 Python TLS)") + try: + return mint_from_sso_browser( + sso_token, + page, + log=lambda m: log(f"[Debug] {m}"), + require_referrer=require_ref, + timeout_sec=min(timeout, 120.0), + ) + except Exception as exc: # noqa: BLE001 + log(f"[!] 浏览器 PKCE 失败: {exc}") + if not allow_device: + raise + log("[cpa] 继续回退设备码铸造(可能缺 referrer)") + elif http_err is not None and not allow_device: + raise http_err if not allow_device and not sso_token: raise RuntimeError("无 sso cookie,且已禁用设备码回退;无法铸造带 referrer 的 token") + if not allow_device: + # 有 sso 但 browser 也没开/没 page + if http_err is not None: + raise http_err + raise RuntimeError("SSO 铸造失败,且未启用任何回退") # 设备码回退(旧路径,通常无 referrer) from oidc_mint import mint_with_browser diff --git a/grok_register_ttk.py b/grok_register_ttk.py index ba3e984..bed610a 100644 --- a/grok_register_ttk.py +++ b/grok_register_ttk.py @@ -85,6 +85,7 @@ DEFAULT_CONFIG = { # OIDC:优先 SSO→Authorization Code + referrer=grok-build "cpa_prefer_sso_oauth": True, # True=用 sso cookie 走 PKCE(必须带 referrer) "cpa_require_referrer": True, # True=access_token 无 referrer=grok-build 则失败 + "cpa_allow_browser_fallback": True, # True=HTTP 铸造失败时用注册浏览器走 PKCE(绕 Python TLS) "cpa_allow_device_fallback": False, # True=SSO 失败时回退设备码(通常不可用) # OIDC 铸造代理/超时 "mint_proxy": "", # 铸造专用代理;空=复用 proxy diff --git a/oidc_mint/__init__.py b/oidc_mint/__init__.py index ec7c5c2..60e48b0 100644 --- a/oidc_mint/__init__.py +++ b/oidc_mint/__init__.py @@ -16,6 +16,7 @@ from .oauth_code import ( OAuthCodeError, SCOPE as CODE_SCOPE, mint_from_sso, + mint_from_sso_browser, normalize_sso_cookie, sso_to_token, ) @@ -26,6 +27,7 @@ __all__ = [ "mint_with_browser", "shutdown_mint_browsers", "mint_from_sso", + "mint_from_sso_browser", "sso_to_token", "normalize_sso_cookie", "CLIENT_ID", diff --git a/oidc_mint/oauth_code.py b/oidc_mint/oauth_code.py index 2199eb5..7aa83db 100644 --- a/oidc_mint/oauth_code.py +++ b/oidc_mint/oauth_code.py @@ -520,3 +520,404 @@ def mint_from_sso( "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)", + "failed to perform, curl: (35)", + "ssl_error_syscall", + "connection reset", + "connection aborted", + "name resolution", + "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, + }