sso-nebus 0.1.0__py3-none-any.whl → 0.1.2__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 +3 -3
- sso_nebus/base.py +420 -6
- sso_nebus/exmples/example_admin.py +53 -0
- sso_nebus/exmples/example_user.py +2 -2
- sso_nebus/service_client.py +17 -4
- sso_nebus/user_client.py +24 -12
- {sso_nebus-0.1.0.dist-info → sso_nebus-0.1.2.dist-info}/METADATA +50 -2
- sso_nebus-0.1.2.dist-info/RECORD +13 -0
- sso_nebus-0.1.0.dist-info/RECORD +0 -12
- {sso_nebus-0.1.0.dist-info → sso_nebus-0.1.2.dist-info}/WHEEL +0 -0
- {sso_nebus-0.1.0.dist-info → sso_nebus-0.1.2.dist-info}/licenses/LICENSE +0 -0
sso_nebus/__init__.py
CHANGED
|
@@ -6,9 +6,9 @@ SSO Nebus Client - Python клиент для взаимодействия с MS
|
|
|
6
6
|
- ServiceClient: для микросервисного взаимодействия (Client Credentials)
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
from user_client import UserClient
|
|
10
|
-
from service_client import ServiceClient
|
|
11
|
-
from exceptions import (
|
|
9
|
+
from .user_client import UserClient
|
|
10
|
+
from .service_client import ServiceClient
|
|
11
|
+
from .exceptions import (
|
|
12
12
|
SSOClientError,
|
|
13
13
|
AuthenticationError,
|
|
14
14
|
AuthorizationError,
|
sso_nebus/base.py
CHANGED
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
from typing import Optional, Dict, Any
|
|
4
4
|
from urllib.parse import urljoin
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
5
6
|
|
|
6
7
|
import aiohttp
|
|
7
8
|
from aiohttp import ClientSession, ClientResponse
|
|
8
9
|
|
|
9
|
-
from exceptions import (
|
|
10
|
+
from .exceptions import (
|
|
10
11
|
APIError,
|
|
11
12
|
AuthenticationError,
|
|
12
13
|
AuthorizationError,
|
|
14
|
+
TokenError,
|
|
13
15
|
)
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
class BaseClient:
|
|
18
|
+
class BaseClient(ABC):
|
|
17
19
|
"""Базовый класс для всех SSO клиентов"""
|
|
18
20
|
|
|
19
21
|
def __init__(
|
|
@@ -22,6 +24,7 @@ class BaseClient:
|
|
|
22
24
|
api_version: str = "v1",
|
|
23
25
|
timeout: int = 30,
|
|
24
26
|
session: Optional[ClientSession] = None,
|
|
27
|
+
auto_refresh_token: bool = True,
|
|
25
28
|
):
|
|
26
29
|
"""
|
|
27
30
|
Инициализация базового клиента
|
|
@@ -31,12 +34,15 @@ class BaseClient:
|
|
|
31
34
|
api_version: Версия API (по умолчанию "v1")
|
|
32
35
|
timeout: Таймаут запросов в секундах
|
|
33
36
|
session: Опциональная aiohttp сессия (если не указана, создается новая)
|
|
37
|
+
auto_refresh_token: Автоматически обновлять токен при получении 401 ошибки
|
|
34
38
|
"""
|
|
35
39
|
self.base_url = base_url.rstrip("/")
|
|
36
40
|
self.api_version = api_version
|
|
37
41
|
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
|
38
42
|
self._session = session
|
|
39
43
|
self._own_session = session is None
|
|
44
|
+
self.auto_refresh_token = auto_refresh_token
|
|
45
|
+
self._refreshing = False # Флаг для предотвращения рекурсивных обновлений
|
|
40
46
|
|
|
41
47
|
@property
|
|
42
48
|
def api_base_url(self) -> str:
|
|
@@ -80,6 +86,21 @@ class BaseClient:
|
|
|
80
86
|
headers["Authorization"] = f"Bearer {access_token}"
|
|
81
87
|
return headers
|
|
82
88
|
|
|
89
|
+
@abstractmethod
|
|
90
|
+
async def _refresh_token(self) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Абстрактный метод для обновления токена.
|
|
93
|
+
Должен быть реализован в дочерних классах.
|
|
94
|
+
"""
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def _get_access_token(self) -> Optional[str]:
|
|
98
|
+
"""
|
|
99
|
+
Получить текущий access token.
|
|
100
|
+
Должен быть переопределен в дочерних классах.
|
|
101
|
+
"""
|
|
102
|
+
return None
|
|
103
|
+
|
|
83
104
|
async def _handle_response(self, response: ClientResponse) -> Dict[str, Any]:
|
|
84
105
|
"""
|
|
85
106
|
Обработать HTTP ответ
|
|
@@ -125,9 +146,10 @@ class BaseClient:
|
|
|
125
146
|
json_data: Optional[Dict[str, Any]] = None,
|
|
126
147
|
form_data: Optional[Dict[str, Any]] = None,
|
|
127
148
|
params: Optional[Dict[str, Any]] = None,
|
|
149
|
+
retry_on_401: bool = True,
|
|
128
150
|
) -> Dict[str, Any]:
|
|
129
151
|
"""
|
|
130
|
-
Выполнить HTTP запрос
|
|
152
|
+
Выполнить HTTP запрос с автоматическим обновлением токена при 401
|
|
131
153
|
|
|
132
154
|
Args:
|
|
133
155
|
method: HTTP метод (GET, POST, etc.)
|
|
@@ -136,10 +158,15 @@ class BaseClient:
|
|
|
136
158
|
json_data: JSON данные для тела запроса
|
|
137
159
|
form_data: Form data для тела запроса
|
|
138
160
|
params: Query параметры
|
|
161
|
+
retry_on_401: Повторить запрос после обновления токена при 401
|
|
139
162
|
|
|
140
163
|
Returns:
|
|
141
164
|
Распарсенный JSON ответ
|
|
142
165
|
"""
|
|
166
|
+
# Используем сохраненный токен, если не передан явно
|
|
167
|
+
if access_token is None:
|
|
168
|
+
access_token = self._get_access_token()
|
|
169
|
+
|
|
143
170
|
url = self._build_url(endpoint)
|
|
144
171
|
headers = self._get_headers(access_token)
|
|
145
172
|
|
|
@@ -155,6 +182,40 @@ class BaseClient:
|
|
|
155
182
|
data=form_data,
|
|
156
183
|
params=params,
|
|
157
184
|
) as response:
|
|
185
|
+
# Если получили 401 и включен авто-рефреш, пытаемся обновить токен
|
|
186
|
+
if (
|
|
187
|
+
response.status == 401
|
|
188
|
+
and self.auto_refresh_token
|
|
189
|
+
and retry_on_401
|
|
190
|
+
and not self._refreshing
|
|
191
|
+
and access_token
|
|
192
|
+
):
|
|
193
|
+
try:
|
|
194
|
+
# Пытаемся обновить токен
|
|
195
|
+
self._refreshing = True
|
|
196
|
+
await self._refresh_token()
|
|
197
|
+
# Получаем новый токен
|
|
198
|
+
new_token = self._get_access_token()
|
|
199
|
+
if new_token and new_token != access_token:
|
|
200
|
+
# Повторяем запрос с новым токеном
|
|
201
|
+
headers = self._get_headers(new_token)
|
|
202
|
+
if form_data:
|
|
203
|
+
headers.pop("Content-Type", None)
|
|
204
|
+
async with self.session.request(
|
|
205
|
+
method=method,
|
|
206
|
+
url=url,
|
|
207
|
+
headers=headers,
|
|
208
|
+
json=json_data,
|
|
209
|
+
data=form_data,
|
|
210
|
+
params=params,
|
|
211
|
+
) as retry_response:
|
|
212
|
+
return await self._handle_response(retry_response)
|
|
213
|
+
except Exception:
|
|
214
|
+
# Если обновление не удалось, пробрасываем оригинальную ошибку
|
|
215
|
+
pass
|
|
216
|
+
finally:
|
|
217
|
+
self._refreshing = False
|
|
218
|
+
|
|
158
219
|
return await self._handle_response(response)
|
|
159
220
|
|
|
160
221
|
async def get(
|
|
@@ -162,9 +223,16 @@ class BaseClient:
|
|
|
162
223
|
endpoint: str,
|
|
163
224
|
access_token: Optional[str] = None,
|
|
164
225
|
params: Optional[Dict[str, Any]] = None,
|
|
226
|
+
use_auto_refresh: bool = True,
|
|
165
227
|
) -> Dict[str, Any]:
|
|
166
228
|
"""Выполнить GET запрос"""
|
|
167
|
-
return await self._request(
|
|
229
|
+
return await self._request(
|
|
230
|
+
"GET",
|
|
231
|
+
endpoint,
|
|
232
|
+
access_token=access_token,
|
|
233
|
+
params=params,
|
|
234
|
+
retry_on_401=use_auto_refresh,
|
|
235
|
+
)
|
|
168
236
|
|
|
169
237
|
async def post(
|
|
170
238
|
self,
|
|
@@ -173,6 +241,7 @@ class BaseClient:
|
|
|
173
241
|
json_data: Optional[Dict[str, Any]] = None,
|
|
174
242
|
form_data: Optional[Dict[str, Any]] = None,
|
|
175
243
|
params: Optional[Dict[str, Any]] = None,
|
|
244
|
+
use_auto_refresh: bool = True,
|
|
176
245
|
) -> Dict[str, Any]:
|
|
177
246
|
"""Выполнить POST запрос"""
|
|
178
247
|
return await self._request(
|
|
@@ -182,6 +251,7 @@ class BaseClient:
|
|
|
182
251
|
json_data=json_data,
|
|
183
252
|
form_data=form_data,
|
|
184
253
|
params=params,
|
|
254
|
+
retry_on_401=use_auto_refresh,
|
|
185
255
|
)
|
|
186
256
|
|
|
187
257
|
async def put(
|
|
@@ -190,15 +260,359 @@ class BaseClient:
|
|
|
190
260
|
access_token: Optional[str] = None,
|
|
191
261
|
json_data: Optional[Dict[str, Any]] = None,
|
|
192
262
|
params: Optional[Dict[str, Any]] = None,
|
|
263
|
+
use_auto_refresh: bool = True,
|
|
193
264
|
) -> Dict[str, Any]:
|
|
194
265
|
"""Выполнить PUT запрос"""
|
|
195
|
-
return await self._request(
|
|
266
|
+
return await self._request(
|
|
267
|
+
"PUT",
|
|
268
|
+
endpoint,
|
|
269
|
+
access_token=access_token,
|
|
270
|
+
json_data=json_data,
|
|
271
|
+
params=params,
|
|
272
|
+
retry_on_401=use_auto_refresh,
|
|
273
|
+
)
|
|
196
274
|
|
|
197
275
|
async def delete(
|
|
198
276
|
self,
|
|
199
277
|
endpoint: str,
|
|
200
278
|
access_token: Optional[str] = None,
|
|
201
279
|
params: Optional[Dict[str, Any]] = None,
|
|
280
|
+
use_auto_refresh: bool = True,
|
|
202
281
|
) -> Dict[str, Any]:
|
|
203
282
|
"""Выполнить DELETE запрос"""
|
|
204
|
-
return await self._request(
|
|
283
|
+
return await self._request(
|
|
284
|
+
"DELETE",
|
|
285
|
+
endpoint,
|
|
286
|
+
access_token=access_token,
|
|
287
|
+
params=params,
|
|
288
|
+
retry_on_401=use_auto_refresh,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# ========== Админские методы ==========
|
|
292
|
+
|
|
293
|
+
# Пользователи
|
|
294
|
+
async def create_user(
|
|
295
|
+
self,
|
|
296
|
+
login: str,
|
|
297
|
+
email: str,
|
|
298
|
+
password: str,
|
|
299
|
+
name: str,
|
|
300
|
+
surname: str,
|
|
301
|
+
lastname: Optional[str] = None,
|
|
302
|
+
access_token: Optional[str] = None,
|
|
303
|
+
) -> Dict[str, Any]:
|
|
304
|
+
"""Создать нового пользователя (требует sso.admin.create)"""
|
|
305
|
+
json_data = {
|
|
306
|
+
"login": login,
|
|
307
|
+
"email": email,
|
|
308
|
+
"password": password,
|
|
309
|
+
"name": name,
|
|
310
|
+
"surname": surname,
|
|
311
|
+
}
|
|
312
|
+
if lastname:
|
|
313
|
+
json_data["lastname"] = lastname
|
|
314
|
+
return await self.post("admin/users", access_token=access_token, json_data=json_data)
|
|
315
|
+
|
|
316
|
+
async def get_users(
|
|
317
|
+
self,
|
|
318
|
+
skip: int = 0,
|
|
319
|
+
limit: int = 100,
|
|
320
|
+
access_token: Optional[str] = None,
|
|
321
|
+
) -> Dict[str, Any]:
|
|
322
|
+
"""Получить список пользователей (требует sso.admin.read)"""
|
|
323
|
+
return await self.get(
|
|
324
|
+
"admin/users",
|
|
325
|
+
access_token=access_token,
|
|
326
|
+
params={"skip": skip, "limit": limit},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def get_user(self, user_id: int, access_token: Optional[str] = None) -> Dict[str, Any]:
|
|
330
|
+
"""Получить пользователя по ID (требует sso.admin.read)"""
|
|
331
|
+
return await self.get(f"admin/users/{user_id}", access_token=access_token)
|
|
332
|
+
|
|
333
|
+
async def update_user(
|
|
334
|
+
self,
|
|
335
|
+
user_id: int,
|
|
336
|
+
access_token: Optional[str] = None,
|
|
337
|
+
**kwargs,
|
|
338
|
+
) -> Dict[str, Any]:
|
|
339
|
+
"""Обновить пользователя (требует sso.admin.edit)"""
|
|
340
|
+
return await self.put(f"admin/users/{user_id}", access_token=access_token, json_data=kwargs)
|
|
341
|
+
|
|
342
|
+
async def delete_user(self, user_id: int, access_token: Optional[str] = None) -> Dict[str, Any]:
|
|
343
|
+
"""Удалить пользователя (требует sso.admin.delete)"""
|
|
344
|
+
return await self.delete(f"admin/users/{user_id}", access_token=access_token)
|
|
345
|
+
|
|
346
|
+
# Роли
|
|
347
|
+
async def create_role(
|
|
348
|
+
self,
|
|
349
|
+
name: str,
|
|
350
|
+
display_name: str,
|
|
351
|
+
description: Optional[str] = None,
|
|
352
|
+
client_id: Optional[str] = None,
|
|
353
|
+
access_token: Optional[str] = None,
|
|
354
|
+
) -> Dict[str, Any]:
|
|
355
|
+
"""Создать новую роль (требует sso.admin.create)"""
|
|
356
|
+
json_data = {"name": name, "display_name": display_name}
|
|
357
|
+
if description:
|
|
358
|
+
json_data["description"] = description
|
|
359
|
+
if client_id:
|
|
360
|
+
json_data["client_id"] = client_id
|
|
361
|
+
return await self.post("admin/roles", access_token=access_token, json_data=json_data)
|
|
362
|
+
|
|
363
|
+
async def get_roles(
|
|
364
|
+
self,
|
|
365
|
+
skip: int = 0,
|
|
366
|
+
limit: int = 100,
|
|
367
|
+
client_id: Optional[str] = None,
|
|
368
|
+
access_token: Optional[str] = None,
|
|
369
|
+
) -> Dict[str, Any]:
|
|
370
|
+
"""Получить список ролей (требует sso.admin.read)"""
|
|
371
|
+
params = {"skip": skip, "limit": limit}
|
|
372
|
+
if client_id:
|
|
373
|
+
params["client_id"] = client_id
|
|
374
|
+
return await self.get("admin/roles", access_token=access_token, params=params)
|
|
375
|
+
|
|
376
|
+
async def get_role(self, role_id: int, access_token: Optional[str] = None) -> Dict[str, Any]:
|
|
377
|
+
"""Получить роль по ID (требует sso.admin.read)"""
|
|
378
|
+
return await self.get(f"admin/roles/{role_id}", access_token=access_token)
|
|
379
|
+
|
|
380
|
+
async def update_role(
|
|
381
|
+
self,
|
|
382
|
+
role_id: int,
|
|
383
|
+
access_token: Optional[str] = None,
|
|
384
|
+
**kwargs,
|
|
385
|
+
) -> Dict[str, Any]:
|
|
386
|
+
"""Обновить роль (требует sso.admin.edit)"""
|
|
387
|
+
return await self.put(f"admin/roles/{role_id}", access_token=access_token, json_data=kwargs)
|
|
388
|
+
|
|
389
|
+
async def delete_role(self, role_id: int, access_token: Optional[str] = None) -> Dict[str, Any]:
|
|
390
|
+
"""Удалить роль (требует sso.admin.delete)"""
|
|
391
|
+
return await self.delete(f"admin/roles/{role_id}", access_token=access_token)
|
|
392
|
+
|
|
393
|
+
# Разрешения (Scopes)
|
|
394
|
+
async def create_scope(
|
|
395
|
+
self,
|
|
396
|
+
name: str,
|
|
397
|
+
service_name: str,
|
|
398
|
+
resource: str,
|
|
399
|
+
action: str,
|
|
400
|
+
description: Optional[str] = None,
|
|
401
|
+
access_token: Optional[str] = None,
|
|
402
|
+
) -> Dict[str, Any]:
|
|
403
|
+
"""Создать новое разрешение (требует sso.admin.create)"""
|
|
404
|
+
json_data = {
|
|
405
|
+
"name": name,
|
|
406
|
+
"service_name": service_name,
|
|
407
|
+
"resource": resource,
|
|
408
|
+
"action": action,
|
|
409
|
+
}
|
|
410
|
+
if description:
|
|
411
|
+
json_data["description"] = description
|
|
412
|
+
return await self.post("admin/scopes", access_token=access_token, json_data=json_data)
|
|
413
|
+
|
|
414
|
+
async def get_scopes(
|
|
415
|
+
self,
|
|
416
|
+
skip: int = 0,
|
|
417
|
+
limit: int = 100,
|
|
418
|
+
access_token: Optional[str] = None,
|
|
419
|
+
) -> Dict[str, Any]:
|
|
420
|
+
"""Получить список разрешений (требует sso.admin.read)"""
|
|
421
|
+
return await self.get(
|
|
422
|
+
"admin/scopes",
|
|
423
|
+
access_token=access_token,
|
|
424
|
+
params={"skip": skip, "limit": limit},
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
async def get_scope(self, scope_id: int, access_token: Optional[str] = None) -> Dict[str, Any]:
|
|
428
|
+
"""Получить разрешение по ID (требует sso.admin.read)"""
|
|
429
|
+
return await self.get(f"admin/scopes/{scope_id}", access_token=access_token)
|
|
430
|
+
|
|
431
|
+
async def update_scope(
|
|
432
|
+
self,
|
|
433
|
+
scope_id: int,
|
|
434
|
+
access_token: Optional[str] = None,
|
|
435
|
+
**kwargs,
|
|
436
|
+
) -> Dict[str, Any]:
|
|
437
|
+
"""Обновить разрешение (требует sso.admin.edit)"""
|
|
438
|
+
return await self.put(f"admin/scopes/{scope_id}", access_token=access_token, json_data=kwargs)
|
|
439
|
+
|
|
440
|
+
async def delete_scope(self, scope_id: int, access_token: Optional[str] = None) -> Dict[str, Any]:
|
|
441
|
+
"""Удалить разрешение (требует sso.admin.delete)"""
|
|
442
|
+
return await self.delete(f"admin/scopes/{scope_id}", access_token=access_token)
|
|
443
|
+
|
|
444
|
+
# Микросервисы (Clients)
|
|
445
|
+
async def create_client(
|
|
446
|
+
self,
|
|
447
|
+
service_name: str,
|
|
448
|
+
access_token: Optional[str] = None,
|
|
449
|
+
) -> Dict[str, Any]:
|
|
450
|
+
"""Создать новый микросервис (требует sso.admin.create)"""
|
|
451
|
+
return await self.post("admin/clients", access_token=access_token, json_data={"service_name": service_name})
|
|
452
|
+
|
|
453
|
+
async def get_clients(
|
|
454
|
+
self,
|
|
455
|
+
skip: int = 0,
|
|
456
|
+
limit: int = 100,
|
|
457
|
+
access_token: Optional[str] = None,
|
|
458
|
+
) -> Dict[str, Any]:
|
|
459
|
+
"""Получить список клиентов (требует sso.admin.read)"""
|
|
460
|
+
return await self.get(
|
|
461
|
+
"admin/clients",
|
|
462
|
+
access_token=access_token,
|
|
463
|
+
params={"skip": skip, "limit": limit},
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
async def get_client(self, client_id: str, access_token: Optional[str] = None) -> Dict[str, Any]:
|
|
467
|
+
"""Получить клиента по ID (требует sso.admin.read)"""
|
|
468
|
+
return await self.get(f"admin/clients/{client_id}", access_token=access_token)
|
|
469
|
+
|
|
470
|
+
async def update_client(
|
|
471
|
+
self,
|
|
472
|
+
client_id: str,
|
|
473
|
+
access_token: Optional[str] = None,
|
|
474
|
+
**kwargs,
|
|
475
|
+
) -> Dict[str, Any]:
|
|
476
|
+
"""Обновить клиента (требует sso.admin.edit)"""
|
|
477
|
+
return await self.put(f"admin/clients/{client_id}", access_token=access_token, json_data=kwargs)
|
|
478
|
+
|
|
479
|
+
async def assign_scopes_to_client(
|
|
480
|
+
self,
|
|
481
|
+
client_id: str,
|
|
482
|
+
scope_ids: list[int],
|
|
483
|
+
access_token: Optional[str] = None,
|
|
484
|
+
) -> Dict[str, Any]:
|
|
485
|
+
"""Назначить разрешения клиенту (требует sso.admin.edit)"""
|
|
486
|
+
return await self.post(
|
|
487
|
+
f"admin/clients/{client_id}/scopes",
|
|
488
|
+
access_token=access_token,
|
|
489
|
+
json_data={"scope_ids": scope_ids},
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
async def rotate_client_secret(
|
|
493
|
+
self,
|
|
494
|
+
client_id: str,
|
|
495
|
+
access_token: Optional[str] = None,
|
|
496
|
+
) -> Dict[str, Any]:
|
|
497
|
+
"""Ротация client_secret (требует sso.admin.edit)"""
|
|
498
|
+
return await self.post(f"admin/clients/{client_id}/rotate-secret", access_token=access_token)
|
|
499
|
+
|
|
500
|
+
# Назначение ролей пользователям
|
|
501
|
+
async def assign_role_to_user(
|
|
502
|
+
self,
|
|
503
|
+
user_id: int,
|
|
504
|
+
role_id: int,
|
|
505
|
+
access_token: Optional[str] = None,
|
|
506
|
+
) -> Dict[str, Any]:
|
|
507
|
+
"""Назначить роль пользователю (требует sso.admin.create)"""
|
|
508
|
+
return await self.post(
|
|
509
|
+
f"admin/user-roles/{user_id}/roles",
|
|
510
|
+
access_token=access_token,
|
|
511
|
+
json_data={"role_id": role_id},
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
async def revoke_role_from_user(
|
|
515
|
+
self,
|
|
516
|
+
user_id: int,
|
|
517
|
+
role_id: int,
|
|
518
|
+
access_token: Optional[str] = None,
|
|
519
|
+
) -> Dict[str, Any]:
|
|
520
|
+
"""Отозвать роль у пользователя (требует sso.admin.delete)"""
|
|
521
|
+
return await self.delete(
|
|
522
|
+
f"admin/user-roles/{user_id}/roles/{role_id}",
|
|
523
|
+
access_token=access_token,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
async def get_user_roles(
|
|
527
|
+
self,
|
|
528
|
+
user_id: int,
|
|
529
|
+
access_token: Optional[str] = None,
|
|
530
|
+
) -> Dict[str, Any]:
|
|
531
|
+
"""Получить роли пользователя (требует sso.admin.read)"""
|
|
532
|
+
return await self.get(f"admin/user-roles/{user_id}/roles", access_token=access_token)
|
|
533
|
+
|
|
534
|
+
async def get_user_scopes(
|
|
535
|
+
self,
|
|
536
|
+
user_id: int,
|
|
537
|
+
access_token: Optional[str] = None,
|
|
538
|
+
) -> Dict[str, Any]:
|
|
539
|
+
"""Получить разрешения пользователя (требует sso.admin.read)"""
|
|
540
|
+
return await self.get(f"admin/user-roles/{user_id}/scopes", access_token=access_token)
|
|
541
|
+
|
|
542
|
+
async def get_users_with_roles(
|
|
543
|
+
self,
|
|
544
|
+
skip: int = 0,
|
|
545
|
+
limit: int = 100,
|
|
546
|
+
access_token: Optional[str] = None,
|
|
547
|
+
) -> Dict[str, Any]:
|
|
548
|
+
"""Получить список пользователей с их ролями (требует sso.admin.read)"""
|
|
549
|
+
return await self.get(
|
|
550
|
+
"admin/user-roles",
|
|
551
|
+
access_token=access_token,
|
|
552
|
+
params={"skip": skip, "limit": limit},
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Логи
|
|
556
|
+
async def get_role_logs(
|
|
557
|
+
self,
|
|
558
|
+
skip: int = 0,
|
|
559
|
+
limit: int = 100,
|
|
560
|
+
user_id: Optional[int] = None,
|
|
561
|
+
search: Optional[str] = None,
|
|
562
|
+
access_token: Optional[str] = None,
|
|
563
|
+
) -> Dict[str, Any]:
|
|
564
|
+
"""Получить логи действий с ролями (требует sso.admin.read)"""
|
|
565
|
+
params = {"skip": skip, "limit": limit}
|
|
566
|
+
if user_id:
|
|
567
|
+
params["user_id"] = user_id
|
|
568
|
+
if search:
|
|
569
|
+
params["search"] = search
|
|
570
|
+
return await self.get("logs/role", access_token=access_token, params=params)
|
|
571
|
+
|
|
572
|
+
async def get_user_logs(
|
|
573
|
+
self,
|
|
574
|
+
skip: int = 0,
|
|
575
|
+
limit: int = 100,
|
|
576
|
+
user_id: Optional[int] = None,
|
|
577
|
+
search: Optional[str] = None,
|
|
578
|
+
access_token: Optional[str] = None,
|
|
579
|
+
) -> Dict[str, Any]:
|
|
580
|
+
"""Получить логи действий с пользователями (требует sso.admin.read)"""
|
|
581
|
+
params = {"skip": skip, "limit": limit}
|
|
582
|
+
if user_id:
|
|
583
|
+
params["user_id"] = user_id
|
|
584
|
+
if search:
|
|
585
|
+
params["search"] = search
|
|
586
|
+
return await self.get("logs/user", access_token=access_token, params=params)
|
|
587
|
+
|
|
588
|
+
async def get_auth_logs(
|
|
589
|
+
self,
|
|
590
|
+
skip: int = 0,
|
|
591
|
+
limit: int = 100,
|
|
592
|
+
user_id: Optional[int] = None,
|
|
593
|
+
search: Optional[str] = None,
|
|
594
|
+
access_token: Optional[str] = None,
|
|
595
|
+
) -> Dict[str, Any]:
|
|
596
|
+
"""Получить логи действий авторизации (требует sso.admin.read)"""
|
|
597
|
+
params = {"skip": skip, "limit": limit}
|
|
598
|
+
if user_id:
|
|
599
|
+
params["user_id"] = user_id
|
|
600
|
+
if search:
|
|
601
|
+
params["search"] = search
|
|
602
|
+
return await self.get("logs/auth", access_token=access_token, params=params)
|
|
603
|
+
|
|
604
|
+
async def get_service_logs(
|
|
605
|
+
self,
|
|
606
|
+
skip: int = 0,
|
|
607
|
+
limit: int = 100,
|
|
608
|
+
user_id: Optional[int] = None,
|
|
609
|
+
search: Optional[str] = None,
|
|
610
|
+
access_token: Optional[str] = None,
|
|
611
|
+
) -> Dict[str, Any]:
|
|
612
|
+
"""Получить логи действий сервисов (требует sso.admin.read)"""
|
|
613
|
+
params = {"skip": skip, "limit": limit}
|
|
614
|
+
if user_id:
|
|
615
|
+
params["user_id"] = user_id
|
|
616
|
+
if search:
|
|
617
|
+
params["search"] = search
|
|
618
|
+
return await self.get("logs/service", access_token=access_token, params=params)
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
# Полный цикл авторизации
|
|
17
|
+
token_response = await client.full_auth_flow(
|
|
18
|
+
login="admin",
|
|
19
|
+
password="SecretPassword123!",
|
|
20
|
+
scope="sso.admin.read sso.admin.create",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
print(f"Access token: {token_response.access_token[:50]}...")
|
|
24
|
+
print(
|
|
25
|
+
f"Refresh token: {token_response.refresh_token[:50] if token_response.refresh_token else None}...")
|
|
26
|
+
print(f"Expires in: {token_response.expires_in} seconds")
|
|
27
|
+
|
|
28
|
+
# Получаем информацию о пользователе
|
|
29
|
+
user_info = await client.get_current_user()
|
|
30
|
+
print(f"\nПользователь: {user_info.name} {user_info.surname}")
|
|
31
|
+
print(f"Email: {user_info.email}")
|
|
32
|
+
print(f"Scopes: {user_info.scopes}")
|
|
33
|
+
|
|
34
|
+
# Обновляем токен
|
|
35
|
+
new_token = await client.refresh_access_token()
|
|
36
|
+
print(
|
|
37
|
+
f"\nНовый access token получен: {new_token.access_token[:50]}...")
|
|
38
|
+
|
|
39
|
+
# Получаем список доступных сервисов
|
|
40
|
+
services = await client.get_available_services()
|
|
41
|
+
print(f"\nДоступно сервисов: {len(services.services)}")
|
|
42
|
+
for service in services.services:
|
|
43
|
+
print(f" - {service.name} ({service.client_id})")
|
|
44
|
+
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print(f"Ошибка: {e}")
|
|
47
|
+
|
|
48
|
+
finally:
|
|
49
|
+
await client.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
asyncio.run(main())
|
|
@@ -16,8 +16,8 @@ async def main():
|
|
|
16
16
|
try:
|
|
17
17
|
# Полный цикл авторизации
|
|
18
18
|
token_response = await client.full_auth_flow(
|
|
19
|
-
login="user
|
|
20
|
-
password="
|
|
19
|
+
login="user",
|
|
20
|
+
password="SimplePassword123!",
|
|
21
21
|
scope="sso.admin.read sso.admin.create",
|
|
22
22
|
)
|
|
23
23
|
|
sso_nebus/service_client.py
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
|
-
from base import BaseClient
|
|
6
|
-
from models import TokenResponse, UserInfo
|
|
7
|
-
from exceptions import TokenError
|
|
5
|
+
from .base import BaseClient
|
|
6
|
+
from .models import TokenResponse, UserInfo
|
|
7
|
+
from .exceptions import TokenError
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class ServiceClient(BaseClient):
|
|
@@ -18,6 +18,8 @@ class ServiceClient(BaseClient):
|
|
|
18
18
|
api_version: str = "v1",
|
|
19
19
|
timeout: int = 30,
|
|
20
20
|
session=None,
|
|
21
|
+
auto_refresh_token: bool = True,
|
|
22
|
+
default_scope: Optional[str] = None,
|
|
21
23
|
):
|
|
22
24
|
"""
|
|
23
25
|
Инициализация клиента для микросервисов
|
|
@@ -29,11 +31,14 @@ class ServiceClient(BaseClient):
|
|
|
29
31
|
api_version: Версия API
|
|
30
32
|
timeout: Таймаут запросов
|
|
31
33
|
session: Опциональная aiohttp сессия
|
|
34
|
+
auto_refresh_token: Автоматически обновлять токен при получении 401 ошибки
|
|
35
|
+
default_scope: Scope по умолчанию для автоматического получения токена
|
|
32
36
|
"""
|
|
33
|
-
super().__init__(base_url, api_version, timeout, session)
|
|
37
|
+
super().__init__(base_url, api_version, timeout, session, auto_refresh_token)
|
|
34
38
|
self.client_id = client_id
|
|
35
39
|
self.client_secret = client_secret
|
|
36
40
|
self._access_token: Optional[str] = None
|
|
41
|
+
self.default_scope = default_scope
|
|
37
42
|
|
|
38
43
|
async def get_access_token(self, scope: Optional[str] = None) -> TokenResponse:
|
|
39
44
|
"""
|
|
@@ -90,6 +95,14 @@ class ServiceClient(BaseClient):
|
|
|
90
95
|
"""
|
|
91
96
|
self._access_token = access_token
|
|
92
97
|
|
|
98
|
+
def _get_access_token(self) -> Optional[str]:
|
|
99
|
+
"""Получить текущий access token (для BaseClient)"""
|
|
100
|
+
return self._access_token
|
|
101
|
+
|
|
102
|
+
async def _refresh_token(self) -> None:
|
|
103
|
+
"""Обновить access token (для авто-рефреша)"""
|
|
104
|
+
await self.get_access_token(self.default_scope)
|
|
105
|
+
|
|
93
106
|
def get_token(self) -> Optional[str]:
|
|
94
107
|
"""Получить текущий access token"""
|
|
95
108
|
return self._access_token
|
sso_nebus/user_client.py
CHANGED
|
@@ -5,8 +5,8 @@ import hashlib
|
|
|
5
5
|
import base64
|
|
6
6
|
import secrets
|
|
7
7
|
|
|
8
|
-
from base import BaseClient
|
|
9
|
-
from models import (
|
|
8
|
+
from .base import BaseClient
|
|
9
|
+
from .models import (
|
|
10
10
|
PKCEParams,
|
|
11
11
|
TokenResponse,
|
|
12
12
|
UserInfo,
|
|
@@ -14,7 +14,7 @@ from models import (
|
|
|
14
14
|
LoginResponse,
|
|
15
15
|
ServicesList,
|
|
16
16
|
)
|
|
17
|
-
from exceptions import TokenError
|
|
17
|
+
from .exceptions import TokenError
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class UserClient(BaseClient):
|
|
@@ -28,6 +28,7 @@ class UserClient(BaseClient):
|
|
|
28
28
|
api_version: str = "v1",
|
|
29
29
|
timeout: int = 30,
|
|
30
30
|
session=None,
|
|
31
|
+
auto_refresh_token: bool = True,
|
|
31
32
|
):
|
|
32
33
|
"""
|
|
33
34
|
Инициализация клиента для пользователей
|
|
@@ -39,8 +40,9 @@ class UserClient(BaseClient):
|
|
|
39
40
|
api_version: Версия API
|
|
40
41
|
timeout: Таймаут запросов
|
|
41
42
|
session: Опциональная aiohttp сессия
|
|
43
|
+
auto_refresh_token: Автоматически обновлять токен при получении 401 ошибки
|
|
42
44
|
"""
|
|
43
|
-
super().__init__(base_url, api_version, timeout, session)
|
|
45
|
+
super().__init__(base_url, api_version, timeout, session, auto_refresh_token)
|
|
44
46
|
self.client_id = client_id
|
|
45
47
|
self.redirect_uri = redirect_uri
|
|
46
48
|
self._pkce_params: Optional[PKCEParams] = None
|
|
@@ -70,7 +72,7 @@ class UserClient(BaseClient):
|
|
|
70
72
|
Returns:
|
|
71
73
|
PKCEParams с code_verifier, code_challenge и state
|
|
72
74
|
"""
|
|
73
|
-
data = await self.get("
|
|
75
|
+
data = await self.get("pkce-params")
|
|
74
76
|
self._pkce_params = PKCEParams(**data)
|
|
75
77
|
return self._pkce_params
|
|
76
78
|
|
|
@@ -116,7 +118,7 @@ class UserClient(BaseClient):
|
|
|
116
118
|
if scope:
|
|
117
119
|
params["scope"] = scope
|
|
118
120
|
|
|
119
|
-
data = await self.get("
|
|
121
|
+
data = await self.get("authorize", params=params)
|
|
120
122
|
return AuthorizeResponse(**data)
|
|
121
123
|
|
|
122
124
|
async def login(
|
|
@@ -142,7 +144,7 @@ class UserClient(BaseClient):
|
|
|
142
144
|
"password": password,
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
data = await self.post("
|
|
147
|
+
data = await self.post("login", json_data=json_data)
|
|
146
148
|
return LoginResponse(**data)
|
|
147
149
|
|
|
148
150
|
async def exchange_code_for_tokens(
|
|
@@ -180,7 +182,7 @@ class UserClient(BaseClient):
|
|
|
180
182
|
if redirect_uri:
|
|
181
183
|
form_data["redirect_uri"] = redirect_uri
|
|
182
184
|
|
|
183
|
-
data = await self.post("
|
|
185
|
+
data = await self.post("token", form_data=form_data)
|
|
184
186
|
token_response = TokenResponse(**data)
|
|
185
187
|
|
|
186
188
|
# Сохраняем токены
|
|
@@ -211,7 +213,7 @@ class UserClient(BaseClient):
|
|
|
211
213
|
"client_id": self.client_id,
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
data = await self.post("
|
|
216
|
+
data = await self.post("token", form_data=form_data)
|
|
215
217
|
token_response = TokenResponse(**data)
|
|
216
218
|
|
|
217
219
|
# Обновляем токены
|
|
@@ -236,7 +238,7 @@ class UserClient(BaseClient):
|
|
|
236
238
|
raise TokenError(
|
|
237
239
|
"Access token не найден. Выполните авторизацию сначала.")
|
|
238
240
|
|
|
239
|
-
data = await self.get("
|
|
241
|
+
data = await self.get("me", access_token=access_token)
|
|
240
242
|
return UserInfo(**data)
|
|
241
243
|
|
|
242
244
|
async def logout(self, refresh_token: Optional[str] = None) -> dict:
|
|
@@ -256,7 +258,7 @@ class UserClient(BaseClient):
|
|
|
256
258
|
|
|
257
259
|
form_data = {"refresh_token": refresh_token}
|
|
258
260
|
|
|
259
|
-
data = await self.post("
|
|
261
|
+
data = await self.post("logout", form_data=form_data)
|
|
260
262
|
|
|
261
263
|
# Очищаем токены
|
|
262
264
|
self._access_token = None
|
|
@@ -272,9 +274,19 @@ class UserClient(BaseClient):
|
|
|
272
274
|
Returns:
|
|
273
275
|
ServicesList со списком активных микросервисов
|
|
274
276
|
"""
|
|
275
|
-
data = await self.get("
|
|
277
|
+
data = await self.get("services")
|
|
276
278
|
return ServicesList(**data)
|
|
277
279
|
|
|
280
|
+
def _get_access_token(self) -> Optional[str]:
|
|
281
|
+
"""Получить текущий access token (для BaseClient)"""
|
|
282
|
+
return self._access_token
|
|
283
|
+
|
|
284
|
+
async def _refresh_token(self) -> None:
|
|
285
|
+
"""Обновить access token используя refresh token (для авто-рефреша)"""
|
|
286
|
+
if not self._refresh_token:
|
|
287
|
+
raise TokenError("Refresh token не найден. Выполните авторизацию сначала.")
|
|
288
|
+
await self.refresh_access_token(self._refresh_token)
|
|
289
|
+
|
|
278
290
|
def get_access_token(self) -> Optional[str]:
|
|
279
291
|
"""Получить текущий access token"""
|
|
280
292
|
return self._access_token
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sso-nebus
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Python клиент для взаимодействия с MS Auth Service API
|
|
5
5
|
License: LICENSE
|
|
6
6
|
License-File: LICENSE
|
|
@@ -29,7 +29,7 @@ pip install -e .
|
|
|
29
29
|
Или если пакет опубликован:
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
pip install
|
|
32
|
+
pip install sso_nebus
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
## Быстрый старт
|
|
@@ -168,6 +168,54 @@ async def main():
|
|
|
168
168
|
asyncio.run(main())
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
+
Пример для получения информации по пользвателю для подстановки в Depends
|
|
172
|
+
```
|
|
173
|
+
from fastapi import FastAPI, Header, HTTPException
|
|
174
|
+
from typing import Optional
|
|
175
|
+
|
|
176
|
+
app = FastAPI()
|
|
177
|
+
|
|
178
|
+
sso_client = ServiceClient(
|
|
179
|
+
base_url="http://localhost:8000",
|
|
180
|
+
client_id="your_service_id",
|
|
181
|
+
client_secret="your_service_secret"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def get_current_user(authorization: Optional[str] = Header(None)):
|
|
185
|
+
"""
|
|
186
|
+
Dependency для получения текущего пользователя из токена
|
|
187
|
+
"""
|
|
188
|
+
if not authorization:
|
|
189
|
+
raise HTTPException(status_code=401, detail="Токен не предоставлен")
|
|
190
|
+
|
|
191
|
+
# Извлекаем токен из заголовка "Bearer <token>"
|
|
192
|
+
try:
|
|
193
|
+
token = authorization.split(" ")[1]
|
|
194
|
+
except IndexError:
|
|
195
|
+
raise HTTPException(status_code=401, detail="Неверный формат токена")
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
user_info = await sso_client.get_current_user(access_token=token)
|
|
199
|
+
return user_info
|
|
200
|
+
except AuthenticationError:
|
|
201
|
+
raise HTTPException(status_code=401, detail="Невалидный токен")
|
|
202
|
+
except Exception as e:
|
|
203
|
+
raise HTTPException(status_code=500, detail=f"Ошибка при проверке токена: {e}")
|
|
204
|
+
|
|
205
|
+
@app.get("/protected")
|
|
206
|
+
async def protected_endpoint(current_user = Depends(get_current_user)):
|
|
207
|
+
"""
|
|
208
|
+
Защищенный endpoint, который требует валидный токен пользователя
|
|
209
|
+
"""
|
|
210
|
+
return {
|
|
211
|
+
"message": f"Привет, {current_user.name} {current_user.surname}!",
|
|
212
|
+
"user_id": current_user.id,
|
|
213
|
+
"email": current_user.email,
|
|
214
|
+
"scopes": current_user.scopes
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
|
|
171
219
|
## API Reference
|
|
172
220
|
|
|
173
221
|
### UserClient
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
sso_nebus/__init__.py,sha256=B7X9F074caljXhvE8Fpq1SwO-gVxzFUurOdbIkNLxp0,872
|
|
2
|
+
sso_nebus/base.py,sha256=4LQ7-qxxonGILktmX3uQCraolrVnWZ_kqRfKO8EOcmk,24213
|
|
3
|
+
sso_nebus/exceptions.py,sha256=XHrvfGGvs3eKMzkpwRxXyDngjpMSp7X7E8EUXwv26NA,1262
|
|
4
|
+
sso_nebus/exmples/example_admin.py,sha256=eAqkS4J5YyJneP4EPUrBlvNbGlK7rHEO39kCklt_WQM,1822
|
|
5
|
+
sso_nebus/exmples/example_service.py,sha256=19NNTmbaRSaWLpDQffGWNDmXkDJXdLOgD2WkV32xUu8,1778
|
|
6
|
+
sso_nebus/exmples/example_user.py,sha256=c_Uf36FDHIXvv_vdqsO5XvXkvu1YOUsqaTO44_8DqVM,1878
|
|
7
|
+
sso_nebus/models.py,sha256=0xIE5RvnxV2sAxO7_GTCes6HlHbdzGwHIjWVe1X7-ks,3024
|
|
8
|
+
sso_nebus/service_client.py,sha256=RUmnwVJBXfEqFfidCwHfwZo-2S_tyz_n9Ojuje7j4F4,5470
|
|
9
|
+
sso_nebus/user_client.py,sha256=-3pJmvZxJgyEo3moGQvZVAOhJMOIC6NuE8NgJ33Fyfs,12976
|
|
10
|
+
sso_nebus-0.1.2.dist-info/licenses/LICENSE,sha256=dCbOm3zpH8T7vLDC2K7QJLu-LEl2zqaSuyARbqfGsEY,1863
|
|
11
|
+
sso_nebus-0.1.2.dist-info/METADATA,sha256=hed0VrJyId6el7rcpUs4ecjqyh2-c3D372CXzJvaGk0,8406
|
|
12
|
+
sso_nebus-0.1.2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
13
|
+
sso_nebus-0.1.2.dist-info/RECORD,,
|
sso_nebus-0.1.0.dist-info/RECORD
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|