feat: 支持固定类型 Provider OAuth 授权

- 新增 provider_type 字段区分自定义/预置 Provider 类型(claude_code/codex/gemini_cli/antigravity)
- 实现完整 OAuth 2.0 授权流程:start(生成授权 URL + PKCE)、complete(换取 token)、refresh
- 前端 KeyFormDialog 添加 OAuth 授权 UI,支持开始授权、粘贴回调 URL、完成授权、强制刷新
- 请求时自动检测 token 过期并刷新(120s 预留窗口 + Redis 分布式锁防并发)
- 固定类型 Provider 自动创建预置端点并锁定 base_url/custom_path
- 数据库迁移:添加 providers.provider_type,扩展 api_key 列为 TEXT
- 可选依赖 tls-client 用于 Claude token 请求的 TLS 指纹伪装
This commit is contained in:
AAEE86
2026-02-04 10:24:25 +08:00
parent f6dac1c38a
commit e4fdc65e52
25 changed files with 1818 additions and 36 deletions

View File

@@ -0,0 +1,255 @@
from __future__ import annotations
import asyncio
from typing import Any
import httpx
import jwt
from src.clients.http_client import HTTPClientPool, build_proxy_url
from src.core.logger import logger
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
def _coerce_proxy_url(proxy_config: dict[str, Any] | None) -> str | None:
if not proxy_config:
return None
try:
if not proxy_config.get("enabled", True):
return None
return build_proxy_url(proxy_config)
except Exception:
return None
async def _httpx_post(
url: str,
*,
headers: dict[str, str] | None,
data: Any,
json_body: Any,
proxy_config: dict[str, Any] | None,
timeout_seconds: float,
) -> httpx.Response:
client = await HTTPClientPool.get_proxy_client(proxy_config)
return await client.post(
url,
headers=headers,
data=data,
json=json_body,
timeout=timeout_seconds,
)
def _tls_client_post_sync(
url: str,
*,
headers: dict[str, str] | None,
data: Any,
json_body: Any,
proxy_url: str | None,
timeout_seconds: float,
) -> tuple[int, dict[str, str], str]:
# tls-client is optional at runtime; import only when needed.
import tls_client # type: ignore
session = tls_client.Session(
client_identifier="firefox_120",
random_tls_extension_order=True,
)
if proxy_url:
session.proxies = {"http": proxy_url, "https": proxy_url}
# tls-client uses a requests-like API.
resp = session.post(
url,
headers=headers or {},
data=data,
json=json_body,
timeout_seconds=timeout_seconds,
)
# Normalize output
status_code = int(getattr(resp, "status_code", 0))
text = str(getattr(resp, "text", ""))
resp_headers = dict(getattr(resp, "headers", {}) or {})
return status_code, resp_headers, text
async def post_oauth_token(
*,
provider_type: str,
token_url: str,
headers: dict[str, str] | None,
data: Any = None,
json_body: Any = None,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 30.0,
) -> httpx.Response:
"""POST to token endpoint.
Claude Code + Anthropic token URL will try tls-client (Firefox TLS fingerprint) first.
If tls-client is unavailable or fails, fall back to httpx.
IMPORTANT: Never log secrets (tokens, secrets). This function only logs generic errors.
"""
if provider_type == "claude_code" and token_url == _ANTHROPIC_TOKEN_URL:
proxy_url = _coerce_proxy_url(proxy_config)
try:
status_code, resp_headers, text = await asyncio.to_thread(
_tls_client_post_sync,
token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_url=proxy_url,
timeout_seconds=timeout_seconds,
)
return httpx.Response(
status_code=status_code,
headers=resp_headers,
content=text.encode("utf-8", errors="replace"),
request=httpx.Request("POST", token_url),
)
except Exception as e:
logger.warning(
"Claude OAuth token request via tls-client failed; fallback to httpx. err={!r}",
e,
)
return await _httpx_post(
token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
def parse_codex_id_token(id_token: str | None) -> tuple[str | None, str | None]:
"""Parse Codex id_token WITHOUT signature verification.
Extract:
- email: claim `email`
- account_id: claim `https://api.openai.com/auth`.`chatgpt_account_id`
Return (email, account_id). On any failure returns (None, None).
"""
if not id_token:
return (None, None)
try:
claims = jwt.decode(
id_token,
options={
"verify_signature": False,
"verify_aud": False,
},
)
email = claims.get("email")
auth_info = claims.get("https://api.openai.com/auth") or {}
account_id = None
if isinstance(auth_info, dict):
account_id = auth_info.get("chatgpt_account_id")
return (
str(email) if isinstance(email, str) and email else None,
str(account_id) if isinstance(account_id, str) and account_id else None,
)
except Exception:
return (None, None)
async def fetch_google_email(
access_token: str,
*,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 10.0,
) -> str | None:
if not access_token:
return None
client = await HTTPClientPool.get_proxy_client(proxy_config)
try:
resp = await client.get(
_GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
timeout=timeout_seconds,
)
if resp.status_code < 200 or resp.status_code >= 300:
return None
data = resp.json()
email = data.get("email")
if isinstance(email, str) and email:
return email
return None
except Exception:
return None
def extract_claude_email_from_token_response(token: dict[str, Any]) -> str | None:
# CLIProxyAPI expects: { account: { email_address: ... } }
try:
account = token.get("account")
if isinstance(account, dict):
email = account.get("email_address")
if isinstance(email, str) and email:
return email
except Exception:
pass
return None
async def enrich_auth_config(
*,
provider_type: str,
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Enrich auth_config with non-secret metadata (email/account_id).
- Claude Code: email from token response (if present)
- Codex: parse id_token -> email/account_id
- Gemini/Antigravity: call Google userinfo -> email
id_token is not persisted.
"""
# Claude
if provider_type == "claude_code":
email = extract_claude_email_from_token_response(token_response)
if email:
auth_config["email"] = email
return auth_config
# Codex
if provider_type == "codex":
id_token = token_response.get("id_token")
email, account_id = parse_codex_id_token(str(id_token) if id_token else None)
if email:
auth_config["email"] = email
if account_id:
auth_config["account_id"] = account_id
return auth_config
# Gemini family (gemini_cli / antigravity)
if provider_type in {"gemini_cli", "antigravity"}:
# Only fetch if missing to reduce overhead
if not auth_config.get("email"):
email = await fetch_google_email(
access_token,
proxy_config=proxy_config,
timeout_seconds=10.0,
)
if email:
auth_config["email"] = email
return auth_config
return auth_config

View File

@@ -0,0 +1,5 @@
"""固定 Provider 模板与 OAuth 常量。
注意:此包会包含从 CLIProxyAPI 复制的固定端点与 OAuth 客户端常量。
敏感信息(如 client_secret / refresh_token / access_token不得出现在日志或 API 响应中。
"""

View File

@@ -0,0 +1,130 @@
"""固定 Provider 的模板定义。
该文件用于集中管理:
- 固定 Provider 的上游 API base_url / 固定路径策略(通常通过 EndpointDefinition.default_path + 锁定 custom_path=None
- 固定 Provider 的 OAuth2 客户端常量authorize/token/client_id/client_secret/scopes/redirect_uri
注意:该文件会直接包含从参考项目 CLIProxyAPI 复制的 OAuth client_id/client_secret敏感
务必确保:
- 不把 client_secret/refresh_token/access_token 输出到日志
- 不通过 API 响应把敏感信息返回给前端
"""
from __future__ import annotations
from dataclasses import dataclass
from src.core.provider_templates.types import ProviderType
@dataclass(frozen=True, slots=True)
class FixedProviderOAuth:
authorize_url: str
token_url: str
client_id: str
client_secret: str
scopes: list[str]
redirect_uri: str
use_pkce: bool
@dataclass(frozen=True, slots=True)
class FixedProviderTemplate:
provider_type: ProviderType
display_name: str
# 上游 APIProviderEndpoint.base_url 应锁定为该值custom_path 通常保持 None 使用 default_path
api_base_url: str
# 该 Provider 默认创建哪些 endpoint signature
endpoint_signatures: list[str]
# OAuth2 配置(用于生成授权 URL / 换 token / refresh
oauth: FixedProviderOAuth
# ------------------------------
# Fixed templates
# ------------------------------
# 说明client_id/client_secret 从 CLIProxyAPI/internal/auth 复制。
# 该文件包含敏感信息,务必避免输出到日志或 API 响应。
FIXED_PROVIDERS: dict[ProviderType, FixedProviderTemplate] = {
ProviderType.CLAUDE_CODE: FixedProviderTemplate(
provider_type=ProviderType.CLAUDE_CODE,
display_name="ClaudeCode",
api_base_url="https://api.anthropic.com",
endpoint_signatures=["claude:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://claude.ai/oauth/authorize",
token_url="https://console.anthropic.com/v1/oauth/token",
client_id="9d1c250a-e61b-44d9-88ed-5944d1962f5e",
client_secret="",
scopes=["org:create_api_key", "user:profile", "user:inference"],
redirect_uri="http://localhost:54545/callback",
use_pkce=True,
),
),
ProviderType.CODEX: FixedProviderTemplate(
provider_type=ProviderType.CODEX,
display_name="Codex",
api_base_url="https://chatgpt.com/backend-api/codex",
endpoint_signatures=["openai:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://auth.openai.com/oauth/authorize",
token_url="https://auth.openai.com/oauth/token",
client_id="app_EMoamEEZ73f0CkXaXp7hrann",
client_secret="",
scopes=["openid", "email", "profile", "offline_access"],
redirect_uri="http://localhost:1455/auth/callback",
use_pkce=True,
),
),
ProviderType.GEMINI_CLI: FixedProviderTemplate(
provider_type=ProviderType.GEMINI_CLI,
display_name="GeminiCli",
api_base_url="https://cloudcode-pa.googleapis.com",
endpoint_signatures=["gemini:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
client_id="681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
client_secret="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",
scopes=[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
redirect_uri="http://localhost:8085/oauth2callback",
use_pkce=False,
),
),
ProviderType.ANTIGRAVITY: FixedProviderTemplate(
provider_type=ProviderType.ANTIGRAVITY,
display_name="Antigravity",
api_base_url="https://cloudcode-pa.googleapis.com",
endpoint_signatures=["gemini:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
client_id="1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
client_secret="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",
scopes=[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
redirect_uri="http://localhost:51121/oauth2callback",
use_pkce=False,
),
),
}
__all__ = [
"FixedProviderOAuth",
"FixedProviderTemplate",
"FIXED_PROVIDERS",
]

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from enum import Enum
class ProviderType(str, Enum):
CUSTOM = "custom"
CLAUDE_CODE = "claude_code"
CODEX = "codex"
GEMINI_CLI = "gemini_cli"
ANTIGRAVITY = "antigravity"
__all__ = ["ProviderType"]