From bf30d40c8b654968f1a749570d65f22aaadf9612 Mon Sep 17 00:00:00 2001 From: Chaos Date: Tue, 14 Jul 2026 10:23:24 +0800 Subject: [PATCH] Fix browser PKCE stuck on consent without code. Use real Allow clicks (JS click breaks React action), then browser-side Next.js Server Action POST as fallback; harden SSO cookie injection. --- oidc_mint/oauth_code.py | 442 +++++++++++++++++++++++++++++++--------- 1 file changed, 346 insertions(+), 96 deletions(-) diff --git a/oidc_mint/oauth_code.py b/oidc_mint/oauth_code.py index 35183db..76186d2 100644 --- a/oidc_mint/oauth_code.py +++ b/oidc_mint/oauth_code.py @@ -563,21 +563,67 @@ def _ensure_sso_on_page(page: Any, sso: str, log: LogFn) -> None: 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}") + for url in ("https://accounts.x.ai/", "https://auth.x.ai/"): + try: + page.get(url) + time.sleep(0.25) + except Exception as exc: # noqa: BLE001 + log(f"open {url} warn: {exc}") + + items = [] + for domain in (".x.ai", "accounts.x.ai", ".accounts.x.ai", "auth.x.ai", ".auth.x.ai"): + for name in ("sso", "sso-rw"): + items.append( + { + "name": name, + "value": sso, + "domain": domain, + "path": "/", + "secure": True, + "sameSite": "None", + } + ) + + set_ok = False + # 优先 CDP/DrissionPage set.cookies(可写 httpOnly 域 cookie) + for target in (page, getattr(page, "browser", None)): + if target is None: + continue + try: + setter = getattr(target, "set", None) + cookies_fn = getattr(setter, "cookies", None) if setter is not None else None + if not callable(cookies_fn): + continue + try: + cookies_fn(items) + set_ok = True + log(f"browser sso cookie set bulk via {type(target).__name__}") + break + except Exception: + n = 0 + for it in items: + try: + cookies_fn(it) + n += 1 + except Exception: + pass + if n: + set_ok = True + log(f"browser sso cookie set one-by-one={n}") + break + except Exception: + continue + + # document.cookie 兜底(非 httpOnly) set_js = r""" (sso) => { try { const maxAge = 60 * 60 * 24 * 30; - const base = `; path=/; max-age=${maxAge}; SameSite=Lax`; + const base = `; path=/; max-age=${maxAge}; SameSite=None; Secure`; + document.cookie = `sso=${sso}${base}; domain=.x.ai`; + document.cookie = `sso-rw=${sso}${base}; domain=.x.ai`; 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); @@ -586,75 +632,232 @@ def _ensure_sso_on_page(page: Any, sso: str, log: LogFn) -> None: """ try: ok = _page_eval(page, set_js, sso) - log(f"browser sso cookie set: {ok!r}") + log(f"browser sso document.cookie: {ok!r}") + set_ok = set_ok or (ok is True) or (ok == True) or (str(ok).lower() == "true") + except Exception as exc: # noqa: BLE001 + log(f"browser sso document.cookie fail: {exc}") + + if not set_ok: + raise OAuthCodeError("写入 sso cookie 失败") + + +def _page_html(page: Any) -> str: + try: + html = str(getattr(page, "html", "") or "") + if html: + return html 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 + pass + try: + return str(_page_eval(page, "() => document.documentElement.outerHTML") or "") + except Exception: + return "" + + +def _extract_next_action_id(html: str) -> str: + """从 consent 页 HTML 抽 Next-Action / Server Action id。""" + text = html or "" + patterns = ( + r'"next-action"\s*:\s*"([a-f0-9]{20,})"', + r'"actionId"\s*:\s*"([a-f0-9]{20,})"', + r'\$ACTION_ID_([a-f0-9]{20,})', + r'next-action["\']?\s*[:=]\s*["\']([a-f0-9]{20,})', + ) + for pat in patterns: + m = re.search(pat, text, re.I) + if m: + return m.group(1) + return NEXT_ACTION_ID 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)}; -} -""" + """Consent「允许」必须真实点击(JS click 会导致 Invalid action)。""" + labels = ("允许", "Allow", "Authorize", "Approve", "授权") + # 1) DrissionPage 真实点击(与 device consent 同一套经验) try: - ret = _page_eval(page, js) + candidates = [] + for sel in ("tag:button", "css:button", "css:[role='button']", "css:input[type='submit']"): + try: + found = page.eles(sel, timeout=0.3) or [] + except Exception: + found = [] + for el in found: + try: + t = (getattr(el, "text", None) or el.raw_text or "").strip() + except Exception: + t = "" + if not t: + continue + compact = re.sub(r"\s+", "", t) + if compact in labels or t in labels: + candidates.append((0, el, t)) + elif any(x in compact for x in labels) and "全部" not in compact and "All" not in compact: + candidates.append((1, el, t)) + candidates.sort(key=lambda x: x[0]) + for _, el, t in candidates: + try: + el.click() # real click + log(f"browser REAL click allow: {t!r}") + return True + except Exception as exc: # noqa: BLE001 + log(f"real click {t!r} failed: {exc}") + try: + el.click(by_js=True) + log(f"browser JS click allow: {t!r}") + return True + except Exception: + continue + except Exception as exc: # noqa: BLE001 + log(f"ele allow click failed: {exc}") + + # 2) 表单 action=allow 后 submit(device consent 兜底同款) + try: + ret = _page_eval( + page, + r""" +() => { + const labels = new Set(['允许','Allow','Authorize','Approve','授权']); + const f = document.querySelector('form'); + if (f) { + let a = f.querySelector('input[name=action]'); + if (!a) { + a = document.createElement('input'); + a.type = 'hidden'; + a.name = 'action'; + f.appendChild(a); + } + a.value = 'allow'; + const btn = [...f.querySelectorAll('button,[role=button],input[type=submit]')] + .find(b => labels.has(((b.innerText||b.value||'').trim()))); + if (btn) { btn.click(); return {ok:true, via:'form-btn'}; } + f.submit(); + return {ok:true, via:'form-submit'}; + } + // 无 form:真实派发 pointer/mouse 事件 + const nodes = [...document.querySelectorAll('button,[role=button],input[type=submit],a')]; + const target = nodes.find(n => { + const t = ((n.innerText||n.textContent||n.value||'').replace(/\s+/g,'')).trim(); + return labels.has(t) || (t.includes('允许') && !t.includes('全部')); + }); + if (!target) { + return {ok:false, texts: nodes.slice(0,10).map(n => (n.innerText||n.value||'').trim()).filter(Boolean)}; + } + target.scrollIntoView({block:'center'}); + target.focus(); + for (const type of ['pointerdown','mousedown','pointerup','mouseup','click']) { + target.dispatchEvent(new MouseEvent(type, {bubbles:true, cancelable:true, view:window})); + } + return {ok:true, via:'mouse-events', text:(target.innerText||target.value||'').trim()}; +} +""", + ) + if isinstance(ret, dict) and ret.get("ok"): + log(f"browser allow fallback: {ret}") + return True + log(f"browser allow button not found: {ret!r}") 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_consent_server_action( + page: Any, + consent_url: str, + flow: AuthCodeFlow, + log: LogFn, +) -> str: + """在浏览器内 POST Next.js Server Action 批准 consent,直接拿 code。""" + html = _page_html(page) + action_id = _extract_next_action_id(html) + 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=(",", ":")) + # 用 window key 轮询,兼容 DrissionPage 对 Promise 支持不一 + wrap = f""" +(() => {{ + const key = '__cpa_consent_' + Date.now(); + window[key] = null; + fetch({consent_url!r}, {{ + method: 'POST', + headers: {{ + 'Accept': 'text/x-component', + 'Content-Type': 'text/plain;charset=UTF-8', + 'Next-Action': {action_id!r}, + 'Origin': 'https://accounts.x.ai', + 'Referer': {consent_url!r}, + }}, + body: {body!r}, + credentials: 'include', + redirect: 'follow', + }}).then(async (r) => {{ + const text = await r.text(); + window[key] = {{status: r.status, url: r.url, text: text}}; + }}).catch((e) => {{ + window[key] = {{status: 0, url: '', text: String(e)}}; + }}); + return key; +}})() +""" + try: + key = _page_eval(page, wrap) + except Exception as exc: # noqa: BLE001 + raise OAuthCodeError(f"browser consent fetch 启动失败: {exc}") from exc + log(f"browser consent server-action post action_id={action_id[:12]}…") + deadline = time.time() + 20 + ret = None + 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: + ret = None + if ret: + break + time.sleep(0.2) + if not isinstance(ret, dict): + raise OAuthCodeError(f"browser consent 无响应: {ret!r}") + status = int(ret.get("status") or 0) + text = str(ret.get("text") or "") + final = str(ret.get("url") or "") + if status and (status < 200 or status >= 300): + raise OAuthCodeError(f"browser consent HTTP {status}: {_short(text, 240)}") + if "code=" in final: + qs = parse_qs(urlparse(final).query) + code = (qs.get("code") or [""])[0].strip() + if code: + log("browser got code from consent redirect url") + return code + # 响应体 / RSC + try: + code = parse_consent_code(text) + if code: + log("browser got code from consent server-action body") + return code + except OAuthCodeError: + pass + # 有时 code 在 text 的 redirect 串里 + m = re.search(r"[?&]code=([A-Za-z0-9._~\-]+)", text) + if m: + log("browser got code from consent body regex") + return m.group(1) + raise OAuthCodeError(f"browser consent 未返回 code: {_short(text, 240)}") + + def _browser_fetch_token(page: Any, code: str, flow: AuthCodeFlow, log: LogFn) -> TokenResult: """在浏览器上下文用 fetch 换 token,绕开 Python TLS 对 auth.x.ai 的 EOF。""" form = { @@ -833,8 +1036,22 @@ def mint_from_sso_browser( raise OAuthCodeError(f"browser 打开 authorize 失败: {exc}") from exc code = "" - deadline = time.time() + max(20.0, float(timeout_sec)) + deadline = time.time() + max(30.0, float(timeout_sec)) last_url = "" + consent_attempts = 0 + server_action_tried = False + wait_after_click_until = 0.0 + + def _code_from_url(u: str) -> str: + if not u or "code=" not in u: + return "" + if not any(x in u for x in ("127.0.0.1", "localhost", "callback", "redirect")): + # 仍允许任意带 code 的 redirect + if "code=" not in u: + return "" + qs = parse_qs(urlparse(u).query) + return (qs.get("code") or [""])[0].strip() + while time.time() < deadline: try: url = str(getattr(page, "url", "") or "") @@ -845,38 +1062,59 @@ def mint_from_sso_browser( 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 + code = _code_from_url(url) + 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: + # consent 页:真实点击 Allow;点后短暂等待跳转;仍不行再 Server Action POST + if "/oauth2/consent" in url or "/consent" in url: + # 刚点过,先等 redirect + if wait_after_click_until and time.time() < wait_after_click_until: + time.sleep(0.3) + continue + + consent_attempts += 1 + if consent_attempts <= 4: + clicked = _browser_click_allow(page, log) + if clicked: + wait_after_click_until = time.time() + 4.0 + time.sleep(0.6) + # 点后立刻看 url / html try: - code = parse_consent_code(html) - if code: - log("browser got code from consent html") - break - except OAuthCodeError: - pass - except Exception: - pass + url2 = str(getattr(page, "url", "") or "") + except Exception: + url2 = url + code = _code_from_url(url2) + if code: + log("browser got code after allow click") + break + html = _page_html(page) + if html: + try: + code = parse_consent_code(html) + if code: + log("browser got code from consent html after click") + break + except OAuthCodeError: + pass + continue + + # 真实点击无效:浏览器内 Server Action POST(与 HTTP approve 同 payload) + if not server_action_tried: + server_action_tried = True + try: + code = _browser_consent_server_action(page, url, flow, log) + if code: + break + except Exception as exc: # noqa: BLE001 + log(f"browser consent server-action failed: {exc}") + time.sleep(0.5) continue if "sign-in" in url or "sign-up" in url: # cookie 可能没带上,重写一次 + log("browser landed sign-in, re-inject sso") _ensure_sso_on_page(page, sso, log) try: page.get(auth_url) @@ -885,8 +1123,20 @@ def mint_from_sso_browser( time.sleep(0.8) continue - time.sleep(0.5) + # 中间跳转页:稍等 + time.sleep(0.4) + if not code: + # 最后再扫一次页面 HTML + try: + html = _page_html(page) + if html: + try: + code = parse_consent_code(html) + except OAuthCodeError: + code = "" + except Exception: + code = "" if not code: raise OAuthCodeError( f"browser PKCE 超时未拿到 code(last_url={_short(last_url, 160)})"