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.
This commit is contained in:
1 parent
2e833f2af3
commit
8b399277e7
4 files changed
+443
-9
No files matched your search
+39
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user