补回 mint scope 中的 grok-cli:access,铸造后校验 JWT scope, 避免 cli-chat-proxy 返回 grok-cli-token-auth-required。 新增 remint_cli_scope.py,基于已有 sso 覆盖写回缺 scope 的 CPA auth; 同步更新 cpa/schema 文档说明 referrer 与 scope 双重要求。
179 lines
5.7 KiB
Python
179 lines
5.7 KiB
Python
"""CPA (CLIProxyAPI) xAI auth JSON 组装,对齐 router-for-me/CLIProxyAPI
|
||
internal/auth/xai/token.go 的 TokenStorage 结构。
|
||
|
||
生成的 xai-<email>.json 可被 CLIProxyAPI 直接加载;CLIProxyAPI 请求 grok 时会
|
||
自带 x-grok-client-version 头(xai_executor.go 硬编码 0.2.93),免费 Build 账号
|
||
不会 426。
|
||
|
||
2026-07:AccessToken 必须同时满足:
|
||
1) claim referrer=grok-build
|
||
2) scope 含 grok-cli:access
|
||
否则 cli-chat-proxy 会返回:
|
||
WKE=unauthorized:grok-cli-token-auth-required
|
||
铸造请走 oidc_mint.oauth_code(SSO→Authorization Code + PKCE)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import re
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
# 对齐 CLIProxyAPI internal/auth/xai/types.go
|
||
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||
ISSUER = "https://auth.x.ai"
|
||
TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token"
|
||
REDIRECT_URI = "http://127.0.0.1:56121/callback"
|
||
# 免费 Grok 4.5 (Build) 走 cli-chat-proxy;付费 API 用 https://api.x.ai/v1
|
||
CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1"
|
||
API_BASE_URL = "https://api.x.ai/v1"
|
||
DEFAULT_BASE_URL = CLI_BASE_URL
|
||
|
||
GROK_VERSION = "0.2.93"
|
||
GROK_TOKEN_UA = (
|
||
f"grok-pager/{GROK_VERSION} grok-shell/{GROK_VERSION} (linux; x86_64)"
|
||
)
|
||
DEFAULT_HEADERS = {
|
||
"x-grok-client-version": GROK_VERSION,
|
||
"x-xai-token-auth": "xai-grok-cli",
|
||
"x-authenticateresponse": "authenticate-response",
|
||
"x-grok-client-identifier": "grok-pager",
|
||
"User-Agent": GROK_TOKEN_UA,
|
||
}
|
||
|
||
|
||
def _sanitize_file_segment(value: str) -> str:
|
||
"""对齐 CPA CredentialFileName 的清洗规则。"""
|
||
value = (value or "").strip()
|
||
if not value:
|
||
return ""
|
||
out: list[str] = []
|
||
for ch in value:
|
||
if (
|
||
("a" <= ch <= "z")
|
||
or ("A" <= ch <= "Z")
|
||
or ("0" <= ch <= "9")
|
||
or ch in {"@", ".", "_", "-"}
|
||
):
|
||
out.append(ch)
|
||
else:
|
||
out.append("-")
|
||
return "".join(out).strip("-")
|
||
|
||
|
||
def credential_file_name(email: str = "", sub: str = "") -> str:
|
||
"""返回 CPA 认证文件名:xai-<email>.json。"""
|
||
email_s = _sanitize_file_segment(email)
|
||
if email_s:
|
||
return f"xai-{email_s}.json"
|
||
sub_s = _sanitize_file_segment(sub)
|
||
if sub_s:
|
||
return f"xai-{sub_s}.json"
|
||
ts = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
|
||
return f"xai-{ts}.json"
|
||
|
||
|
||
def _jwt_payload(token: str) -> dict[str, Any]:
|
||
parts = (token or "").split(".")
|
||
if len(parts) < 2:
|
||
raise ValueError("not a JWT")
|
||
seg = parts[1]
|
||
seg += "=" * (-len(seg) % 4)
|
||
return json.loads(base64.urlsafe_b64decode(seg))
|
||
|
||
|
||
def expired_from_access_token(access_token: str) -> tuple[str, int, str, str]:
|
||
"""从 access_token 解析 (expired_rfc3339, expires_in, sub, referrer)。"""
|
||
pl = _jwt_payload(access_token)
|
||
exp = int(pl["exp"])
|
||
iat = int(pl["iat"]) if pl.get("iat") is not None else exp - 21600
|
||
expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
sub = str(pl.get("sub") or pl.get("principal_id") or "").strip()
|
||
referrer = str(pl.get("referrer") or "").strip()
|
||
return expired, max(exp - iat, 0), sub, referrer
|
||
|
||
|
||
def build_cpa_xai_auth(
|
||
*,
|
||
email: str,
|
||
access_token: str,
|
||
refresh_token: str,
|
||
sub: str | None = None,
|
||
id_token: str | None = None,
|
||
expires_in: int | None = None,
|
||
expired: str | None = None,
|
||
last_refresh: str | None = None,
|
||
base_url: str = DEFAULT_BASE_URL,
|
||
token_endpoint: str = TOKEN_ENDPOINT,
|
||
redirect_uri: str = REDIRECT_URI,
|
||
sso: str | None = None,
|
||
headers: dict[str, str] | None = None,
|
||
disabled: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""组装一个 CLIProxyAPI 可导入的 xai auth 对象(TokenStorage 字段)。"""
|
||
access_token = (access_token or "").strip()
|
||
refresh_token = (refresh_token or "").strip()
|
||
if not access_token:
|
||
raise ValueError("access_token is required")
|
||
if not refresh_token:
|
||
raise ValueError("refresh_token is required (CPA 无法在缺 refresh_token 时续期)")
|
||
|
||
referrer = ""
|
||
try:
|
||
exp_s, exp_in, sub_jwt, referrer = expired_from_access_token(access_token)
|
||
except Exception:
|
||
exp_s, exp_in, sub_jwt, referrer = "", 21600, "", ""
|
||
|
||
if not expired:
|
||
expired = exp_s
|
||
if expires_in is None:
|
||
expires_in = exp_in or 21600
|
||
if not sub:
|
||
sub = sub_jwt
|
||
if not last_refresh:
|
||
last_refresh = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
# 从 id_token 补 email
|
||
email = (email or "").strip()
|
||
if not email and id_token:
|
||
try:
|
||
idp = _jwt_payload(id_token)
|
||
email = str(idp.get("email") or "").strip()
|
||
except Exception:
|
||
pass
|
||
|
||
base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
||
if not re.search(r"/v1$", base_url) and base_url.endswith("cli-chat-proxy.grok.com"):
|
||
base_url = base_url + "/v1"
|
||
|
||
hdrs = dict(DEFAULT_HEADERS)
|
||
if headers:
|
||
hdrs.update({str(k): str(v) for k, v in headers.items() if k and v is not None})
|
||
|
||
payload: dict[str, Any] = {
|
||
"type": "xai",
|
||
"auth_kind": "oauth",
|
||
"access_token": access_token,
|
||
"refresh_token": refresh_token,
|
||
"token_type": "Bearer",
|
||
"expires_in": int(expires_in),
|
||
"expired": expired,
|
||
"last_refresh": last_refresh,
|
||
"email": email,
|
||
"sub": (sub or "").strip(),
|
||
"base_url": base_url,
|
||
"token_endpoint": token_endpoint,
|
||
"redirect_uri": redirect_uri,
|
||
"disabled": bool(disabled),
|
||
"headers": hdrs,
|
||
}
|
||
if id_token:
|
||
payload["id_token"] = id_token.strip()
|
||
if sso:
|
||
payload["sso"] = str(sso).strip()
|
||
if referrer:
|
||
payload["referrer"] = referrer
|
||
return payload
|