python3-commons 0.18.21__tar.gz → 0.19.0__tar.gz
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.
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.env_template +6 -0
- {python3_commons-0.18.21/src/python3_commons.egg-info → python3_commons-0.19.0}/PKG-INFO +1 -1
- python3_commons-0.19.0/src/python3_commons/auth.py +241 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/helpers.py +1 -1
- {python3_commons-0.18.21 → python3_commons-0.19.0/src/python3_commons.egg-info}/PKG-INFO +1 -1
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/SOURCES.txt +2 -0
- python3_commons-0.19.0/tests/integration/conftest.py +29 -0
- python3_commons-0.19.0/tests/integration/test_auth.py +29 -0
- python3_commons-0.18.21/src/python3_commons/auth.py +0 -198
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.coveragerc +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.devcontainer/Dockerfile +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.devcontainer/devcontainer.json +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.devcontainer/docker-compose.yml +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.github/workflows/checks.yml +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.github/workflows/python-publish.yaml +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.github/workflows/release-on-tag-push.yml +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.gitignore +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.pre-commit-config.yaml +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/.python-version +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/AUTHORS.rst +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/CHANGELOG.rst +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/LICENSE +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/README.md +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/README.rst +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/Makefile +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/_static/.gitignore +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/authors.rst +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/changelog.rst +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/conf.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/index.rst +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/license.rst +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/pyproject.toml +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/setup.cfg +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/__init__.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/api_client.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/async_functools.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/audit.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/cache.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/conf.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/__init__.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/helpers.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/__init__.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/auth.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/common.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/rbac.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/users.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/exceptions.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/fs.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/generators.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/log/__init__.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/log/filters.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/log/formatters.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/object_storage.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/permissions.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/__init__.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/common.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/json.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/msgpack.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/msgspec.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/soap_client.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/dependency_links.txt +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/requires.txt +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/top_level.txt +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/__init__.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/integration/__init__.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/integration/test_cache.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/integration/test_osc.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/__init__.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/conftest.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/log/__init__.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/log/test_formatters.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_async_functools.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_audit.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_helpers.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_msgpack.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_msgspec.py +0 -0
- {python3_commons-0.18.21 → python3_commons-0.19.0}/uv.lock +0 -0
|
@@ -17,3 +17,9 @@ DB_DSN=postgresql+asyncpg://dev:dev@localhost:5432/fastapi-project-template
|
|
|
17
17
|
API_AUTH_ENABLED=false
|
|
18
18
|
OIDC_AUTHORITY_URL=https://oidc.provider.com/auth
|
|
19
19
|
OIDC_CLIENT_ID=12345-54321
|
|
20
|
+
|
|
21
|
+
TEST_OIDC_AUTHORITY_URL=https://oidc.provider.com/auth
|
|
22
|
+
TEST_OIDC_CLIENT_ID=12345-54321
|
|
23
|
+
TEST_OIDC_CLIENT_SECRET=
|
|
24
|
+
TEST_OIDC_USERNAME=testuser
|
|
25
|
+
TEST_OIDC_PASSWORD=
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
from typing import Any, Self, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import HttpUrl
|
|
8
|
+
|
|
9
|
+
from python3_commons.helpers import replace_origin
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import aiohttp
|
|
13
|
+
except ImportError as e:
|
|
14
|
+
msg = 'Install python3-commons[authn] to use this feature'
|
|
15
|
+
raise RuntimeError(msg) from e
|
|
16
|
+
|
|
17
|
+
import msgspec
|
|
18
|
+
|
|
19
|
+
from python3_commons.conf import oidc_settings
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
_OIDC_LOCK = threading.Lock()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TokenData(msgspec.Struct):
|
|
26
|
+
exp: int
|
|
27
|
+
iat: int
|
|
28
|
+
iss: str
|
|
29
|
+
sub: str
|
|
30
|
+
aud: str | Sequence[str] | None = None
|
|
31
|
+
email: str | None = None
|
|
32
|
+
name: str | None = None
|
|
33
|
+
preferred_username: str | None = None
|
|
34
|
+
realm_access: dict[str, Sequence[str]] | None = None
|
|
35
|
+
resource_access: dict[str, dict[str, Sequence[str]]] | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def roles(self) -> list[str]:
|
|
39
|
+
roles_list = []
|
|
40
|
+
|
|
41
|
+
if self.realm_access:
|
|
42
|
+
roles_list.extend(self.realm_access.get('roles', []))
|
|
43
|
+
|
|
44
|
+
if self.resource_access:
|
|
45
|
+
for client in self.resource_access.values():
|
|
46
|
+
roles_list.extend(client.get('roles', []))
|
|
47
|
+
|
|
48
|
+
return list(set(roles_list))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
T = TypeVar('T', bound=TokenData)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OIDCTokenResponse(msgspec.Struct):
|
|
55
|
+
access_token: str
|
|
56
|
+
token_type: str
|
|
57
|
+
expires_in: int
|
|
58
|
+
refresh_token: str
|
|
59
|
+
scope: str
|
|
60
|
+
id_token: str
|
|
61
|
+
error: str | None = None
|
|
62
|
+
error_description: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OIDCError(Exception):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OIDCAuthError(OIDCError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# TODO: use api_client
|
|
74
|
+
class OIDCClient:
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
authority_url: HttpUrl,
|
|
78
|
+
client_id: str,
|
|
79
|
+
client_secret: str | None = None,
|
|
80
|
+
*,
|
|
81
|
+
timeout: float = 10.0,
|
|
82
|
+
verify_ssl: bool = True,
|
|
83
|
+
connection_limit: int = 100,
|
|
84
|
+
) -> None:
|
|
85
|
+
if oidc_settings.authority_internal_host:
|
|
86
|
+
authority_url = replace_origin(authority_url, oidc_settings.authority_internal_host)
|
|
87
|
+
|
|
88
|
+
self._authority_url = authority_url
|
|
89
|
+
self._client_id = client_id
|
|
90
|
+
self._client_secret = client_secret
|
|
91
|
+
self._oidc_config: Mapping[str, Any] | None = None
|
|
92
|
+
|
|
93
|
+
self._connection_limit = connection_limit
|
|
94
|
+
self._session: aiohttp.ClientSession | None = None
|
|
95
|
+
self._timeout = timeout
|
|
96
|
+
self._verify_ssl = verify_ssl
|
|
97
|
+
|
|
98
|
+
def get_session(self) -> aiohttp.ClientSession:
|
|
99
|
+
if self._session:
|
|
100
|
+
return self._session
|
|
101
|
+
|
|
102
|
+
with _OIDC_LOCK:
|
|
103
|
+
if self._session:
|
|
104
|
+
return self._session
|
|
105
|
+
|
|
106
|
+
connector = aiohttp.TCPConnector(verify_ssl=self._verify_ssl, limit=self._connection_limit)
|
|
107
|
+
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
|
108
|
+
session = aiohttp.ClientSession(connector=connector, timeout=timeout)
|
|
109
|
+
self._session = session
|
|
110
|
+
|
|
111
|
+
return session
|
|
112
|
+
|
|
113
|
+
async def __aenter__(self) -> Self:
|
|
114
|
+
self.get_session()
|
|
115
|
+
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
async def __aexit__(self, *_: object) -> None:
|
|
119
|
+
if self._session:
|
|
120
|
+
await self._session.close()
|
|
121
|
+
|
|
122
|
+
async def _fetch_openid_config(self) -> dict:
|
|
123
|
+
"""
|
|
124
|
+
Fetch the OpenID configuration (including JWKS URI) from OIDC authority.
|
|
125
|
+
"""
|
|
126
|
+
if self._session is None:
|
|
127
|
+
msg = 'ClientSession not initialized'
|
|
128
|
+
raise RuntimeError(msg)
|
|
129
|
+
|
|
130
|
+
oidc_config_url = f'{self._authority_url}/.well-known/openid-configuration'
|
|
131
|
+
|
|
132
|
+
logger.debug('Fetching OpenID configuration from: %s', oidc_config_url)
|
|
133
|
+
|
|
134
|
+
async with self._session.get(oidc_config_url) as response:
|
|
135
|
+
if response.status != HTTPStatus.OK:
|
|
136
|
+
_msg = 'Failed to fetch OpenID configuration'
|
|
137
|
+
|
|
138
|
+
raise RuntimeError(_msg)
|
|
139
|
+
|
|
140
|
+
return await response.json()
|
|
141
|
+
|
|
142
|
+
async def fetch_jwks(self, jwks_uri: str) -> dict:
|
|
143
|
+
"""
|
|
144
|
+
Fetch the JSON Web Key Set (JWKS) for validating the token's signature.
|
|
145
|
+
"""
|
|
146
|
+
if self._session is None:
|
|
147
|
+
msg = 'ClientSession not initialized'
|
|
148
|
+
raise RuntimeError(msg)
|
|
149
|
+
|
|
150
|
+
if authority_internal_host := oidc_settings.authority_internal_host:
|
|
151
|
+
logger.debug('Received jwks_uri: %s', jwks_uri)
|
|
152
|
+
logger.debug('Replacing OIDC authority host with: %s', authority_internal_host)
|
|
153
|
+
jwks_uri = str(replace_origin(HttpUrl(jwks_uri), authority_internal_host))
|
|
154
|
+
logger.debug('Modified jwks_uri: %s', jwks_uri)
|
|
155
|
+
|
|
156
|
+
async with self._session.get(jwks_uri) as response:
|
|
157
|
+
if response.status != HTTPStatus.OK:
|
|
158
|
+
_msg = 'Failed to fetch JWKS'
|
|
159
|
+
|
|
160
|
+
raise RuntimeError(_msg)
|
|
161
|
+
|
|
162
|
+
return await response.json()
|
|
163
|
+
|
|
164
|
+
async def get_openid_config(self) -> Mapping[str, Any]:
|
|
165
|
+
if self._oidc_config:
|
|
166
|
+
return self._oidc_config
|
|
167
|
+
|
|
168
|
+
with _OIDC_LOCK:
|
|
169
|
+
if self._oidc_config:
|
|
170
|
+
return self._oidc_config
|
|
171
|
+
|
|
172
|
+
oidc_config = await self._fetch_openid_config()
|
|
173
|
+
self._oidc_config = oidc_config
|
|
174
|
+
|
|
175
|
+
return oidc_config
|
|
176
|
+
|
|
177
|
+
async def fetch_token(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
username: str,
|
|
181
|
+
password: str,
|
|
182
|
+
scope: str = 'openid profile email',
|
|
183
|
+
) -> OIDCTokenResponse:
|
|
184
|
+
if self._session is None:
|
|
185
|
+
msg = 'ClientSession not initialized'
|
|
186
|
+
raise RuntimeError(msg)
|
|
187
|
+
|
|
188
|
+
data = {
|
|
189
|
+
'grant_type': 'password',
|
|
190
|
+
'username': username,
|
|
191
|
+
'password': password,
|
|
192
|
+
'client_id': self._client_id,
|
|
193
|
+
'scope': scope,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if self._client_secret:
|
|
197
|
+
data['client_secret'] = self._client_secret
|
|
198
|
+
|
|
199
|
+
openid_config = await self.get_openid_config()
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
async with self._session.post(
|
|
203
|
+
openid_config['token_endpoint'],
|
|
204
|
+
data=data,
|
|
205
|
+
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
|
206
|
+
) as response:
|
|
207
|
+
payload = await response.read()
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
body = msgspec.json.decode(payload)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
msg = f'Non-JSON response from OIDC provider: {payload[:300]!r}'
|
|
213
|
+
raise OIDCError(msg) from e
|
|
214
|
+
|
|
215
|
+
if response.status >= 400:
|
|
216
|
+
error = None
|
|
217
|
+
description = None
|
|
218
|
+
|
|
219
|
+
if isinstance(body, dict):
|
|
220
|
+
error = body.get('error') or body.get('code')
|
|
221
|
+
description = body.get('error_description') or body.get('message') or ''
|
|
222
|
+
|
|
223
|
+
error = error or f'http_{response.status}'
|
|
224
|
+
|
|
225
|
+
if error in {'invalid_grant', 'invalid_client'}:
|
|
226
|
+
msg = f'{error}: {description}'
|
|
227
|
+
raise OIDCAuthError(msg)
|
|
228
|
+
|
|
229
|
+
msg = f'{error}: {description}'
|
|
230
|
+
raise OIDCError(msg)
|
|
231
|
+
|
|
232
|
+
decoder = msgspec.json.Decoder(OIDCTokenResponse)
|
|
233
|
+
|
|
234
|
+
return decoder.decode(payload)
|
|
235
|
+
|
|
236
|
+
except TimeoutError as e:
|
|
237
|
+
msg = 'OIDC request timed out'
|
|
238
|
+
raise OIDCError(msg) from e
|
|
239
|
+
except aiohttp.ClientError as e:
|
|
240
|
+
msg = 'OIDC transport error'
|
|
241
|
+
raise OIDCError(msg) from e
|
|
@@ -172,7 +172,7 @@ def replace_origin(url: HttpUrl, host_url: HttpUrl) -> HttpUrl:
|
|
|
172
172
|
scheme=host_url.scheme,
|
|
173
173
|
host=host_url.host,
|
|
174
174
|
port=host_url.port,
|
|
175
|
-
path=url.path,
|
|
175
|
+
path=url_path.lstrip('/') if (url_path := url.path) else url_path,
|
|
176
176
|
query=url.query,
|
|
177
177
|
fragment=url.fragment,
|
|
178
178
|
)
|
|
@@ -59,6 +59,8 @@ src/python3_commons/serializers/msgpack.py
|
|
|
59
59
|
src/python3_commons/serializers/msgspec.py
|
|
60
60
|
tests/__init__.py
|
|
61
61
|
tests/integration/__init__.py
|
|
62
|
+
tests/integration/conftest.py
|
|
63
|
+
tests/integration/test_auth.py
|
|
62
64
|
tests/integration/test_cache.py
|
|
63
65
|
tests/integration/test_osc.py
|
|
64
66
|
tests/unit/__init__.py
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from os import getenv
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import HttpUrl
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture(scope='session')
|
|
8
|
+
def authority_url() -> HttpUrl:
|
|
9
|
+
return HttpUrl(getenv('TEST_OIDC_AUTHORITY_URL', ''))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(scope='session')
|
|
13
|
+
def client_id() -> str:
|
|
14
|
+
return getenv('TEST_OIDC_CLIENT_ID', '')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(scope='session')
|
|
18
|
+
def client_secret() -> str:
|
|
19
|
+
return getenv('TEST_OIDC_CLIENT_SECRET', '')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture(scope='session')
|
|
23
|
+
def oidc_username() -> str:
|
|
24
|
+
return getenv('TEST_OIDC_USERNAME', '')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(scope='session')
|
|
28
|
+
def oidc_password() -> str:
|
|
29
|
+
return getenv('TEST_OIDC_PASSWORD', '')
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from pydantic import HttpUrl
|
|
7
|
+
|
|
8
|
+
from python3_commons.auth import OIDCClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
async def test_get_token_cognito(
|
|
13
|
+
authority_url: HttpUrl, client_id: str, client_secret: str, oidc_username: str, oidc_password: str
|
|
14
|
+
) -> None:
|
|
15
|
+
async with (
|
|
16
|
+
OIDCClient(
|
|
17
|
+
authority_url=authority_url,
|
|
18
|
+
client_id=client_id,
|
|
19
|
+
client_secret=client_secret,
|
|
20
|
+
verify_ssl=False,
|
|
21
|
+
timeout=10,
|
|
22
|
+
) as client,
|
|
23
|
+
):
|
|
24
|
+
token = await client.fetch_token(
|
|
25
|
+
username=oidc_username,
|
|
26
|
+
password=oidc_password,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
assert token.access_token
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from collections.abc import Sequence
|
|
5
|
-
from http import HTTPStatus
|
|
6
|
-
from typing import Self, TypeVar
|
|
7
|
-
|
|
8
|
-
from pydantic import HttpUrl
|
|
9
|
-
|
|
10
|
-
from python3_commons.helpers import replace_origin
|
|
11
|
-
|
|
12
|
-
try:
|
|
13
|
-
import aiohttp
|
|
14
|
-
except ImportError as e:
|
|
15
|
-
msg = 'Install python3-commons[authn] to use this feature'
|
|
16
|
-
raise RuntimeError(msg) from e
|
|
17
|
-
|
|
18
|
-
import msgspec
|
|
19
|
-
|
|
20
|
-
from python3_commons.conf import oidc_settings
|
|
21
|
-
|
|
22
|
-
logger = logging.getLogger(__name__)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class TokenData(msgspec.Struct):
|
|
26
|
-
exp: int
|
|
27
|
-
iat: int
|
|
28
|
-
iss: str
|
|
29
|
-
sub: str
|
|
30
|
-
aud: str | Sequence[str] | None = None
|
|
31
|
-
email: str | None = None
|
|
32
|
-
name: str | None = None
|
|
33
|
-
preferred_username: str | None = None
|
|
34
|
-
realm_access: dict[str, Sequence[str]] | None = None
|
|
35
|
-
resource_access: dict[str, dict[str, Sequence[str]]] | None = None
|
|
36
|
-
|
|
37
|
-
@property
|
|
38
|
-
def roles(self) -> list[str]:
|
|
39
|
-
roles_list = []
|
|
40
|
-
|
|
41
|
-
if self.realm_access:
|
|
42
|
-
roles_list.extend(self.realm_access.get('roles', []))
|
|
43
|
-
|
|
44
|
-
if self.resource_access:
|
|
45
|
-
for client in self.resource_access.values():
|
|
46
|
-
roles_list.extend(client.get('roles', []))
|
|
47
|
-
|
|
48
|
-
return list(set(roles_list))
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
T = TypeVar('T', bound=TokenData)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class OIDCTokenResponse(msgspec.Struct):
|
|
55
|
-
access_token: str
|
|
56
|
-
token_type: str
|
|
57
|
-
expires_in: int
|
|
58
|
-
refresh_token: str
|
|
59
|
-
scope: str
|
|
60
|
-
id_token: str
|
|
61
|
-
error: str | None = None
|
|
62
|
-
error_description: str | None = None
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
async def fetch_openid_config() -> dict:
|
|
66
|
-
"""
|
|
67
|
-
Fetch the OpenID configuration (including JWKS URI) from OIDC authority.
|
|
68
|
-
"""
|
|
69
|
-
if (authority_url := oidc_settings.authority_url) is None:
|
|
70
|
-
msg = 'OIDC authority URL is required'
|
|
71
|
-
raise ValueError(msg)
|
|
72
|
-
|
|
73
|
-
if oidc_settings.authority_internal_host:
|
|
74
|
-
authority_url = replace_origin(authority_url, oidc_settings.authority_internal_host)
|
|
75
|
-
|
|
76
|
-
oidc_config_url = f'{authority_url}.well-known/openid-configuration'
|
|
77
|
-
|
|
78
|
-
logger.debug('Fetching OpenID configuration from: %s', oidc_config_url)
|
|
79
|
-
|
|
80
|
-
async with aiohttp.ClientSession() as session, session.get(oidc_config_url) as response:
|
|
81
|
-
if response.status != HTTPStatus.OK:
|
|
82
|
-
_msg = 'Failed to fetch OpenID configuration'
|
|
83
|
-
|
|
84
|
-
raise RuntimeError(_msg)
|
|
85
|
-
|
|
86
|
-
return await response.json()
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
async def fetch_jwks(jwks_uri: str) -> dict:
|
|
90
|
-
"""
|
|
91
|
-
Fetch the JSON Web Key Set (JWKS) for validating the token's signature.
|
|
92
|
-
"""
|
|
93
|
-
if authority_internal_host := oidc_settings.authority_internal_host:
|
|
94
|
-
logger.debug('Received jwks_uri: %s', jwks_uri)
|
|
95
|
-
logger.debug('Replacing OIDC authority host with: %s', authority_internal_host)
|
|
96
|
-
jwks_uri = str(replace_origin(HttpUrl(jwks_uri), authority_internal_host))
|
|
97
|
-
logger.debug('Modified jwks_uri: %s', jwks_uri)
|
|
98
|
-
|
|
99
|
-
async with aiohttp.ClientSession() as session, session.get(jwks_uri) as response:
|
|
100
|
-
if response.status != HTTPStatus.OK:
|
|
101
|
-
_msg = 'Failed to fetch JWKS'
|
|
102
|
-
|
|
103
|
-
raise RuntimeError(_msg)
|
|
104
|
-
|
|
105
|
-
return await response.json()
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
class OIDCError(Exception):
|
|
109
|
-
pass
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
class OIDCAuthError(OIDCError):
|
|
113
|
-
pass
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
# TODO: use api_client
|
|
117
|
-
class OIDCClient:
|
|
118
|
-
def __init__(
|
|
119
|
-
self,
|
|
120
|
-
authority_url: str,
|
|
121
|
-
client_id: str,
|
|
122
|
-
client_secret: str | None = None,
|
|
123
|
-
*,
|
|
124
|
-
timeout: float = 10.0,
|
|
125
|
-
session: aiohttp.ClientSession | None = None,
|
|
126
|
-
) -> None:
|
|
127
|
-
self._token_url = f'{authority_url}/protocol/openid-connect/token' # TODO: get it from openid-configuration
|
|
128
|
-
self._client_id = client_id
|
|
129
|
-
self._client_secret = client_secret
|
|
130
|
-
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
131
|
-
self._session = session
|
|
132
|
-
self._owns_session = session is None
|
|
133
|
-
|
|
134
|
-
async def __aenter__(self) -> Self:
|
|
135
|
-
if self._session is None:
|
|
136
|
-
self._session = aiohttp.ClientSession(timeout=self._timeout)
|
|
137
|
-
return self
|
|
138
|
-
|
|
139
|
-
async def __aexit__(self, *_: object) -> None:
|
|
140
|
-
if self._owns_session and self._session:
|
|
141
|
-
await self._session.close()
|
|
142
|
-
|
|
143
|
-
async def fetch_token(
|
|
144
|
-
self,
|
|
145
|
-
*,
|
|
146
|
-
username: str,
|
|
147
|
-
password: str,
|
|
148
|
-
scope: str = 'openid profile email',
|
|
149
|
-
) -> OIDCTokenResponse:
|
|
150
|
-
if self._session is None:
|
|
151
|
-
msg = 'ClientSession not initialized'
|
|
152
|
-
|
|
153
|
-
raise RuntimeError(msg)
|
|
154
|
-
|
|
155
|
-
data = {
|
|
156
|
-
'grant_type': 'password',
|
|
157
|
-
'username': username,
|
|
158
|
-
'password': password,
|
|
159
|
-
'client_id': self._client_id,
|
|
160
|
-
'scope': scope,
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if self._client_secret:
|
|
164
|
-
data['client_secret'] = self._client_secret
|
|
165
|
-
|
|
166
|
-
try:
|
|
167
|
-
async with self._session.post(
|
|
168
|
-
self._token_url,
|
|
169
|
-
data=data,
|
|
170
|
-
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
|
171
|
-
) as resp:
|
|
172
|
-
payload = await resp.read()
|
|
173
|
-
decoder = msgspec.json.Decoder(type=OIDCTokenResponse)
|
|
174
|
-
token = decoder.decode(payload)
|
|
175
|
-
|
|
176
|
-
except TimeoutError as e:
|
|
177
|
-
msg = 'OIDC request timed out'
|
|
178
|
-
|
|
179
|
-
raise OIDCError(msg) from e
|
|
180
|
-
except aiohttp.ClientError as e:
|
|
181
|
-
msg = 'OIDC transport error'
|
|
182
|
-
|
|
183
|
-
raise OIDCError(msg) from e
|
|
184
|
-
|
|
185
|
-
if not resp.ok:
|
|
186
|
-
error = token.error
|
|
187
|
-
description = token.error_description
|
|
188
|
-
|
|
189
|
-
if error in {'invalid_grant', 'invalid_client'}:
|
|
190
|
-
msg = f'{error}: {description}'
|
|
191
|
-
|
|
192
|
-
raise OIDCAuthError(msg)
|
|
193
|
-
|
|
194
|
-
msg = f'{error}: {description}'
|
|
195
|
-
|
|
196
|
-
raise OIDCError(msg)
|
|
197
|
-
|
|
198
|
-
return token
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python3_commons-0.18.21 → python3_commons-0.19.0}/.github/workflows/release-on-tag-push.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/__init__.py
RENAMED
|
File without changes
|
{python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/common.py
RENAMED
|
File without changes
|
|
File without changes
|
{python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/msgpack.py
RENAMED
|
File without changes
|
{python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/msgspec.py
RENAMED
|
File without changes
|
|
File without changes
|
{python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/requires.txt
RENAMED
|
File without changes
|
{python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|