mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
255
src/core/provider_oauth_utils.py
Normal file
255
src/core/provider_oauth_utils.py
Normal 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
|
||||
5
src/core/provider_templates/__init__.py
Normal file
5
src/core/provider_templates/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""固定 Provider 模板与 OAuth 常量。
|
||||
|
||||
注意:此包会包含从 CLIProxyAPI 复制的固定端点与 OAuth 客户端常量。
|
||||
敏感信息(如 client_secret / refresh_token / access_token)不得出现在日志或 API 响应中。
|
||||
"""
|
||||
130
src/core/provider_templates/fixed_providers.py
Normal file
130
src/core/provider_templates/fixed_providers.py
Normal 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
|
||||
|
||||
# 上游 API(ProviderEndpoint.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",
|
||||
]
|
||||
14
src/core/provider_templates/types.py
Normal file
14
src/core/provider_templates/types.py
Normal 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"]
|
||||
Reference in New Issue
Block a user