sso-nebus 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sso_nebus/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ SSO Nebus Client - Python клиент для взаимодействия с MS Auth Service API
3
+
4
+ Этот пакет предоставляет удобные классы для работы с API аутентификации:
5
+ - UserClient: для пользовательского взаимодействия (OAuth 2.0 с PKCE)
6
+ - ServiceClient: для микросервисного взаимодействия (Client Credentials)
7
+ """
8
+
9
+ from user_client import UserClient
10
+ from service_client import ServiceClient
11
+ from exceptions import (
12
+ SSOClientError,
13
+ AuthenticationError,
14
+ AuthorizationError,
15
+ APIError,
16
+ TokenError,
17
+ )
18
+
19
+ __all__ = [
20
+ "UserClient",
21
+ "ServiceClient",
22
+ "SSOClientError",
23
+ "AuthenticationError",
24
+ "AuthorizationError",
25
+ "APIError",
26
+ "TokenError",
27
+ ]
28
+
29
+ __version__ = "0.1.0"
30
+
sso_nebus/base.py ADDED
@@ -0,0 +1,204 @@
1
+ """Базовый класс для SSO клиентов"""
2
+
3
+ from typing import Optional, Dict, Any
4
+ from urllib.parse import urljoin
5
+
6
+ import aiohttp
7
+ from aiohttp import ClientSession, ClientResponse
8
+
9
+ from exceptions import (
10
+ APIError,
11
+ AuthenticationError,
12
+ AuthorizationError,
13
+ )
14
+
15
+
16
+ class BaseClient:
17
+ """Базовый класс для всех SSO клиентов"""
18
+
19
+ def __init__(
20
+ self,
21
+ base_url: str,
22
+ api_version: str = "v1",
23
+ timeout: int = 30,
24
+ session: Optional[ClientSession] = None,
25
+ ):
26
+ """
27
+ Инициализация базового клиента
28
+
29
+ Args:
30
+ base_url: Базовый URL API (например, "http://localhost:8000")
31
+ api_version: Версия API (по умолчанию "v1")
32
+ timeout: Таймаут запросов в секундах
33
+ session: Опциональная aiohttp сессия (если не указана, создается новая)
34
+ """
35
+ self.base_url = base_url.rstrip("/")
36
+ self.api_version = api_version
37
+ self.timeout = aiohttp.ClientTimeout(total=timeout)
38
+ self._session = session
39
+ self._own_session = session is None
40
+
41
+ @property
42
+ def api_base_url(self) -> str:
43
+ """Базовый URL для API endpoints"""
44
+ return urljoin(self.base_url, f"/api/{self.api_version}/")
45
+
46
+ @property
47
+ def session(self) -> ClientSession:
48
+ """Получить или создать aiohttp сессию"""
49
+ if self._session is None:
50
+ self._session = aiohttp.ClientSession(timeout=self.timeout)
51
+ self._own_session = True
52
+ elif self._session.closed:
53
+ # Если сессия закрыта, создаем новую
54
+ self._session = aiohttp.ClientSession(timeout=self.timeout)
55
+ self._own_session = True
56
+ return self._session
57
+
58
+ async def close(self):
59
+ """Закрыть сессию (если она была создана клиентом)"""
60
+ if self._own_session and self._session and not self._session.closed:
61
+ await self._session.close()
62
+
63
+ async def __aenter__(self):
64
+ """Поддержка async context manager"""
65
+ return self
66
+
67
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
68
+ """Закрытие сессии при выходе из context manager"""
69
+ await self.close()
70
+
71
+ def _build_url(self, endpoint: str) -> str:
72
+ """Построить полный URL для endpoint"""
73
+ endpoint = endpoint.lstrip("/")
74
+ return urljoin(self.api_base_url, endpoint)
75
+
76
+ def _get_headers(self, access_token: Optional[str] = None) -> Dict[str, str]:
77
+ """Получить заголовки для запроса"""
78
+ headers = {"Content-Type": "application/json"}
79
+ if access_token:
80
+ headers["Authorization"] = f"Bearer {access_token}"
81
+ return headers
82
+
83
+ async def _handle_response(self, response: ClientResponse) -> Dict[str, Any]:
84
+ """
85
+ Обработать HTTP ответ
86
+
87
+ Args:
88
+ response: aiohttp ClientResponse
89
+
90
+ Returns:
91
+ Распарсенный JSON ответ
92
+
93
+ Raises:
94
+ APIError: При ошибках API
95
+ AuthenticationError: При ошибках аутентификации (401)
96
+ AuthorizationError: При ошибках авторизации (403)
97
+ """
98
+ try:
99
+ data = await response.json()
100
+ except aiohttp.ContentTypeError:
101
+ # Если ответ не JSON, пытаемся получить текст
102
+ text = await response.text()
103
+ data = {"detail": text} if text else {
104
+ "detail": "Неизвестная ошибка"}
105
+
106
+ if response.status == 401:
107
+ detail = data.get("detail", "Ошибка аутентификации")
108
+ raise AuthenticationError(detail)
109
+
110
+ if response.status == 403:
111
+ detail = data.get("detail", "Ошибка авторизации")
112
+ raise AuthorizationError(detail)
113
+
114
+ if not response.ok:
115
+ detail = data.get("detail", f"Ошибка API: {response.status}")
116
+ raise APIError(detail, status_code=response.status)
117
+
118
+ return data
119
+
120
+ async def _request(
121
+ self,
122
+ method: str,
123
+ endpoint: str,
124
+ access_token: Optional[str] = None,
125
+ json_data: Optional[Dict[str, Any]] = None,
126
+ form_data: Optional[Dict[str, Any]] = None,
127
+ params: Optional[Dict[str, Any]] = None,
128
+ ) -> Dict[str, Any]:
129
+ """
130
+ Выполнить HTTP запрос
131
+
132
+ Args:
133
+ method: HTTP метод (GET, POST, etc.)
134
+ endpoint: API endpoint (например, "auth/me")
135
+ access_token: Access token для авторизации (опционально)
136
+ json_data: JSON данные для тела запроса
137
+ form_data: Form data для тела запроса
138
+ params: Query параметры
139
+
140
+ Returns:
141
+ Распарсенный JSON ответ
142
+ """
143
+ url = self._build_url(endpoint)
144
+ headers = self._get_headers(access_token)
145
+
146
+ # Если form_data, меняем Content-Type
147
+ if form_data:
148
+ headers.pop("Content-Type", None)
149
+
150
+ async with self.session.request(
151
+ method=method,
152
+ url=url,
153
+ headers=headers,
154
+ json=json_data,
155
+ data=form_data,
156
+ params=params,
157
+ ) as response:
158
+ return await self._handle_response(response)
159
+
160
+ async def get(
161
+ self,
162
+ endpoint: str,
163
+ access_token: Optional[str] = None,
164
+ params: Optional[Dict[str, Any]] = None,
165
+ ) -> Dict[str, Any]:
166
+ """Выполнить GET запрос"""
167
+ return await self._request("GET", endpoint, access_token=access_token, params=params)
168
+
169
+ async def post(
170
+ self,
171
+ endpoint: str,
172
+ access_token: Optional[str] = None,
173
+ json_data: Optional[Dict[str, Any]] = None,
174
+ form_data: Optional[Dict[str, Any]] = None,
175
+ params: Optional[Dict[str, Any]] = None,
176
+ ) -> Dict[str, Any]:
177
+ """Выполнить POST запрос"""
178
+ return await self._request(
179
+ "POST",
180
+ endpoint,
181
+ access_token=access_token,
182
+ json_data=json_data,
183
+ form_data=form_data,
184
+ params=params,
185
+ )
186
+
187
+ async def put(
188
+ self,
189
+ endpoint: str,
190
+ access_token: Optional[str] = None,
191
+ json_data: Optional[Dict[str, Any]] = None,
192
+ params: Optional[Dict[str, Any]] = None,
193
+ ) -> Dict[str, Any]:
194
+ """Выполнить PUT запрос"""
195
+ return await self._request("PUT", endpoint, access_token=access_token, json_data=json_data, params=params)
196
+
197
+ async def delete(
198
+ self,
199
+ endpoint: str,
200
+ access_token: Optional[str] = None,
201
+ params: Optional[Dict[str, Any]] = None,
202
+ ) -> Dict[str, Any]:
203
+ """Выполнить DELETE запрос"""
204
+ return await self._request("DELETE", endpoint, access_token=access_token, params=params)
@@ -0,0 +1,38 @@
1
+ """Исключения для SSO клиента"""
2
+
3
+
4
+ class SSOClientError(Exception):
5
+ """Базовое исключение для всех ошибок клиента"""
6
+
7
+ def __init__(self, message: str, status_code: int | None = None):
8
+ self.message = message
9
+ self.status_code = status_code
10
+ super().__init__(self.message)
11
+
12
+
13
+ class AuthenticationError(SSOClientError):
14
+ """Ошибка аутентификации (401)"""
15
+
16
+ def __init__(self, message: str = "Ошибка аутентификации"):
17
+ super().__init__(message, status_code=401)
18
+
19
+
20
+ class AuthorizationError(SSOClientError):
21
+ """Ошибка авторизации (403)"""
22
+
23
+ def __init__(self, message: str = "Ошибка авторизации"):
24
+ super().__init__(message, status_code=403)
25
+
26
+
27
+ class APIError(SSOClientError):
28
+ """Ошибка API (4xx, 5xx)"""
29
+
30
+ def __init__(self, message: str, status_code: int):
31
+ super().__init__(message, status_code=status_code)
32
+
33
+
34
+ class TokenError(SSOClientError):
35
+ """Ошибка работы с токенами"""
36
+
37
+ def __init__(self, message: str = "Ошибка работы с токеном"):
38
+ super().__init__(message)
@@ -0,0 +1,50 @@
1
+ """Пример использования ServiceClient"""
2
+
3
+ import asyncio
4
+ from service_client import ServiceClient
5
+
6
+
7
+ async def main():
8
+ # Создаем клиент для микросервиса
9
+ client = ServiceClient(
10
+ base_url="http://localhost:8000",
11
+ client_id="service_id",
12
+ client_secret="service_secret",
13
+ )
14
+
15
+ try:
16
+ # Получаем access token
17
+ token_response = await client.get_access_token(
18
+ scope="system.client.read system.client.edit",
19
+ )
20
+
21
+ print(f"Access token: {token_response.access_token[:50]}...")
22
+ print(f"Expires in: {token_response.expires_in} seconds")
23
+ print(f"Scope: {token_response.scope}")
24
+
25
+ # Получаем информацию о текущем пользователе/сервисе
26
+ user_info = await client.get_current_user()
27
+ print(f"\nClient ID: {user_info.id if hasattr(user_info, 'id') else 'N/A'}")
28
+
29
+ # Выполняем запросы с авторизацией
30
+ # Пример: получение списка пользователей (требует админских прав)
31
+ try:
32
+ data = await client.request_with_auth(
33
+ method="GET",
34
+ endpoint="admin/users",
35
+ params={"skip": 0, "limit": 10},
36
+ )
37
+ print(f"\nПолучено пользователей: {len(data) if isinstance(data, list) else 'N/A'}")
38
+ except Exception as e:
39
+ print(f"\nОшибка при запросе: {e}")
40
+
41
+ except Exception as e:
42
+ print(f"Ошибка: {e}")
43
+
44
+ finally:
45
+ await client.close()
46
+
47
+
48
+ if __name__ == "__main__":
49
+ asyncio.run(main())
50
+
@@ -0,0 +1,54 @@
1
+ """Пример использования UserClient"""
2
+
3
+ import asyncio
4
+
5
+ from user_client import UserClient
6
+
7
+
8
+ async def main():
9
+ # Создаем клиент
10
+ client = UserClient(
11
+ base_url="http://localhost:8000",
12
+ client_id="your_client_id",
13
+ redirect_uri="http://localhost:3000/callback",
14
+ )
15
+
16
+ try:
17
+ # Полный цикл авторизации
18
+ token_response = await client.full_auth_flow(
19
+ login="user@example.com",
20
+ password="password123",
21
+ scope="sso.admin.read sso.admin.create",
22
+ )
23
+
24
+ print(f"Access token: {token_response.access_token[:50]}...")
25
+ print(
26
+ f"Refresh token: {token_response.refresh_token[:50] if token_response.refresh_token else None}...")
27
+ print(f"Expires in: {token_response.expires_in} seconds")
28
+
29
+ # Получаем информацию о пользователе
30
+ user_info = await client.get_current_user()
31
+ print(f"\nПользователь: {user_info.name} {user_info.surname}")
32
+ print(f"Email: {user_info.email}")
33
+ print(f"Scopes: {user_info.scopes}")
34
+
35
+ # Обновляем токен
36
+ new_token = await client.refresh_access_token()
37
+ print(
38
+ f"\nНовый access token получен: {new_token.access_token[:50]}...")
39
+
40
+ # Получаем список доступных сервисов
41
+ services = await client.get_available_services()
42
+ print(f"\nДоступно сервисов: {len(services.services)}")
43
+ for service in services.services:
44
+ print(f" - {service.name} ({service.client_id})")
45
+
46
+ except Exception as e:
47
+ print(f"Ошибка: {e}")
48
+
49
+ finally:
50
+ await client.close()
51
+
52
+
53
+ if __name__ == "__main__":
54
+ asyncio.run(main())
sso_nebus/models.py ADDED
@@ -0,0 +1,67 @@
1
+ """Pydantic модели для SSO клиента"""
2
+
3
+ from typing import Optional
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class PKCEParams(BaseModel):
8
+ """PKCE параметры для OAuth 2.0"""
9
+
10
+ code_verifier: str = Field(..., description="Code verifier для PKCE")
11
+ code_challenge: str = Field(..., description="Code challenge для PKCE")
12
+ state: str = Field(..., description="State parameter для CSRF защиты")
13
+
14
+
15
+ class TokenResponse(BaseModel):
16
+ """Ответ с токенами"""
17
+
18
+ access_token: str = Field(..., description="Access token")
19
+ token_type: str = Field(default="Bearer", description="Тип токена")
20
+ expires_in: int = Field(..., description="Время жизни токена в секундах")
21
+ refresh_token: Optional[str] = Field(
22
+ None, description="Refresh token (только для пользователей)")
23
+ scope: Optional[str] = Field(None, description="Разрешения (scopes)")
24
+
25
+
26
+ class UserInfo(BaseModel):
27
+ """Информация о пользователе"""
28
+
29
+ id: int = Field(..., description="ID пользователя")
30
+ email: str = Field(..., description="Email пользователя")
31
+ name: str = Field(..., description="Имя пользователя")
32
+ surname: str = Field(..., description="Фамилия пользователя")
33
+ lastname: Optional[str] = Field(None, description="Отчество пользователя")
34
+ scopes: list[str] = Field(default_factory=list,
35
+ description="Список разрешений пользователя")
36
+
37
+
38
+ class ServiceInfo(BaseModel):
39
+ """Информация о сервисе"""
40
+
41
+ client_id: str = Field(..., description="Уникальный идентификатор клиента")
42
+ name: Optional[str] = Field(None, description="Название сервиса")
43
+
44
+
45
+ class ServicesList(BaseModel):
46
+ """Список доступных сервисов"""
47
+
48
+ services: list[ServiceInfo] = Field(...,
49
+ description="Список всех активных микросервисов")
50
+
51
+
52
+ class AuthorizeResponse(BaseModel):
53
+ """Ответ на запрос авторизации"""
54
+
55
+ session_id: str = Field(...,
56
+ description="ID сессии для последующего логина")
57
+ message: str = Field(..., description="Сообщение для клиента")
58
+
59
+
60
+ class LoginResponse(BaseModel):
61
+ """Ответ на запрос логина"""
62
+
63
+ success: bool = Field(..., description="Успешность операции")
64
+ message: str = Field(..., description="Сообщение")
65
+ authorization_code: str = Field(...,
66
+ description="Authorization code для обмена на токены")
67
+ state: str = Field(..., description="State parameter для проверки")
@@ -0,0 +1,134 @@
1
+ """Клиент для микросервисного взаимодействия (Client Credentials)"""
2
+
3
+ from typing import Optional
4
+
5
+ from base import BaseClient
6
+ from models import TokenResponse, UserInfo
7
+ from exceptions import TokenError
8
+
9
+
10
+ class ServiceClient(BaseClient):
11
+ """Клиент для микросервисного взаимодействия с Client Credentials Grant"""
12
+
13
+ def __init__(
14
+ self,
15
+ base_url: str,
16
+ client_id: str,
17
+ client_secret: str,
18
+ api_version: str = "v1",
19
+ timeout: int = 30,
20
+ session=None,
21
+ ):
22
+ """
23
+ Инициализация клиента для микросервисов
24
+
25
+ Args:
26
+ base_url: Базовый URL API
27
+ client_id: ID микросервиса
28
+ client_secret: Секрет микросервиса
29
+ api_version: Версия API
30
+ timeout: Таймаут запросов
31
+ session: Опциональная aiohttp сессия
32
+ """
33
+ super().__init__(base_url, api_version, timeout, session)
34
+ self.client_id = client_id
35
+ self.client_secret = client_secret
36
+ self._access_token: Optional[str] = None
37
+
38
+ async def get_access_token(self, scope: Optional[str] = None) -> TokenResponse:
39
+ """
40
+ Получить access token используя Client Credentials Grant
41
+
42
+ Args:
43
+ scope: Запрашиваемые разрешения (разделенные пробелом)
44
+
45
+ Returns:
46
+ TokenResponse с access_token
47
+ """
48
+ form_data = {
49
+ "grant_type": "client_credentials",
50
+ "client_id": self.client_id,
51
+ "client_secret": self.client_secret,
52
+ }
53
+
54
+ if scope:
55
+ form_data["scope"] = scope
56
+
57
+ data = await self.post("auth/token", form_data=form_data)
58
+ token_response = TokenResponse(**data)
59
+
60
+ # Сохраняем токен
61
+ self._access_token = token_response.access_token
62
+
63
+ return token_response
64
+
65
+ async def get_current_user(self, access_token: Optional[str] = None) -> UserInfo:
66
+ """
67
+ Получить информацию о текущем пользователе/сервисе
68
+
69
+ Args:
70
+ access_token: Access token (если не указан, используется сохраненный)
71
+
72
+ Returns:
73
+ UserInfo с информацией (для микросервисов может быть ограниченная информация)
74
+ """
75
+ access_token = access_token or self._access_token
76
+
77
+ if not access_token:
78
+ raise TokenError(
79
+ "Access token не найден. Вызовите get_access_token() сначала.")
80
+
81
+ data = await self.get("auth/me", access_token=access_token)
82
+ return UserInfo(**data)
83
+
84
+ def set_access_token(self, access_token: str):
85
+ """
86
+ Установить access token вручную
87
+
88
+ Args:
89
+ access_token: Access token
90
+ """
91
+ self._access_token = access_token
92
+
93
+ def get_token(self) -> Optional[str]:
94
+ """Получить текущий access token"""
95
+ return self._access_token
96
+
97
+ async def request_with_auth(
98
+ self,
99
+ method: str,
100
+ endpoint: str,
101
+ json_data: Optional[dict] = None,
102
+ params: Optional[dict] = None,
103
+ auto_refresh: bool = True,
104
+ ) -> dict:
105
+ """
106
+ Выполнить запрос с автоматической авторизацией
107
+
108
+ Args:
109
+ method: HTTP метод (GET, POST, PUT, DELETE)
110
+ endpoint: API endpoint
111
+ json_data: JSON данные для тела запроса
112
+ params: Query параметры
113
+ auto_refresh: Автоматически получать токен, если его нет
114
+
115
+ Returns:
116
+ Распарсенный JSON ответ
117
+ """
118
+ access_token = self._access_token
119
+
120
+ if not access_token and auto_refresh:
121
+ await self.get_access_token()
122
+ access_token = self._access_token
123
+
124
+ if not access_token:
125
+ raise TokenError(
126
+ "Access token не найден. Вызовите get_access_token() сначала.")
127
+
128
+ return await self._request(
129
+ method=method,
130
+ endpoint=endpoint,
131
+ access_token=access_token,
132
+ json_data=json_data,
133
+ params=params,
134
+ )
@@ -0,0 +1,341 @@
1
+ """Клиент для пользовательского взаимодействия (OAuth 2.0 с PKCE)"""
2
+
3
+ from typing import Optional
4
+ import hashlib
5
+ import base64
6
+ import secrets
7
+
8
+ from base import BaseClient
9
+ from models import (
10
+ PKCEParams,
11
+ TokenResponse,
12
+ UserInfo,
13
+ AuthorizeResponse,
14
+ LoginResponse,
15
+ ServicesList,
16
+ )
17
+ from exceptions import TokenError
18
+
19
+
20
+ class UserClient(BaseClient):
21
+ """Клиент для пользовательского взаимодействия с OAuth 2.0 Authorization Code Flow с PKCE"""
22
+
23
+ def __init__(
24
+ self,
25
+ base_url: str,
26
+ client_id: str,
27
+ redirect_uri: Optional[str] = None,
28
+ api_version: str = "v1",
29
+ timeout: int = 30,
30
+ session=None,
31
+ ):
32
+ """
33
+ Инициализация клиента для пользователей
34
+
35
+ Args:
36
+ base_url: Базовый URL API
37
+ client_id: ID OAuth клиента
38
+ redirect_uri: URI для редиректа (опционально)
39
+ api_version: Версия API
40
+ timeout: Таймаут запросов
41
+ session: Опциональная aiohttp сессия
42
+ """
43
+ super().__init__(base_url, api_version, timeout, session)
44
+ self.client_id = client_id
45
+ self.redirect_uri = redirect_uri
46
+ self._pkce_params: Optional[PKCEParams] = None
47
+ self._access_token: Optional[str] = None
48
+ self._refresh_token: Optional[str] = None
49
+
50
+ @staticmethod
51
+ def _generate_code_verifier() -> str:
52
+ """Генерация code_verifier для PKCE"""
53
+ return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=")
54
+
55
+ @staticmethod
56
+ def _generate_code_challenge(code_verifier: str) -> str:
57
+ """Генерация code_challenge из code_verifier"""
58
+ sha256_hash = hashlib.sha256(code_verifier.encode("utf-8")).digest()
59
+ return base64.urlsafe_b64encode(sha256_hash).decode("utf-8").rstrip("=")
60
+
61
+ @staticmethod
62
+ def _generate_state() -> str:
63
+ """Генерация state для CSRF защиты"""
64
+ return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=")
65
+
66
+ async def get_pkce_params(self) -> PKCEParams:
67
+ """
68
+ Получить PKCE параметры от сервера
69
+
70
+ Returns:
71
+ PKCEParams с code_verifier, code_challenge и state
72
+ """
73
+ data = await self.get("auth/pkce-params")
74
+ self._pkce_params = PKCEParams(**data)
75
+ return self._pkce_params
76
+
77
+ async def authorize(
78
+ self,
79
+ scope: Optional[str] = None,
80
+ redirect_uri: Optional[str] = None,
81
+ pkce_params: Optional[PKCEParams] = None,
82
+ ) -> AuthorizeResponse:
83
+ """
84
+ Инициировать OAuth 2.0 Authorization Code Flow
85
+
86
+ Args:
87
+ scope: Запрашиваемые разрешения (разделенные пробелом)
88
+ redirect_uri: URI для редиректа (если не указан, используется из конструктора)
89
+ pkce_params: PKCE параметры (если не указаны, получаются автоматически)
90
+
91
+ Returns:
92
+ AuthorizeResponse с session_id для последующего логина
93
+ """
94
+ # Получаем PKCE параметры, если не переданы
95
+ if pkce_params is None:
96
+ if self._pkce_params is None:
97
+ pkce_params = await self.get_pkce_params()
98
+ else:
99
+ pkce_params = self._pkce_params
100
+ else:
101
+ self._pkce_params = pkce_params
102
+
103
+ redirect_uri = redirect_uri or self.redirect_uri
104
+
105
+ params = {
106
+ "response_type": "code",
107
+ "client_id": self.client_id,
108
+ "state": pkce_params.state,
109
+ "code_challenge": pkce_params.code_challenge,
110
+ "code_challenge_method": "S256",
111
+ }
112
+
113
+ if redirect_uri:
114
+ params["redirect_uri"] = redirect_uri
115
+
116
+ if scope:
117
+ params["scope"] = scope
118
+
119
+ data = await self.get("auth/authorize", params=params)
120
+ return AuthorizeResponse(**data)
121
+
122
+ async def login(
123
+ self,
124
+ login: str,
125
+ password: str,
126
+ session_id: str,
127
+ ) -> LoginResponse:
128
+ """
129
+ Выполнить аутентификацию пользователя
130
+
131
+ Args:
132
+ login: Логин пользователя
133
+ password: Пароль пользователя
134
+ session_id: ID сессии из метода authorize()
135
+
136
+ Returns:
137
+ LoginResponse с authorization_code
138
+ """
139
+ json_data = {
140
+ "session_id": session_id,
141
+ "login": login,
142
+ "password": password,
143
+ }
144
+
145
+ data = await self.post("auth/login", json_data=json_data)
146
+ return LoginResponse(**data)
147
+
148
+ async def exchange_code_for_tokens(
149
+ self,
150
+ authorization_code: str,
151
+ redirect_uri: Optional[str] = None,
152
+ pkce_params: Optional[PKCEParams] = None,
153
+ ) -> TokenResponse:
154
+ """
155
+ Обменять authorization code на токены
156
+
157
+ Args:
158
+ authorization_code: Authorization code из метода login()
159
+ redirect_uri: Redirect URI (если не указан, используется из конструктора)
160
+ pkce_params: PKCE параметры (если не указаны, используются сохраненные)
161
+
162
+ Returns:
163
+ TokenResponse с access_token и refresh_token
164
+ """
165
+ if pkce_params is None:
166
+ if self._pkce_params is None:
167
+ raise TokenError(
168
+ "PKCE параметры не найдены. Вызовите get_pkce_params() или authorize() сначала.")
169
+ pkce_params = self._pkce_params
170
+
171
+ redirect_uri = redirect_uri or self.redirect_uri
172
+
173
+ form_data = {
174
+ "grant_type": "authorization_code",
175
+ "code": authorization_code,
176
+ "client_id": self.client_id,
177
+ "code_verifier": pkce_params.code_verifier,
178
+ }
179
+
180
+ if redirect_uri:
181
+ form_data["redirect_uri"] = redirect_uri
182
+
183
+ data = await self.post("auth/token", form_data=form_data)
184
+ token_response = TokenResponse(**data)
185
+
186
+ # Сохраняем токены
187
+ self._access_token = token_response.access_token
188
+ self._refresh_token = token_response.refresh_token
189
+
190
+ return token_response
191
+
192
+ async def refresh_access_token(self, refresh_token: Optional[str] = None) -> TokenResponse:
193
+ """
194
+ Обновить access token используя refresh token
195
+
196
+ Args:
197
+ refresh_token: Refresh token (если не указан, используется сохраненный)
198
+
199
+ Returns:
200
+ TokenResponse с новым access_token и refresh_token
201
+ """
202
+ refresh_token = refresh_token or self._refresh_token
203
+
204
+ if not refresh_token:
205
+ raise TokenError(
206
+ "Refresh token не найден. Выполните авторизацию сначала.")
207
+
208
+ form_data = {
209
+ "grant_type": "refresh_token",
210
+ "refresh_token": refresh_token,
211
+ "client_id": self.client_id,
212
+ }
213
+
214
+ data = await self.post("auth/token", form_data=form_data)
215
+ token_response = TokenResponse(**data)
216
+
217
+ # Обновляем токены
218
+ self._access_token = token_response.access_token
219
+ self._refresh_token = token_response.refresh_token
220
+
221
+ return token_response
222
+
223
+ async def get_current_user(self, access_token: Optional[str] = None) -> UserInfo:
224
+ """
225
+ Получить информацию о текущем пользователе
226
+
227
+ Args:
228
+ access_token: Access token (если не указан, используется сохраненный)
229
+
230
+ Returns:
231
+ UserInfo с информацией о пользователе
232
+ """
233
+ access_token = access_token or self._access_token
234
+
235
+ if not access_token:
236
+ raise TokenError(
237
+ "Access token не найден. Выполните авторизацию сначала.")
238
+
239
+ data = await self.get("auth/me", access_token=access_token)
240
+ return UserInfo(**data)
241
+
242
+ async def logout(self, refresh_token: Optional[str] = None) -> dict:
243
+ """
244
+ Выйти из системы и отозвать refresh token
245
+
246
+ Args:
247
+ refresh_token: Refresh token для отзыва (если не указан, используется сохраненный)
248
+
249
+ Returns:
250
+ Словарь с сообщением об успешном выходе
251
+ """
252
+ refresh_token = refresh_token or self._refresh_token
253
+
254
+ if not refresh_token:
255
+ raise TokenError("Refresh token не найден.")
256
+
257
+ form_data = {"refresh_token": refresh_token}
258
+
259
+ data = await self.post("auth/logout", form_data=form_data)
260
+
261
+ # Очищаем токены
262
+ self._access_token = None
263
+ self._refresh_token = None
264
+ self._pkce_params = None
265
+
266
+ return data
267
+
268
+ async def get_available_services(self) -> ServicesList:
269
+ """
270
+ Получить список всех доступных микросервисов
271
+
272
+ Returns:
273
+ ServicesList со списком активных микросервисов
274
+ """
275
+ data = await self.get("auth/services")
276
+ return ServicesList(**data)
277
+
278
+ def get_access_token(self) -> Optional[str]:
279
+ """Получить текущий access token"""
280
+ return self._access_token
281
+
282
+ def get_refresh_token(self) -> Optional[str]:
283
+ """Получить текущий refresh token"""
284
+ return self._refresh_token
285
+
286
+ def set_tokens(self, access_token: str, refresh_token: Optional[str] = None):
287
+ """
288
+ Установить токены вручную
289
+
290
+ Args:
291
+ access_token: Access token
292
+ refresh_token: Refresh token (опционально)
293
+ """
294
+ self._access_token = access_token
295
+ if refresh_token:
296
+ self._refresh_token = refresh_token
297
+
298
+ async def full_auth_flow(
299
+ self,
300
+ login: str,
301
+ password: str,
302
+ scope: Optional[str] = None,
303
+ redirect_uri: Optional[str] = None,
304
+ ) -> TokenResponse:
305
+ """
306
+ Выполнить полный цикл авторизации (удобный метод)
307
+
308
+ Args:
309
+ login: Логин пользователя
310
+ password: Пароль пользователя
311
+ scope: Запрашиваемые разрешения
312
+ redirect_uri: URI для редиректа
313
+
314
+ Returns:
315
+ TokenResponse с токенами
316
+ """
317
+ # 1. Получаем PKCE параметры
318
+ pkce_params = await self.get_pkce_params()
319
+
320
+ # 2. Инициируем авторизацию
321
+ auth_response = await self.authorize(
322
+ scope=scope,
323
+ redirect_uri=redirect_uri,
324
+ pkce_params=pkce_params,
325
+ )
326
+
327
+ # 3. Выполняем логин
328
+ login_response = await self.login(
329
+ login=login,
330
+ password=password,
331
+ session_id=auth_response.session_id,
332
+ )
333
+
334
+ # 4. Обмениваем код на токены
335
+ token_response = await self.exchange_code_for_tokens(
336
+ authorization_code=login_response.authorization_code,
337
+ redirect_uri=redirect_uri,
338
+ pkce_params=pkce_params,
339
+ )
340
+
341
+ return token_response
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.4
2
+ Name: sso-nebus
3
+ Version: 0.1.0
4
+ Summary: Python клиент для взаимодействия с MS Auth Service API
5
+ License: LICENSE
6
+ License-File: LICENSE
7
+ Author: Артем Костюченко
8
+ Author-email: kostyuchenko.work@gmail.com
9
+ Requires-Python: >=3.12
10
+ Classifier: License :: Other/Proprietary License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: aiohttp (>=3.13.2,<4.0.0)
16
+ Requires-Dist: pydantic (>=2.12.5,<3.0.0)
17
+ Description-Content-Type: text/markdown
18
+
19
+ # SSO Nebus Client
20
+
21
+ Python клиент для взаимодействия с MS Auth Service API. Предоставляет удобные классы для работы с OAuth 2.0 Authorization Code Flow с PKCE (для пользователей) и Client Credentials Grant (для микросервисов).
22
+
23
+ ## Установка
24
+
25
+ ```bash
26
+ pip install -e .
27
+ ```
28
+
29
+ Или если пакет опубликован:
30
+
31
+ ```bash
32
+ pip install sso-nebus-client
33
+ ```
34
+
35
+ ## Быстрый старт
36
+
37
+ ### Для пользователей (UserClient)
38
+
39
+ ```python
40
+ import asyncio
41
+ from sso_client import UserClient
42
+
43
+ async def main():
44
+ # Создаем клиент
45
+ client = UserClient(
46
+ base_url="http://localhost:8000",
47
+ client_id="your_client_id",
48
+ redirect_uri="http://localhost:3000/callback"
49
+ )
50
+
51
+ # Полный цикл авторизации
52
+ token_response = await client.full_auth_flow(
53
+ login="user@example.com",
54
+ password="password123",
55
+ scope="sso.admin.read sso.admin.create"
56
+ )
57
+
58
+ print(f"Access token: {token_response.access_token}")
59
+ print(f"Refresh token: {token_response.refresh_token}")
60
+
61
+ # Получаем информацию о пользователе
62
+ user_info = await client.get_current_user()
63
+ print(f"Пользователь: {user_info.name} {user_info.surname}")
64
+
65
+ # Обновляем токен
66
+ new_token = await client.refresh_access_token()
67
+ print(f"Новый access token: {new_token.access_token}")
68
+
69
+ await client.close()
70
+
71
+ asyncio.run(main())
72
+ ```
73
+
74
+ ### Пошаговая авторизация
75
+
76
+ ```python
77
+ import asyncio
78
+ from sso_client import UserClient
79
+
80
+ async def main():
81
+ client = UserClient(
82
+ base_url="http://localhost:8000",
83
+ client_id="your_client_id"
84
+ )
85
+
86
+ # 1. Получаем PKCE параметры
87
+ pkce_params = await client.get_pkce_params()
88
+
89
+ # 2. Инициируем авторизацию
90
+ auth_response = await client.authorize(
91
+ scope="sso.admin.read",
92
+ pkce_params=pkce_params
93
+ )
94
+
95
+ # 3. Выполняем логин
96
+ login_response = await client.login(
97
+ login="user@example.com",
98
+ password="password123",
99
+ session_id=auth_response.session_id
100
+ )
101
+
102
+ # 4. Обмениваем код на токены
103
+ token_response = await client.exchange_code_for_tokens(
104
+ authorization_code=login_response.authorization_code,
105
+ pkce_params=pkce_params
106
+ )
107
+
108
+ print(f"Токены получены: {token_response.access_token}")
109
+
110
+ await client.close()
111
+
112
+ asyncio.run(main())
113
+ ```
114
+
115
+ ### Для микросервисов (ServiceClient)
116
+
117
+ ```python
118
+ import asyncio
119
+ from sso_client import ServiceClient
120
+
121
+ async def main():
122
+ # Создаем клиент для микросервиса
123
+ client = ServiceClient(
124
+ base_url="http://localhost:8000",
125
+ client_id="service_id",
126
+ client_secret="service_secret"
127
+ )
128
+
129
+ # Получаем access token
130
+ token_response = await client.get_access_token(
131
+ scope="system.client.read system.client.edit"
132
+ )
133
+
134
+ print(f"Access token: {token_response.access_token}")
135
+
136
+ # Выполняем запросы с авторизацией
137
+ user_info = await client.get_current_user()
138
+
139
+ # Или используем request_with_auth для любых endpoints
140
+ data = await client.request_with_auth(
141
+ method="GET",
142
+ endpoint="admin/users",
143
+ params={"skip": 0, "limit": 10}
144
+ )
145
+
146
+ await client.close()
147
+
148
+ asyncio.run(main())
149
+ ```
150
+
151
+ ## Использование с async context manager
152
+
153
+ ```python
154
+ import asyncio
155
+ from sso_client import UserClient
156
+
157
+ async def main():
158
+ async with UserClient(
159
+ base_url="http://localhost:8000",
160
+ client_id="your_client_id"
161
+ ) as client:
162
+ token_response = await client.full_auth_flow(
163
+ login="user@example.com",
164
+ password="password123"
165
+ )
166
+ # Сессия автоматически закроется при выходе
167
+
168
+ asyncio.run(main())
169
+ ```
170
+
171
+ ## API Reference
172
+
173
+ ### UserClient
174
+
175
+ Класс для пользовательского взаимодействия с OAuth 2.0 Authorization Code Flow с PKCE.
176
+
177
+ #### Методы
178
+
179
+ - `get_pkce_params()` - Получить PKCE параметры от сервера
180
+ - `authorize(scope, redirect_uri, pkce_params)` - Инициировать OAuth 2.0 flow
181
+ - `login(login, password, session_id)` - Выполнить аутентификацию
182
+ - `exchange_code_for_tokens(authorization_code, redirect_uri, pkce_params)` - Обменять код на токены
183
+ - `refresh_access_token(refresh_token)` - Обновить access token
184
+ - `get_current_user(access_token)` - Получить информацию о пользователе
185
+ - `logout(refresh_token)` - Выйти из системы
186
+ - `get_available_services()` - Получить список доступных микросервисов
187
+ - `full_auth_flow(login, password, scope, redirect_uri)` - Выполнить полный цикл авторизации
188
+
189
+ ### ServiceClient
190
+
191
+ Класс для микросервисного взаимодействия с Client Credentials Grant.
192
+
193
+ #### Методы
194
+
195
+ - `get_access_token(scope)` - Получить access token
196
+ - `get_current_user(access_token)` - Получить информацию о текущем пользователе/сервисе
197
+ - `request_with_auth(method, endpoint, json_data, params, auto_refresh)` - Выполнить запрос с авторизацией
198
+
199
+ ## Исключения
200
+
201
+ - `SSOClientError` - Базовое исключение
202
+ - `AuthenticationError` - Ошибка аутентификации (401)
203
+ - `AuthorizationError` - Ошибка авторизации (403)
204
+ - `APIError` - Ошибка API (4xx, 5xx)
205
+ - `TokenError` - Ошибка работы с токенами
206
+
207
+ ## Лицензия
208
+
209
+ MIT
210
+
211
+
@@ -0,0 +1,12 @@
1
+ sso_nebus/__init__.py,sha256=tmKUfBVcL4-AzsschcI2nFX9eWnnZfJ4SxlMSBvmF7o,869
2
+ sso_nebus/base.py,sha256=DssaeMXU9kNyIiF4WllvLyLGwqSwKSXMd_rrjGoPHGc,7522
3
+ sso_nebus/exceptions.py,sha256=XHrvfGGvs3eKMzkpwRxXyDngjpMSp7X7E8EUXwv26NA,1262
4
+ sso_nebus/exmples/example_service.py,sha256=19NNTmbaRSaWLpDQffGWNDmXkDJXdLOgD2WkV32xUu8,1778
5
+ sso_nebus/exmples/example_user.py,sha256=HXVV6FZTrnHwKoD_9OlXXlRJ9PsL9FL36bYSK7ztSNo,1883
6
+ sso_nebus/models.py,sha256=0xIE5RvnxV2sAxO7_GTCes6HlHbdzGwHIjWVe1X7-ks,3024
7
+ sso_nebus/service_client.py,sha256=gO6oyvu8x-r7B-T5lD65gybaQY-Ze1DYZbOXRkH-WTA,4704
8
+ sso_nebus/user_client.py,sha256=X4jxWf_XeKDAlw_we552kElyIrdKf3WXgl7QTqroPrM,12269
9
+ sso_nebus-0.1.0.dist-info/licenses/LICENSE,sha256=dCbOm3zpH8T7vLDC2K7QJLu-LEl2zqaSuyARbqfGsEY,1863
10
+ sso_nebus-0.1.0.dist-info/METADATA,sha256=BocwVBRAyPSSXCh7vdHymr368CvEsBLy5HMN7v4Wa_I,6656
11
+ sso_nebus-0.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
12
+ sso_nebus-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,11 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Артём
4
+
5
+ Данный код лицензируется на условиях MIT License. См. полный текст лицензии ниже.
6
+
7
+ Разрешается использование, копирование, изменение, слияние, публикация, распространение, сублицензирование и/или продажа копий данного программного обеспечения, а также разрешение лицам, которым предоставляется данный программный продукт, делать это при соблюдении следующих условий:
8
+
9
+ Уведомление об авторских правах и данном разрешении должны быть включены во все копии или значительные части данного программного обеспечения.
10
+
11
+ Данный программный продукт предоставляется "как есть", без каких-либо гарантий, явных или подразумеваемых, включая, помимо прочего, гарантии товарной пригодности и пригодности для определенной цели. В любом случае автор(ы) или держатель авторских прав не несут ответственности за любые претензии, убытки или другие обязательства, будь то в действии контракта, деликте или других обстоятельствах, возникающие из или в связи с использованием данного программного обеспечения.