"""Microsoft Teams Bot Framework SSO (Single Sign-On) 处理。 处理 signin/tokenExchange 和 signin/verifyState invoke 交互, 实现 Bot Framework OAuth 卡片授权流程、SSO Token 持久化存储、 以及 DM/Channel/Group 三级 SSO 授权检查。 """ from __future__ import annotations import json import time from pathlib import Path from typing import Any import aiohttp from yuxi.utils.logging_config import logger SIGNIN_TOKEN_EXCHANGE = "signin/tokenExchange" SIGNIN_VERIFY_STATE = "signin/verifyState" BOT_USER_TOKEN_SERVICE = "https://token.botframework.com/api/usertoken/GetToken" BOT_USER_TOKEN_EXCHANGE = "https://token.botframework.com/api/usertoken/ExchangeToken" SSO_TOKEN_STORE_FILENAME = "msteams-sso-tokens.json" SSO_TOKEN_TTL_SECONDS = 86400 def is_signin_invoke(activity: dict[str, Any]) -> bool: name = activity.get("name", "") return name in (SIGNIN_TOKEN_EXCHANGE, SIGNIN_VERIFY_STATE) def is_token_exchange(activity: dict[str, Any]) -> bool: return activity.get("name", "") == SIGNIN_TOKEN_EXCHANGE def is_verify_state(activity: dict[str, Any]) -> bool: return activity.get("name", "") == SIGNIN_VERIFY_STATE def extract_signin_token(activity: dict[str, Any]) -> dict[str, Any]: value = activity.get("value", {}) or {} return { "token": value.get("token", ""), "id": value.get("id", ""), "state": value.get("state", ""), } def build_oauth_card( connection_name: str, title: str = "Sign in", text: str = "Please sign in to continue.", button_text: str = "Sign in", ) -> dict[str, Any]: return { "type": "AdaptiveCard", "version": "1.5", "body": [ {"type": "TextBlock", "size": "Large", "weight": "Bolder", "text": title}, {"type": "TextBlock", "text": text, "wrap": True}, ], "actions": [ { "type": "Action.OpenUrl", "title": button_text, "url": f"https://token.botframework.com/api/oauth/signin?signin={connection_name}", } ], } def build_token_exchange_response( activity_id: str, status_code: int = 200, ) -> dict[str, Any]: return { "id": activity_id, "type": "invokeResponse", "status": status_code, } def build_sso_auth_activity( text: str = "Authentication successful", connection_name: str = "", ) -> dict[str, Any]: activity: dict[str, Any] = { "type": "message", "text": text, "textFormat": "markdown", } if connection_name: activity["channelData"] = { "oauthConnectionName": connection_name, } return activity class SSOTokenStore: """SSO Token 文件持久化存储。""" def __init__(self, storage_dir: str | None = None): self._storage_dir = Path(storage_dir or str(Path.home() / ".yuxi" / "msteams")) self._storage_dir.mkdir(parents=True, exist_ok=True) self._tokens: dict[str, dict[str, Any]] = {} self._load() @property def file_path(self) -> Path: return self._storage_dir / SSO_TOKEN_STORE_FILENAME def _load(self) -> None: if not self.file_path.exists(): return try: data = json.loads(self.file_path.read_text(encoding="utf-8")) self._tokens = data.get("tokens", {}) except (json.JSONDecodeError, OSError): pass def _save(self) -> None: try: self.file_path.write_text( json.dumps({"tokens": self._tokens}, ensure_ascii=False, indent=2), encoding="utf-8", ) except OSError as e: logger.error(f"MSTeams SSO token store: failed to save: {e}") def store(self, user_id: str, token_data: dict[str, Any]) -> None: self._tokens[user_id] = { "token_data": token_data, "stored_at": time.time(), } self._save() def get(self, user_id: str) -> dict[str, Any] | None: entry = self._tokens.get(user_id) if not entry: return None stored_at = entry.get("stored_at", 0) if time.time() - stored_at > SSO_TOKEN_TTL_SECONDS: self.remove(user_id) return None return entry.get("token_data") def remove(self, user_id: str) -> None: self._tokens.pop(user_id, None) self._save() def clear(self) -> None: self._tokens.clear() self._save() class SSOHandler: def __init__( self, connection_name: str = "", enabled: bool = False, verify_state_fallback: bool = False, ): self.connection_name = connection_name self.enabled = enabled self.verify_state_fallback = verify_state_fallback self._verified_states: set[str] = set() self._token_store = SSOTokenStore() self._session: aiohttp.ClientSession | None = None @property def token_store(self) -> SSOTokenStore: return self._token_store async def _ensure_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: self._session = aiohttp.ClientSession() return self._session async def close(self) -> None: if self._session and not self._session.closed: await self._session.close() self._session = None def add_verified_state(self, state: str) -> None: self._verified_states.add(state) def is_state_verified(self, state: str) -> bool: return state in self._verified_states async def exchange_token( self, user_token: str, bot_token: str, connection_name: str = "", ) -> dict[str, Any]: conn_name = connection_name or self.connection_name if not conn_name: return {"error": "missing_connection_name"} headers = { "Authorization": f"Bearer {bot_token}", "Content-Type": "application/json", } body = { "userToken": user_token, "connectionName": conn_name, "channelId": "msteams", } try: session = await self._ensure_session() async with session.post(BOT_USER_TOKEN_EXCHANGE, headers=headers, json=body) as resp: if resp.status == 200: result = await resp.json() return { "success": True, "token": result.get("token", ""), "expiration": result.get("expiration", ""), "connectionName": result.get("connectionName", ""), } body_text = await resp.text() logger.warning(f"SSO token exchange failed: HTTP {resp.status} - {body_text[:300]}") return {"error": f"exchange_failed_{resp.status}", "detail": body_text[:300]} except Exception as e: logger.error(f"SSO token exchange error: {e}") return {"error": "network_error", "detail": str(e)} async def get_user_token( self, user_id: str, bot_token: str, connection_name: str = "", ) -> dict[str, Any]: conn_name = connection_name or self.connection_name if not conn_name: return {"error": "missing_connection_name"} headers = { "Authorization": f"Bearer {bot_token}", } try: session = await self._ensure_session() params = {"userId": user_id, "connectionName": conn_name, "channelId": "msteams"} async with session.get(BOT_USER_TOKEN_SERVICE, headers=headers, params=params) as resp: if resp.status == 200: result = await resp.json() return {"success": True, **result} body_text = await resp.text() return {"error": f"get_token_failed_{resp.status}", "detail": body_text[:300]} except Exception as e: logger.error(f"SSO get user token error: {e}") return {"error": "network_error", "detail": str(e)} def check_sso_authorization( self, chat_type: str, conversation_id: str, dm_policy: str = "open", group_policy: str = "open", ) -> bool: """三重检查:DM / Channel / Group 三级 SSO 授权。 Returns True 如果该 chat_type 允许 SSO。 """ if chat_type in ("direct",): return dm_policy != "disabled" if chat_type in ("channel",): return group_policy != "disabled" if chat_type in ("group", "thread"): return group_policy != "disabled" return False async def handle_signin(self, activity: dict[str, Any]) -> dict[str, Any] | None: if not self.enabled: logger.debug("MSTeams SSO: SSO not enabled") return None if is_token_exchange(activity): token_info = extract_signin_token(activity) from_info = activity.get("from", {}) or {} user_id = from_info.get("aadObjectId", "") or from_info.get("id", "") logger.info(f"MSTeams SSO: token exchange from {from_info.get('name', 'unknown')}") if token_info.get("token"): self._token_store.store( user_id, { "token": token_info["token"], "state": token_info.get("state", ""), "connection_name": self.connection_name, }, ) return { "activity_id": activity.get("id", ""), "response": build_token_exchange_response(activity.get("id", ""), 200), "token_info": token_info, "user_id": user_id, } if is_verify_state(activity): state = (activity.get("value", {}) or {}).get("state", "") self.add_verified_state(state) logger.info(f"MSTeams SSO: state verified: {state[:20]}...") return { "activity_id": activity.get("id", ""), "response": build_token_exchange_response(activity.get("id", ""), 200), "state": state, } return None