Open-source Grok free registration CLI, xai_enroller auth pipeline, local auth service, tests and docs.
596 lines
24 KiB
Python
596 lines
24 KiB
Python
import asyncio
|
|
import glob
|
|
import importlib
|
|
import os
|
|
from contextlib import suppress
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import httpx
|
|
|
|
from grok_register.clearance import (
|
|
apply_clearance_to_context,
|
|
cached_user_agent,
|
|
playwright_proxy_settings,
|
|
)
|
|
|
|
from .models import AuthorizationResult, AuthorizationStatus, DeviceFlow, SourceRecord
|
|
|
|
|
|
class HTTPProbeExecutor:
|
|
def __init__(self, client: httpx.AsyncClient, max_body=64 * 1024):
|
|
self.client = client
|
|
self.max_body = max_body
|
|
|
|
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
|
|
parsed = urlparse(flow.verification_url)
|
|
if parsed.scheme != "https" or parsed.hostname != "accounts.x.ai":
|
|
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "invalid_url")
|
|
response = await self.client.get(
|
|
flow.verification_url,
|
|
headers={"Cookie": f"sso={source.sso_token}"},
|
|
follow_redirects=False,
|
|
)
|
|
body = await response.aread()
|
|
if len(body) > self.max_body:
|
|
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "response_too_large")
|
|
text = body.decode("utf-8", errors="replace").lower()
|
|
if response.status_code == 403 and any(
|
|
marker in text for marker in ("challenge", "cf-chl", "captcha")
|
|
):
|
|
return AuthorizationResult(AuthorizationStatus.NEEDS_BROWSER, "challenge")
|
|
if response.status_code in {301, 302, 303, 307, 308}:
|
|
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "redirect")
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION, "http_probe_inconclusive"
|
|
)
|
|
|
|
|
|
class PlaywrightExecutor:
|
|
ALLOWED_CONTROLS = frozenset({"Authorize", "Allow", "Continue", "Confirm"})
|
|
ATTEMPT_TIMEOUT_SECONDS = 55.0
|
|
CLOSE_TIMEOUT_SECONDS = 5.0
|
|
# Keep confirm loops tight: most successful device verifies finish under ~15s.
|
|
LOOP_DEADLINE_SECONDS = 28.0
|
|
COOKIE_WAIT_MS = 250
|
|
CONSENT_WAIT_MS = 350
|
|
CODE_WAIT_MS = 300
|
|
CHALLENGE_WAIT_MS = 700
|
|
POLL_WAIT_MS = 250
|
|
# Drop heavy assets that never participate in device verification.
|
|
BLOCKED_RESOURCE_TYPES = frozenset({"image", "media", "font", "stylesheet"})
|
|
BLOCKED_URL_MARKERS = (
|
|
"google-analytics",
|
|
"googletagmanager",
|
|
"doubleclick",
|
|
"facebook.net",
|
|
"hotjar",
|
|
"segment.io",
|
|
)
|
|
|
|
def __init__(self, concurrency=1, playwright_factory=None, executable_path=None):
|
|
self.concurrency = concurrency
|
|
self.playwright_factory = playwright_factory
|
|
self.executable_path = executable_path or self._find_executable_path()
|
|
self._playwright = None
|
|
self._browser = None
|
|
self._semaphore = asyncio.Semaphore(concurrency)
|
|
self._lifecycle_lock = asyncio.Lock()
|
|
|
|
@staticmethod
|
|
def _find_executable_path():
|
|
configured = os.environ.get("XAI_ENROLLER_BROWSER_EXECUTABLE")
|
|
if configured:
|
|
return configured
|
|
candidates = glob.glob(os.path.expanduser("~/.cloakbrowser/chromium-*/chrome"))
|
|
candidates.extend(
|
|
glob.glob(
|
|
os.path.expanduser(
|
|
"~/.cloakbrowser/chromium-*/Chromium.app/Contents/MacOS/Chromium"
|
|
)
|
|
)
|
|
)
|
|
return sorted(candidates)[-1] if candidates else None
|
|
|
|
async def start(self):
|
|
async with self._lifecycle_lock:
|
|
if self._browser is not None:
|
|
is_connected = getattr(self._browser, "is_connected", None)
|
|
if not callable(is_connected) or is_connected():
|
|
return
|
|
await self._close_unlocked()
|
|
factory = self.playwright_factory
|
|
if factory is None:
|
|
module = importlib.import_module("playwright.async_api")
|
|
factory = module.async_playwright
|
|
playwright = await factory().start()
|
|
options = {"headless": True}
|
|
if self.executable_path:
|
|
options["executable_path"] = self.executable_path
|
|
# REGISTER_PROXY / HTTP_PROXY / HTTPS_PROXY → this process only
|
|
proxy = playwright_proxy_settings()
|
|
if proxy:
|
|
options["proxy"] = proxy
|
|
try:
|
|
browser = await playwright.chromium.launch(**options)
|
|
except BaseException:
|
|
await self._close_transport_resource(playwright.stop())
|
|
raise
|
|
self._playwright = playwright
|
|
self._browser = browser
|
|
|
|
async def close(self):
|
|
async with self._lifecycle_lock:
|
|
await self._close_unlocked()
|
|
|
|
async def _close_unlocked(self):
|
|
browser = self._browser
|
|
playwright = self._playwright
|
|
self._browser = None
|
|
self._playwright = None
|
|
try:
|
|
if browser is not None:
|
|
await self._close_transport_resource(browser.close())
|
|
finally:
|
|
if playwright is not None:
|
|
await self._close_transport_resource(playwright.stop())
|
|
|
|
@staticmethod
|
|
def _consume_task_result(task):
|
|
with suppress(BaseException):
|
|
task.result()
|
|
|
|
@classmethod
|
|
async def _close_transport_resource(cls, awaitable):
|
|
task = asyncio.create_task(awaitable)
|
|
try:
|
|
done, _pending = await asyncio.wait(
|
|
{task}, timeout=cls.CLOSE_TIMEOUT_SECONDS
|
|
)
|
|
except asyncio.CancelledError:
|
|
task.cancel()
|
|
task.add_done_callback(cls._consume_task_result)
|
|
raise
|
|
if task in done:
|
|
cls._consume_task_result(task)
|
|
return True
|
|
task.cancel()
|
|
task.add_done_callback(cls._consume_task_result)
|
|
return False
|
|
|
|
async def _recycle_browser(self, browser):
|
|
async with self._lifecycle_lock:
|
|
if self._browser is browser:
|
|
await self._close_unlocked()
|
|
|
|
async def __aenter__(self):
|
|
await self.start()
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
await self.close()
|
|
|
|
@classmethod
|
|
async def _close_attempt_resources(cls, page, context):
|
|
async def close_session():
|
|
closers = []
|
|
if page is not None:
|
|
closers.append(page.close())
|
|
if context is not None:
|
|
closers.append(context.close())
|
|
if closers:
|
|
await asyncio.gather(*closers, return_exceptions=True)
|
|
|
|
cleanup = asyncio.create_task(close_session())
|
|
cancelled = False
|
|
deadline = asyncio.get_running_loop().time() + cls.CLOSE_TIMEOUT_SECONDS
|
|
while not cleanup.done():
|
|
remaining = deadline - asyncio.get_running_loop().time()
|
|
if remaining <= 0:
|
|
break
|
|
try:
|
|
await asyncio.wait({cleanup}, timeout=remaining)
|
|
except asyncio.CancelledError:
|
|
cancelled = True
|
|
if cleanup.done():
|
|
cls._consume_task_result(cleanup)
|
|
else:
|
|
# asyncio.wait_for() is not a hard deadline: it cancels the child and
|
|
# then waits for cancellation to finish, which a wedged Playwright
|
|
# transport may never do. Abandon the cleanup task after one real
|
|
# wall-clock deadline and consume its eventual result asynchronously.
|
|
cleanup.cancel()
|
|
cleanup.add_done_callback(cls._consume_task_result)
|
|
if cancelled:
|
|
raise asyncio.CancelledError
|
|
return cleanup.done()
|
|
|
|
@staticmethod
|
|
async def _click_visible_exact(page, names):
|
|
for name in names:
|
|
try:
|
|
button = page.get_by_role("button", name=name, exact=True)
|
|
except TypeError:
|
|
button = page.get_by_role("button", name=name)
|
|
for index in range(await button.count()):
|
|
candidate = button.nth(index) if hasattr(button, "nth") else button.first
|
|
is_visible = getattr(candidate, "is_visible", None)
|
|
if is_visible is None or await is_visible():
|
|
await candidate.click()
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
async def _click_visible_turnstile(page):
|
|
"""Click a visible Turnstile widget using the registration solver motion."""
|
|
for selector in (
|
|
".cf-turnstile",
|
|
"iframe[src*='challenges.cloudflare.com']",
|
|
"iframe[src*='turnstile']",
|
|
):
|
|
locator = page.locator(selector).first
|
|
if not await locator.count() or not await locator.is_visible():
|
|
continue
|
|
box = await locator.bounding_box()
|
|
if not box:
|
|
continue
|
|
x = box["x"] + box["width"] / 2
|
|
y = box["y"] + box["height"] / 2
|
|
await page.mouse.move(max(0, x - 25), max(0, y - 8))
|
|
await page.mouse.move(x, y, steps=8)
|
|
await page.mouse.down()
|
|
await asyncio.sleep(0.05)
|
|
await page.mouse.up()
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _expanded_cookies(source):
|
|
allowed = {
|
|
"name",
|
|
"value",
|
|
"url",
|
|
"domain",
|
|
"path",
|
|
"expires",
|
|
"httpOnly",
|
|
"secure",
|
|
"sameSite",
|
|
}
|
|
cookies = []
|
|
for source_cookie in source.cookies:
|
|
cookie = {
|
|
key: value for key, value in source_cookie.items() if key in allowed
|
|
}
|
|
if cookie.get("domain"):
|
|
cookie.pop("url", None)
|
|
elif cookie.get("url"):
|
|
cookie.pop("domain", None)
|
|
cookie.pop("path", None)
|
|
cookies.append(cookie)
|
|
if not cookies:
|
|
cookies = [
|
|
{
|
|
"name": "sso",
|
|
"value": source.sso_token,
|
|
"domain": "accounts.x.ai",
|
|
"path": "/",
|
|
"secure": True,
|
|
"httpOnly": True,
|
|
}
|
|
]
|
|
seen = {
|
|
(cookie.get("name"), cookie.get("domain"), cookie.get("path", "/"))
|
|
for cookie in cookies
|
|
}
|
|
for cookie in list(cookies) if source.cookies else ():
|
|
name = cookie.get("name", "")
|
|
if not name.startswith("sso"):
|
|
continue
|
|
key = (name, ".x.ai", cookie.get("path", "/"))
|
|
if key in seen:
|
|
continue
|
|
clone = dict(cookie)
|
|
clone.pop("url", None)
|
|
clone["domain"] = ".x.ai"
|
|
clone.setdefault("path", "/")
|
|
cookies.append(clone)
|
|
seen.add(key)
|
|
return cookies
|
|
|
|
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
|
|
await self.start()
|
|
async with self._semaphore:
|
|
browser = self._browser
|
|
attempt = asyncio.create_task(
|
|
self._confirm_once(browser, source, flow)
|
|
)
|
|
try:
|
|
done, _pending = await asyncio.wait(
|
|
{attempt}, timeout=self.ATTEMPT_TIMEOUT_SECONDS
|
|
)
|
|
except asyncio.CancelledError:
|
|
attempt.cancel()
|
|
attempt.add_done_callback(self._consume_task_result)
|
|
raise
|
|
if attempt in done:
|
|
return attempt.result()
|
|
|
|
# A Playwright transport can swallow cancellation while one of its
|
|
# RPCs is wedged. Do not let asyncio.wait_for() extend the nominal
|
|
# timeout indefinitely: detach the attempt and replace the complete
|
|
# browser transport before admitting the next account.
|
|
attempt.cancel()
|
|
attempt.add_done_callback(self._consume_task_result)
|
|
await self._recycle_browser(browser)
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
|
|
)
|
|
|
|
async def _install_request_filters(self, page):
|
|
"""Skip non-essential resources so device verification spends less wall time."""
|
|
|
|
async def route_handler(route):
|
|
request = route.request
|
|
resource_type = getattr(request, "resource_type", "") or ""
|
|
url = getattr(request, "url", "") or ""
|
|
if resource_type in self.BLOCKED_RESOURCE_TYPES or any(
|
|
marker in url for marker in self.BLOCKED_URL_MARKERS
|
|
):
|
|
await route.abort()
|
|
return
|
|
await route.continue_()
|
|
|
|
with suppress(Exception):
|
|
await page.route("**/*", route_handler)
|
|
|
|
async def _confirm_once(self, browser, source: SourceRecord, flow: DeviceFlow):
|
|
context = None
|
|
page = None
|
|
code_submitted = False
|
|
consent_submitted = False
|
|
challenge_clicks = 0
|
|
try:
|
|
context_kwargs = {}
|
|
ua = cached_user_agent()
|
|
if ua:
|
|
context_kwargs["user_agent"] = ua
|
|
context = await browser.new_context(**context_kwargs)
|
|
# CF cookies from external FlareSolverr prewarm (host-level)
|
|
await apply_clearance_to_context(context)
|
|
page = await context.new_page()
|
|
await self._install_request_filters(page)
|
|
await context.add_cookies(self._expanded_cookies(source))
|
|
await page.goto(flow.verification_url, wait_until="domcontentloaded")
|
|
deadline = (
|
|
asyncio.get_running_loop().time() + self.LOOP_DEADLINE_SECONDS
|
|
)
|
|
unknown_since = None
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
parsed = urlparse(page.url)
|
|
if parsed.scheme != "https" or not parsed.hostname or not (
|
|
parsed.hostname == "x.ai"
|
|
or parsed.hostname.endswith(".x.ai")
|
|
):
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION, "unsafe_page"
|
|
)
|
|
query_error = parse_qs(parsed.query).get("error", [None])[0]
|
|
text = await page.locator("body").inner_text()
|
|
text_lower = text.lower()
|
|
page_title = getattr(page, "title", None)
|
|
title_lower = (
|
|
(await page_title()).lower() if page_title is not None else ""
|
|
)
|
|
|
|
if query_error == "rate_limited":
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
|
|
)
|
|
if query_error:
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION, "device_verify_failed"
|
|
)
|
|
if any(
|
|
marker in text_lower
|
|
for marker in ("rate limit", "too many requests", "请求过于频繁")
|
|
):
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
|
|
)
|
|
if (
|
|
"attention required" in title_lower
|
|
and "cloudflare" in title_lower
|
|
) or any(
|
|
marker in text_lower
|
|
for marker in (
|
|
"sorry, you have been blocked",
|
|
"unable to access x.ai",
|
|
"cloudflare ray id",
|
|
)
|
|
):
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION,
|
|
"challenge_required",
|
|
)
|
|
if "/oauth2/device/done" in parsed.path or any(
|
|
marker in text_lower
|
|
for marker in ("device authorized", "设备已授权")
|
|
):
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.AUTHORIZED, "confirmed"
|
|
)
|
|
|
|
cookie_clicked = await self._click_visible_exact(
|
|
page,
|
|
(
|
|
"全部拒绝",
|
|
"拒绝全部",
|
|
"Reject all",
|
|
"Reject All",
|
|
),
|
|
)
|
|
if cookie_clicked:
|
|
unknown_since = None
|
|
await page.wait_for_timeout(self.COOKIE_WAIT_MS)
|
|
continue
|
|
|
|
if "/oauth2/device/consent" in parsed.path or any(
|
|
marker in text_lower
|
|
for marker in ("authorize grok build", "授权 grok build")
|
|
):
|
|
if not consent_submitted:
|
|
allowed = await self._click_visible_exact(
|
|
page, ("允许", "Allow", "Authorize", "Approve")
|
|
)
|
|
if allowed:
|
|
consent_submitted = True
|
|
unknown_since = None
|
|
await page.wait_for_timeout(self.CONSENT_WAIT_MS)
|
|
continue
|
|
else:
|
|
await page.wait_for_timeout(self.POLL_WAIT_MS)
|
|
continue
|
|
|
|
code_input = page.locator('input[name="user_code"]')
|
|
try:
|
|
code_input_count = await code_input.count()
|
|
except AttributeError:
|
|
if await self._click_visible_exact(
|
|
page, self.ALLOWED_CONTROLS
|
|
):
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.AUTHORIZED,
|
|
"confirmed",
|
|
)
|
|
code_input_count = 0
|
|
if code_input_count and await code_input.first.is_visible():
|
|
if code_submitted:
|
|
await page.wait_for_timeout(self.POLL_WAIT_MS)
|
|
continue
|
|
unknown_since = None
|
|
current = await code_input.first.input_value()
|
|
# fill() is far cheaper than press_sequentially and the
|
|
# device-code form does not require keystroke events.
|
|
if flow.user_code.replace("-", "") not in current.replace(
|
|
"-", ""
|
|
):
|
|
await code_input.first.fill(flow.user_code)
|
|
submit = page.locator(
|
|
'button[type="submit"], input[type="submit"]'
|
|
)
|
|
if not await submit.count():
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION,
|
|
"submit_missing",
|
|
)
|
|
predicate = lambda response: (
|
|
urlparse(response.url).hostname == "auth.x.ai"
|
|
and urlparse(response.url).path == "/oauth2/device/verify"
|
|
)
|
|
async with page.expect_response(
|
|
predicate, timeout=12000
|
|
) as response_info:
|
|
code_submitted = True
|
|
await submit.first.click()
|
|
response = await response_info.value
|
|
location = urlparse(response.headers.get("location", ""))
|
|
error = parse_qs(location.query).get("error", [None])[0]
|
|
if error == "rate_limited":
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION,
|
|
"rate_limited",
|
|
)
|
|
if error:
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION,
|
|
"device_verify_failed",
|
|
)
|
|
await page.wait_for_timeout(self.CODE_WAIT_MS)
|
|
continue
|
|
|
|
if any(
|
|
marker in text_lower
|
|
for marker in (
|
|
"continue with email",
|
|
"sign in with email",
|
|
"使用邮箱登录",
|
|
)
|
|
) or await page.locator('input[type="password"]').count():
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION,
|
|
"login_required",
|
|
)
|
|
|
|
if any(
|
|
marker in text_lower
|
|
for marker in (
|
|
"captcha",
|
|
"verify you are human",
|
|
"security challenge",
|
|
"turnstile",
|
|
"人机验证",
|
|
"安全验证",
|
|
)
|
|
):
|
|
if (
|
|
challenge_clicks < 3
|
|
and await self._click_visible_turnstile(page)
|
|
):
|
|
challenge_clicks += 1
|
|
unknown_since = None
|
|
await page.wait_for_timeout(self.CHALLENGE_WAIT_MS)
|
|
continue
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION,
|
|
"challenge_required",
|
|
)
|
|
|
|
if any(
|
|
marker in text_lower
|
|
for marker in (
|
|
"multi-factor",
|
|
"two-factor",
|
|
"authentication code",
|
|
"verification code",
|
|
"双重验证",
|
|
"验证码",
|
|
)
|
|
):
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION,
|
|
"mfa_required",
|
|
)
|
|
|
|
if await self._click_visible_exact(page, self.ALLOWED_CONTROLS):
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.AUTHORIZED,
|
|
"confirmed",
|
|
)
|
|
|
|
now = asyncio.get_running_loop().time()
|
|
if unknown_since is None:
|
|
unknown_since = now
|
|
elif now - unknown_since >= 2.5:
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION, "unknown_page"
|
|
)
|
|
|
|
await page.wait_for_timeout(self.POLL_WAIT_MS)
|
|
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as error:
|
|
is_timeout = (
|
|
error.__class__.__name__ == "TimeoutError"
|
|
and error.__class__.__module__.startswith("playwright")
|
|
)
|
|
return AuthorizationResult(
|
|
AuthorizationStatus.NEEDS_INTERACTION,
|
|
"confirmation_timeout" if is_timeout else "browser_error",
|
|
)
|
|
finally:
|
|
await self._close_attempt_resources(page, context)
|