新增 Microsoft Teams 渠道扩展,支持在 Yuxi 平台中集成 Microsoft Teams 协作平台。 包含以下功能模块: - sdk: Bot Framework SDK 封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - auth: JWT 认证 - jwks: JWKS 密钥管理 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - state: 状态管理 - runtime: 运行时管理 - actions: 动作处理 - adaptive_card: 自适应卡片 - task_modules: 任务模块 - message_extension: 消息扩展 - proactive: Proactive Messaging - graph: Microsoft Graph API 集成 - graph_teams: Teams 操作 - graph_members: 成员管理 - graph_messages: 消息获取 - graph_thread: 线程管理 - graph_users: 用户管理 - graph_upload: 文件上传 - files: 文件处理 - file_consent: 文件授权 - conversations: 会话存储 - mentions: @提及处理 - threading: 线程管理 - reactions: 表情反应 - polls: 投票功能 - meetings: 会议集成 - feedback: 反馈处理 - sso: 单点登录 - deep_links: 深层链接 - incoming_webhook: 入站 Webhook - localization: 本地化 - user_agent: 用户代理 - sent_message_cache: 消息缓存 - types: 类型定义
141 lines
4.2 KiB
Python
141 lines
4.2 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from cachetools import TTLCache
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
JWKS_CACHE_MAXSIZE = 100
|
|
JWKS_CACHE_TTL = 600
|
|
|
|
_jwks_cache: TTLCache = TTLCache(maxsize=JWKS_CACHE_MAXSIZE, ttl=JWKS_CACHE_TTL)
|
|
|
|
|
|
class JWKSError(Exception):
|
|
pass
|
|
|
|
|
|
class JWKSNetworkError(JWKSError):
|
|
pass
|
|
|
|
|
|
class JWKSKeyNotFoundError(JWKSError):
|
|
pass
|
|
|
|
|
|
ISSUER_JWKS_MAP = {
|
|
"https://api.botframework.com": "https://login.botframework.com/v1/.well-known/keys",
|
|
"login.microsoftonline.com": "https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
|
"sts.windows.net": "https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
|
}
|
|
|
|
|
|
def _resolve_jwks_uri(iss: str) -> str | None:
|
|
for prefix, uri in ISSUER_JWKS_MAP.items():
|
|
if prefix in iss or iss.startswith(prefix):
|
|
return uri
|
|
if "login.microsoftonline.com" in iss:
|
|
return ISSUER_JWKS_MAP["login.microsoftonline.com"]
|
|
if "sts.windows.net" in iss:
|
|
return ISSUER_JWKS_MAP["sts.windows.net"]
|
|
return None
|
|
|
|
|
|
def _pem_from_jwk(jwk: dict) -> str:
|
|
from cryptography.hazmat.backends import default_backend
|
|
|
|
kty = jwk.get("kty")
|
|
|
|
if kty == "RSA":
|
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
|
|
|
n = int.from_bytes(_b64_decode(_ensure_str(jwk["n"])), byteorder="big")
|
|
e = int.from_bytes(_b64_decode(_ensure_str(jwk["e"])), byteorder="big")
|
|
pub = RSAPublicNumbers(e, n).public_key(default_backend())
|
|
return pub.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode(
|
|
"ascii"
|
|
)
|
|
|
|
if kty == "EC":
|
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers
|
|
|
|
crv = jwk.get("crv", "P-256")
|
|
x = int.from_bytes(_b64_decode(_ensure_str(jwk["x"])), byteorder="big")
|
|
y = int.from_bytes(_b64_decode(_ensure_str(jwk["y"])), byteorder="big")
|
|
|
|
import cryptography.hazmat.primitives.asymmetric.ec as _ec
|
|
|
|
curve = _ec.SECP256R1() if crv == "P-256" else _ec.SECP384R1()
|
|
pub_numbers = EllipticCurvePublicNumbers(x, y, curve)
|
|
pub = pub_numbers.public_key(default_backend())
|
|
return pub.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode(
|
|
"ascii"
|
|
)
|
|
|
|
raise JWKSKeyNotFoundError(f"Unsupported key type: {kty}")
|
|
|
|
|
|
def _b64_decode(data: str) -> bytes:
|
|
import base64
|
|
|
|
padded = data + "=" * (-len(data) % 4)
|
|
return base64.urlsafe_b64decode(padded)
|
|
|
|
|
|
def _ensure_str(val: Any) -> str:
|
|
if isinstance(val, str):
|
|
return val
|
|
return str(val)
|
|
|
|
|
|
async def _fetch_jwks(jwks_uri: str) -> dict:
|
|
cache_key = f"jwks:{jwks_uri}"
|
|
cached = _jwks_cache.get(cache_key)
|
|
if cached:
|
|
return cached
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
resp = await client.get(jwks_uri)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
_jwks_cache[cache_key] = data
|
|
return data
|
|
except httpx.NetworkError as e:
|
|
raise JWKSNetworkError(f"Network error fetching JWKS from {jwks_uri}: {e}") from e
|
|
except httpx.HTTPStatusError as e:
|
|
raise JWKSNetworkError(f"HTTP {e.response.status_code} fetching JWKS from {jwks_uri}") from e
|
|
except Exception as e:
|
|
raise JWKSError(f"Failed to fetch JWKS from {jwks_uri}: {e}") from e
|
|
|
|
|
|
async def get_jwks_public_key(jwks_uri: str, kid: str) -> str:
|
|
jwks_data = await _fetch_jwks(jwks_uri)
|
|
keys = jwks_data.get("keys", [])
|
|
|
|
for jwk in keys:
|
|
if jwk.get("kid") == kid:
|
|
return _pem_from_jwk(jwk)
|
|
|
|
raise JWKSKeyNotFoundError(f"Key with kid '{kid}' not found in JWKS from {jwks_uri}")
|
|
|
|
|
|
def collect_all_issuers(tenant_id: str) -> list[str]:
|
|
return [
|
|
"https://api.botframework.com",
|
|
f"https://login.microsoftonline.com/{tenant_id}/v2.0",
|
|
f"https://sts.windows.net/{tenant_id}/",
|
|
]
|
|
|
|
|
|
def resolve_jwks_uri_for_iss(iss: str) -> str | None:
|
|
return _resolve_jwks_uri(iss)
|
|
|
|
|
|
def clear_jwks_cache() -> None:
|
|
_jwks_cache.clear()
|