from dataclasses import dataclass from urllib.parse import urlparse import httpx class BitrixAuthError(RuntimeError): pass @dataclass(frozen=True) class BitrixUser: id: int name: str @dataclass(frozen=True) class BitrixAuth: member_id: str domain: str access_token: str refresh_token: str expires_in: int user: BitrixUser class BitrixClient: """Получает доверенный OAuth-контекст и проверяет пользователя.""" def __init__( self, client_id: str, client_secret: str, oauth_token_url: str, client: httpx.Client | None = None, ) -> None: self.client_id = client_id self.client_secret = client_secret self.oauth_token_url = oauth_token_url # Клиент передается как внешняя зависимость для модульного тестирования. self._client = client or httpx.Client(timeout=15) # Соответственно, если клиент внешний, то класс # этим ресурсом не управляет. self._owns_client = client is None def authorize(self, refresh_token: str) -> BitrixAuth: try: # Обмениваем рефреш-токен на новую пару токенов. # https://apidocs.bitrix24.com/settings/oauth/auto-renewal.html # https://apidocs.bitrix24.com/settings/oauth/simple-way.html response = self._client.get( self.oauth_token_url, params={ "grant_type": "refresh_token", "client_id": self.client_id, "client_secret": self.client_secret, "refresh_token": refresh_token, }, ) response.raise_for_status() except httpx.HTTPError: # URL запроса содержит секреты, поэтому не пробрасываем его выше. raise BitrixAuthError("Не удалось обновить OAuth-токен") from None data = response.json() if "error" in data: raise BitrixAuthError( str(data.get("error_description") or data["error"])) # Получаем эндпоинт, с которым связаны наши токены. endpoint = str(data.get("client_endpoint") or "") parsed_endpoint = urlparse(endpoint) if parsed_endpoint.scheme != "https" or not parsed_endpoint.hostname: raise BitrixAuthError("Bitrix вернул некорректный REST endpoint") # Сохраняем токен доступа и проверяем пользователя. access_token = str(data["access_token"]) user = self._current_user(endpoint, access_token) expected_user_id = data.get("user_id") if expected_user_id is not None and user.id != int(expected_user_id): raise BitrixAuthError( "OAuth-токен принадлежит другому пользователю") return BitrixAuth( member_id=str(data["member_id"]), domain=parsed_endpoint.hostname.lower(), access_token=access_token, refresh_token=str(data["refresh_token"]), expires_in=int(data.get("expires_in", 3600)), user=user, ) def _current_user(self, endpoint: str, access_token: str) -> BitrixUser: """Получение информации о пользователе для проверки работоспособности.""" # https://apidocs.bitrix24.com/api-reference/user/user-current.html response = self._client.post( endpoint.rstrip("/") + "/user.current.json", data={"auth": access_token}, ) response.raise_for_status() data = response.json() if "error" in data or not data.get("result"): raise BitrixAuthError("Bitrix не подтвердил текущего пользователя") user = data["result"] name = " ".join( part for part in ( str(user.get("NAME") or "").strip(), str(user.get("LAST_NAME") or "").strip(), ) if part ) return BitrixUser(id=int(user["ID"]), name=name or f"ID {user['ID']}") def close(self) -> None: if self._owns_client: self._client.close()