Files
MoviePilot/app/agent/llm/auth.py

624 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
LLM Provider 持久鉴权和外部授权协议的唯一 owner。
"""
from __future__ import annotations
import asyncio
import base64
import copy
import hashlib
import html
import json
import secrets
import time
from typing import Any, Optional
from urllib.parse import urlencode
import jwt
from app.adapters.external.github import GITHUB_DEVICE_CLIENT_ID, GithubAuthClient
from app.agent.llm.runtime import LLMProviderAuthError
from app.agent.llm.session import PendingAuthSession
from app.application.configuration import get_configured_system_config
from app.domain.github import GithubAuthTransportError
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.types import SystemConfigKey
def render_auth_result_html(success: bool, message: str) -> str:
"""
生成 OAuth 回调落地页,并转义所有外部可控文本。
"""
title = "授权成功" if success else "授权失败"
accent = "#3aa675" if success else "#e24b4b"
safe_title = html.escape(title, quote=True)
safe_message = html.escape(str(message or ""), quote=True)
event_payload = (
json.dumps(
{"type": "moviepilot-llm-auth", "success": success},
ensure_ascii=False,
)
.replace("<", "\u003c")
.replace(">", "\u003e")
.replace("&", "\u0026")
)
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{safe_title}</title>
<style>
body {{
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #101418;
color: #f3f5f7;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}}
.card {{
width: min(480px, calc(100vw - 32px));
padding: 28px 24px;
border-radius: 18px;
background: rgba(20, 28, 36, 0.92);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
}}
h1 {{
margin: 0 0 12px;
font-size: 24px;
color: {accent};
}}
p {{
margin: 0;
line-height: 1.7;
color: #d4dbe3;
}}
</style>
</head>
<body>
<div class="card">
<h1>{safe_title}</h1>
<p>{safe_message}</p>
</div>
<script>
if (window.opener) {{
try {{
window.opener.postMessage({event_payload}, "*");
}} catch (err) {{}}
}}
setTimeout(() => window.close(), 1800);
</script>
</body>
</html>"""
class _ProviderAuth:
"""
LLM Provider 持久鉴权和外部授权协议的唯一 owner。
"""
_lock: Any
_chatgpt_refresh_lock: asyncio.Lock
_pending_sessions: dict[str, PendingAuthSession]
_oauth_state_index: dict[str, str]
def __getattr__(self, name: str) -> Any:
"""
将跨 owner 调用交给最终 Facade 的 MRO 解析。
"""
raise AttributeError(name)
_CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
_CHATGPT_ISSUER = "https://auth.openai.com"
_CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
_COPILOT_CLIENT_ID = GITHUB_DEVICE_CLIENT_ID
@staticmethod
def _read_agent_config() -> dict[str, Any]:
"""
读取 AI Agent 配置信息。
"""
config = get_configured_system_config().get(SystemConfigKey.AIAgentConfig)
if isinstance(config, dict):
return config
return {}
@staticmethod
async def _write_agent_config(value: dict[str, Any]) -> None:
"""
使用异步持久化写回 provider 鉴权配置。
`get_configured_system_config().get()` 读取的是内存缓存,这里保留同步调用;
但写入需要落库,因此统一走 `async_set()`。
"""
await get_configured_system_config().async_set(
SystemConfigKey.AIAgentConfig,
copy.deepcopy(value) or None,
)
def _get_auth_store(self) -> dict[str, Any]:
"""
获取所有鉴权数据。
"""
config = self._read_agent_config()
auth_store = config.get("provider_auth")
if isinstance(auth_store, dict):
return auth_store
return {}
def get_saved_auth(self, provider_id: str) -> dict[str, Any] | None:
"""
读取持久化 provider 鉴权信息。
"""
return copy.deepcopy(self._get_auth_store().get(provider_id))
async def save_auth(self, provider_id: str, auth_data: dict[str, Any]) -> None:
"""
写入 provider 鉴权信息。
"""
config = self._read_agent_config()
auth_store = config.get("provider_auth")
if not isinstance(auth_store, dict):
auth_store = {}
auth_store[provider_id] = copy.deepcopy(auth_data)
config["provider_auth"] = auth_store
await self._write_agent_config(config)
async def clear_auth(self, provider_id: str) -> None:
"""
移除 provider 鉴权信息。
"""
config = self._read_agent_config()
auth_store = config.get("provider_auth")
if not isinstance(auth_store, dict):
return
auth_store.pop(provider_id, None)
if auth_store:
config["provider_auth"] = auth_store
else:
config.pop("provider_auth", None)
await self._write_agent_config(config)
def get_auth_status(self, provider_id: str) -> dict[str, Any]:
"""
返回前端展示用的 provider 鉴权摘要。
"""
auth = self.get_saved_auth(provider_id)
if not auth:
return {"connected": False}
return {
"connected": True,
"type": auth.get("type"),
"label": auth.get("label") or auth.get("email") or auth.get("account_id") or "已授权",
"expires_at": auth.get("expires_at"),
"updated_at": auth.get("updated_at"),
}
@staticmethod
def _jwt_claims(token: str) -> dict[str, Any]:
"""
解析 JWT token 内容(不验证签名)。
"""
try:
payload = jwt.decode(token, options={"verify_signature": False})
return payload if isinstance(payload, dict) else {}
except Exception as err:
logger.debug(f"解析 JWT token 内容失败: {err}")
return {}
@staticmethod
def _extract_chatgpt_account_id(token_payload: dict[str, Any]) -> Optional[str]:
"""
从 ChatGPT 的 Token payload 中提取 account id。
"""
if token_payload.get("chatgpt_account_id"):
return str(token_payload["chatgpt_account_id"])
auth_payload = token_payload.get("https://api.openai.com/auth") or {}
if isinstance(auth_payload, dict) and auth_payload.get("chatgpt_account_id"):
return str(auth_payload["chatgpt_account_id"])
organizations = token_payload.get("organizations") or []
if organizations and isinstance(organizations[0], dict):
return organizations[0].get("id")
return None
def _chatgpt_authorize_url(self, redirect_uri: str, challenge: str, state: str) -> str:
"""
构建 ChatGPT OAuth 授权链接。
"""
query = urlencode(
{
"response_type": "code",
"client_id": self._CHATGPT_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": "openid profile email offline_access",
"code_challenge": challenge,
"code_challenge_method": "S256",
"id_token_add_organizations": "true",
"codex_cli_simplified_flow": "true",
"state": state,
"originator": "moviepilot",
}
)
return f"{self._CHATGPT_ISSUER}/oauth/authorize?{query}"
@staticmethod
def _pkce_pair() -> tuple[str, str]:
"""
生成 PKCE verifier 和 challenge。
"""
verifier = secrets.token_urlsafe(64).replace("=", "")
digest = hashlib.sha256(verifier.encode("utf-8")).digest()
challenge = base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
return verifier, challenge
async def start_auth(
self,
provider_id: str,
method_id: str,
callback_url: Optional[str] = None,
) -> dict[str, Any]:
"""
启动 OAuth / device code 会话。
API Key 方式已经由普通设置表单覆盖,这里只处理需要交互式授权的 provider。
"""
provider = await self._get_provider_async(provider_id)
method = next(
(item for item in provider.oauth_methods if item.id == method_id),
None,
)
if not method:
raise LLMProviderAuthError(f"{provider.name} 不支持授权方式:{method_id}")
session = PendingAuthSession(
session_id=secrets.token_urlsafe(18),
provider_id=provider_id,
method_id=method_id,
flow_type=method.type,
expires_at=time.time() + 600,
)
if provider_id == "chatgpt" and method_id == "browser_oauth":
if not callback_url:
raise LLMProviderAuthError("ChatGPT 浏览器授权缺少回调地址")
verifier, challenge = self._pkce_pair()
state = secrets.token_urlsafe(24)
session.authorize_url = self._chatgpt_authorize_url(
redirect_uri=callback_url,
challenge=challenge,
state=state,
)
session.instructions = "请在浏览器中完成 ChatGPT Plus/Pro 登录授权。"
session.context.update(
{
"code_verifier": verifier,
"state": state,
"redirect_uri": callback_url,
}
)
with self._lock:
self._cleanup_auth_sessions_locked()
self._pending_sessions[session.session_id] = session
self._oauth_state_index[state] = session.session_id
return {
"session_id": session.session_id,
"flow_type": "oauth_browser",
"authorize_url": session.authorize_url,
"instructions": session.instructions,
"expires_at": session.expires_at,
}
if provider_id == "chatgpt" and method_id == "device_code":
response = await self._build_async_request(
headers={
"Content-Type": "application/json",
"User-Agent": get_runtime_setting("USER_AGENT"),
}
).post_res(
f"{self._CHATGPT_ISSUER}/api/accounts/deviceauth/usercode",
json={"client_id": self._CHATGPT_CLIENT_ID},
raise_exception=True,
)
response.raise_for_status()
payload = response.json()
session.verification_url = f"{self._CHATGPT_ISSUER}/codex/device"
session.user_code = payload.get("user_code")
session.interval_seconds = max(int(payload.get("interval") or 5), 1)
session.instructions = f"请在浏览器输入设备码:{session.user_code}"
session.context.update(
{
"device_auth_id": payload.get("device_auth_id"),
"user_code": payload.get("user_code"),
}
)
with self._lock:
self._cleanup_auth_sessions_locked()
self._pending_sessions[session.session_id] = session
return {
"session_id": session.session_id,
"flow_type": "device_code",
"verification_url": session.verification_url,
"user_code": session.user_code,
"interval_seconds": session.interval_seconds,
"instructions": session.instructions,
"expires_at": session.expires_at,
}
if provider_id == "github-copilot" and method_id == "device_code":
try:
device = await GithubAuthClient().request_device_code(self._COPILOT_CLIENT_ID)
except GithubAuthTransportError as error:
raise LLMProviderAuthError(str(error)) from error
session.verification_url = device.verification_uri
session.user_code = device.user_code
session.interval_seconds = device.interval_seconds
session.expires_at = time.time() + device.expires_in
session.instructions = f"请在 GitHub 页面输入设备码:{session.user_code}"
session.context.update(
{
"device_code": device.device_code,
}
)
with self._lock:
self._cleanup_auth_sessions_locked()
self._pending_sessions[session.session_id] = session
return {
"session_id": session.session_id,
"flow_type": "device_code",
"verification_url": session.verification_url,
"user_code": session.user_code,
"interval_seconds": session.interval_seconds,
"instructions": session.instructions,
"expires_at": session.expires_at,
}
raise LLMProviderAuthError(f"暂未实现 {provider.name} 的授权方式:{method.label}")
async def handle_chatgpt_callback(
self,
provider_id: str,
code: Optional[str],
state: Optional[str],
error: Optional[str],
error_description: Optional[str],
) -> tuple[bool, str]:
"""
处理 ChatGPT 浏览器 OAuth 回调。
"""
if provider_id != "chatgpt":
return False, "当前 provider 不支持浏览器 OAuth 回调"
if error:
message = error_description or error
with self._lock:
self._cleanup_auth_sessions_locked()
session_id = self._oauth_state_index.pop(state or "", None)
if session_id and session_id in self._pending_sessions:
self._mark_session_error(self._pending_sessions[session_id], message)
return False, message
if not code or not state:
return False, "缺少授权码或 state 参数"
with self._lock:
self._cleanup_auth_sessions_locked()
session_id = self._oauth_state_index.pop(state, None)
session = self._pending_sessions.get(session_id or "")
if not session:
return False, "授权会话不存在或已失效"
if state != session.context.get("state"):
self._mark_session_error(session, "state 校验失败")
return False, "state 校验失败"
try:
payload = await self._exchange_chatgpt_code_for_tokens(
code=code,
redirect_uri=session.context["redirect_uri"],
code_verifier=session.context["code_verifier"],
)
claims = self._jwt_claims(payload.get("id_token") or payload["access_token"])
account_id = self._extract_chatgpt_account_id(claims)
auth_data = {
"type": "oauth",
"provider": "chatgpt",
"access_token": payload["access_token"],
"refresh_token": payload["refresh_token"],
"expires_at": int(time.time() + int(payload.get("expires_in") or 3600)),
"account_id": account_id,
"email": claims.get("email"),
"label": claims.get("email") or account_id or "ChatGPT Plus/Pro",
}
await self._mark_session_success(session, auth_data)
return True, "ChatGPT 授权成功"
except Exception as err:
message = f"ChatGPT 授权失败: {err}"
self._mark_session_error(session, message)
return False, message
async def _exchange_chatgpt_code_for_tokens(
self, code: str, redirect_uri: str, code_verifier: str
) -> dict[str, Any]:
"""
使用 authorization code 交换 ChatGPT 令牌。
"""
response = await self._build_async_request(
headers={"Content-Type": "application/x-www-form-urlencoded"}
).post_res(
f"{self._CHATGPT_ISSUER}/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._CHATGPT_CLIENT_ID,
"code_verifier": code_verifier,
},
raise_exception=True,
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise LLMProviderAuthError("ChatGPT 授权服务返回了无效响应")
return payload
async def _refresh_chatgpt_tokens(self, refresh_token: str) -> dict[str, Any]:
"""
刷新 ChatGPT 的 access_token。
"""
response = await self._build_async_request(
headers={"Content-Type": "application/x-www-form-urlencoded"}
).post_res(
f"{self._CHATGPT_ISSUER}/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": self._CHATGPT_CLIENT_ID,
},
raise_exception=True,
)
if response.status_code >= 400:
# 只展示已知错误码的本地说明,不把响应正文或令牌带入日志和管理接口。
try:
error = response.json().get("error")
except (ValueError, AttributeError):
error = None
code = error.get("code") if isinstance(error, dict) else error
messages = {
"refresh_token_reused": "ChatGPT 刷新令牌已被使用,请重新授权;请勿在多个客户端复制同一授权",
"refresh_token_expired": "ChatGPT 刷新令牌已过期,请重新授权",
"refresh_token_invalidated": "ChatGPT 授权已被撤销,请重新授权",
"invalid_grant": "ChatGPT 授权已失效,请重新授权",
}
message = messages.get(code) if isinstance(code, str) else None
raise LLMProviderAuthError(
message or f"ChatGPT 令牌刷新失败(HTTP {response.status_code}),请稍后重试"
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise LLMProviderAuthError("ChatGPT 刷新服务返回了无效响应")
return payload
async def _poll_chatgpt_device_auth(self, session: PendingAuthSession) -> None:
"""
轮询 ChatGPT Device Auth 状态。
"""
response = await self._build_async_request(
headers={
"Content-Type": "application/json",
"User-Agent": get_runtime_setting("USER_AGENT"),
}
).post_res(
f"{self._CHATGPT_ISSUER}/api/accounts/deviceauth/token",
json={
"device_auth_id": session.context["device_auth_id"],
"user_code": session.context["user_code"],
},
raise_exception=True,
)
if response.status_code in {403, 404}:
session.message = "等待用户在浏览器完成授权"
return
response.raise_for_status()
payload = response.json()
token_payload = await self._exchange_chatgpt_code_for_tokens(
code=payload["authorization_code"],
redirect_uri=f"{self._CHATGPT_ISSUER}/deviceauth/callback",
code_verifier=payload["code_verifier"],
)
claims = self._jwt_claims(token_payload.get("id_token") or token_payload["access_token"])
account_id = self._extract_chatgpt_account_id(claims)
await self._mark_session_success(
session,
{
"type": "oauth",
"provider": "chatgpt",
"access_token": token_payload["access_token"],
"refresh_token": token_payload["refresh_token"],
"expires_at": int(time.time() + int(token_payload.get("expires_in") or 3600)),
"account_id": account_id,
"email": claims.get("email"),
"label": claims.get("email") or account_id or "ChatGPT Plus/Pro",
},
)
async def _poll_copilot_device_auth(self, session: PendingAuthSession) -> None:
"""
轮询 GitHub Copilot Device Auth 状态。
"""
try:
exchange = await GithubAuthClient().exchange_device_code(
self._COPILOT_CLIENT_ID,
session.context["device_code"],
)
except GithubAuthTransportError as error:
raise LLMProviderAuthError(str(error)) from error
if exchange.access_token:
await self._mark_session_success(
session,
{
"type": "oauth",
"provider": "github-copilot",
"access_token": exchange.access_token,
# Copilot 设备码授权返回的是长期可复用 token,这里复用 access 字段即可。
"refresh_token": exchange.refresh_token or exchange.access_token,
"expires_at": None,
"label": "GitHub Copilot",
},
)
return
exchange_error = exchange.error
if exchange_error == "authorization_pending":
session.message = "等待用户在 GitHub 页面完成授权"
return
if exchange_error == "slow_down":
session.interval_seconds = max(session.interval_seconds + 5, 10)
session.message = "GitHub 要求降低轮询频率,稍后继续。"
return
if exchange_error:
raise LLMProviderAuthError("GitHub Copilot 授权失败,请重新开始。")
async def _resolve_chatgpt_oauth(self) -> dict[str, Any]:
"""
解析并返回 ChatGPT OAuth 鉴权,支持自动刷新 Token。
"""
# 读取、刷新和持久化必须串行;等待者重新读取轮换后的令牌,不能重用旧令牌。
async with self._chatgpt_refresh_lock:
auth = self.get_saved_auth("chatgpt")
if not auth or auth.get("type") != "oauth":
raise LLMProviderAuthError("尚未完成 ChatGPT Plus/Pro 授权")
expires_at = auth.get("expires_at")
refresh_token = auth.get("refresh_token")
# 预留 60 秒刷新缓冲,避免刚发起请求就遇到过期。
if expires_at and refresh_token and int(expires_at) <= int(time.time()) + 60:
payload = await self._refresh_chatgpt_tokens(refresh_token)
claims = self._jwt_claims(payload.get("id_token") or payload["access_token"])
auth.update(
{
"access_token": payload["access_token"],
"refresh_token": payload.get("refresh_token") or refresh_token,
"expires_at": int(time.time() + int(payload.get("expires_in") or 3600)),
"account_id": auth.get("account_id") or self._extract_chatgpt_account_id(claims),
"email": auth.get("email") or claims.get("email"),
"label": auth.get("label") or claims.get("email") or auth.get("account_id") or "ChatGPT Plus/Pro",
}
)
await self.save_auth("chatgpt", auth)
return auth