python3-commons 0.18.22__py3-none-any.whl → 0.20.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.
- python3_commons/auth.py +139 -79
- {python3_commons-0.18.22.dist-info → python3_commons-0.20.0.dist-info}/METADATA +2 -2
- {python3_commons-0.18.22.dist-info → python3_commons-0.20.0.dist-info}/RECORD +7 -7
- {python3_commons-0.18.22.dist-info → python3_commons-0.20.0.dist-info}/WHEEL +0 -0
- {python3_commons-0.18.22.dist-info → python3_commons-0.20.0.dist-info}/licenses/AUTHORS.rst +0 -0
- {python3_commons-0.18.22.dist-info → python3_commons-0.20.0.dist-info}/licenses/LICENSE +0 -0
- {python3_commons-0.18.22.dist-info → python3_commons-0.20.0.dist-info}/top_level.txt +0 -0
python3_commons/auth.py
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
import logging
|
|
4
|
-
|
|
2
|
+
import threading
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
5
4
|
from http import HTTPStatus
|
|
6
|
-
from typing import Self, TypeVar
|
|
5
|
+
from typing import Any, Self, TypeVar
|
|
7
6
|
|
|
8
7
|
from pydantic import HttpUrl
|
|
9
8
|
|
|
@@ -17,9 +16,8 @@ except ImportError as e:
|
|
|
17
16
|
|
|
18
17
|
import msgspec
|
|
19
18
|
|
|
20
|
-
from python3_commons.conf import oidc_settings
|
|
21
|
-
|
|
22
19
|
logger = logging.getLogger(__name__)
|
|
20
|
+
_OIDC_LOCK = threading.Lock()
|
|
23
21
|
|
|
24
22
|
|
|
25
23
|
class TokenData(msgspec.Struct):
|
|
@@ -62,49 +60,6 @@ class OIDCTokenResponse(msgspec.Struct):
|
|
|
62
60
|
error_description: str | None = None
|
|
63
61
|
|
|
64
62
|
|
|
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
63
|
class OIDCError(Exception):
|
|
109
64
|
pass
|
|
110
65
|
|
|
@@ -117,29 +72,125 @@ class OIDCAuthError(OIDCError):
|
|
|
117
72
|
class OIDCClient:
|
|
118
73
|
def __init__(
|
|
119
74
|
self,
|
|
120
|
-
authority_url:
|
|
75
|
+
authority_url: HttpUrl,
|
|
121
76
|
client_id: str,
|
|
122
77
|
client_secret: str | None = None,
|
|
123
78
|
*,
|
|
124
79
|
timeout: float = 10.0,
|
|
125
|
-
|
|
80
|
+
verify_ssl: bool = True,
|
|
81
|
+
connection_limit: int = 100,
|
|
82
|
+
authority_internal_host: HttpUrl | None = None,
|
|
126
83
|
) -> None:
|
|
127
|
-
|
|
84
|
+
if authority_internal_host:
|
|
85
|
+
authority_url = replace_origin(authority_url, authority_internal_host)
|
|
86
|
+
|
|
87
|
+
self._authority_url = authority_url
|
|
88
|
+
self._authority_internal_host = authority_internal_host
|
|
128
89
|
self._client_id = client_id
|
|
129
90
|
self._client_secret = client_secret
|
|
130
|
-
|
|
131
|
-
self.
|
|
132
|
-
self.
|
|
91
|
+
|
|
92
|
+
self._connection_limit = connection_limit
|
|
93
|
+
self._session: aiohttp.ClientSession | None = None
|
|
94
|
+
self._timeout = timeout
|
|
95
|
+
self._verify_ssl = verify_ssl
|
|
96
|
+
|
|
97
|
+
self._config: Mapping[str, Any] | None = None
|
|
98
|
+
self._jwks: Mapping[str, Any] | None = None
|
|
99
|
+
|
|
100
|
+
def get_session(self) -> aiohttp.ClientSession:
|
|
101
|
+
if self._session:
|
|
102
|
+
return self._session
|
|
103
|
+
|
|
104
|
+
with _OIDC_LOCK:
|
|
105
|
+
if self._session:
|
|
106
|
+
return self._session
|
|
107
|
+
|
|
108
|
+
connector = aiohttp.TCPConnector(verify_ssl=self._verify_ssl, limit=self._connection_limit)
|
|
109
|
+
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
|
110
|
+
session = aiohttp.ClientSession(connector=connector, timeout=timeout)
|
|
111
|
+
self._session = session
|
|
112
|
+
|
|
113
|
+
return session
|
|
133
114
|
|
|
134
115
|
async def __aenter__(self) -> Self:
|
|
135
|
-
|
|
136
|
-
|
|
116
|
+
self.get_session()
|
|
117
|
+
|
|
137
118
|
return self
|
|
138
119
|
|
|
139
120
|
async def __aexit__(self, *_: object) -> None:
|
|
140
|
-
if self.
|
|
121
|
+
if self._session:
|
|
141
122
|
await self._session.close()
|
|
142
123
|
|
|
124
|
+
async def _fetch_config(self) -> dict:
|
|
125
|
+
"""
|
|
126
|
+
Fetch the OpenID configuration (including JWKS URI) from OIDC authority.
|
|
127
|
+
"""
|
|
128
|
+
if self._session is None:
|
|
129
|
+
msg = 'ClientSession not initialized'
|
|
130
|
+
raise RuntimeError(msg)
|
|
131
|
+
|
|
132
|
+
oidc_config_url = f'{self._authority_url}/.well-known/openid-configuration'
|
|
133
|
+
|
|
134
|
+
logger.debug('Fetching OpenID configuration from: %s', oidc_config_url)
|
|
135
|
+
|
|
136
|
+
async with self._session.get(oidc_config_url) as response:
|
|
137
|
+
if response.status != HTTPStatus.OK:
|
|
138
|
+
_msg = 'Failed to fetch OpenID configuration'
|
|
139
|
+
|
|
140
|
+
raise RuntimeError(_msg)
|
|
141
|
+
|
|
142
|
+
return await response.json()
|
|
143
|
+
|
|
144
|
+
async def get_config(self) -> Mapping[str, Any]:
|
|
145
|
+
if self._config:
|
|
146
|
+
return self._config
|
|
147
|
+
|
|
148
|
+
with _OIDC_LOCK:
|
|
149
|
+
if self._config:
|
|
150
|
+
return self._config
|
|
151
|
+
|
|
152
|
+
config = await self._fetch_config()
|
|
153
|
+
self._config = config
|
|
154
|
+
|
|
155
|
+
return config
|
|
156
|
+
|
|
157
|
+
async def _fetch_jwks(self, jwks_uri: str) -> dict:
|
|
158
|
+
"""
|
|
159
|
+
Fetch the JSON Web Key Set (JWKS) for validating the token's signature.
|
|
160
|
+
"""
|
|
161
|
+
if self._session is None:
|
|
162
|
+
msg = 'ClientSession not initialized'
|
|
163
|
+
raise RuntimeError(msg)
|
|
164
|
+
|
|
165
|
+
if authority_internal_host := self._authority_internal_host:
|
|
166
|
+
logger.debug('Received jwks_uri: %s', jwks_uri)
|
|
167
|
+
logger.debug('Replacing OIDC authority host with: %s', authority_internal_host)
|
|
168
|
+
jwks_uri = str(replace_origin(HttpUrl(jwks_uri), authority_internal_host))
|
|
169
|
+
logger.debug('Modified jwks_uri: %s', jwks_uri)
|
|
170
|
+
|
|
171
|
+
async with self._session.get(jwks_uri) as response:
|
|
172
|
+
if response.status != HTTPStatus.OK:
|
|
173
|
+
_msg = 'Failed to fetch JWKS'
|
|
174
|
+
|
|
175
|
+
raise RuntimeError(_msg)
|
|
176
|
+
|
|
177
|
+
return await response.json()
|
|
178
|
+
|
|
179
|
+
async def get_jwks(self) -> Mapping[str, Any]:
|
|
180
|
+
if self._jwks:
|
|
181
|
+
return self._jwks
|
|
182
|
+
|
|
183
|
+
with _OIDC_LOCK:
|
|
184
|
+
if self._jwks:
|
|
185
|
+
return self._jwks
|
|
186
|
+
|
|
187
|
+
oidc_config = await self.get_config()
|
|
188
|
+
|
|
189
|
+
jwks = await self._fetch_jwks(oidc_config['jwks_uri'])
|
|
190
|
+
self._jwks = jwks
|
|
191
|
+
|
|
192
|
+
return jwks
|
|
193
|
+
|
|
143
194
|
async def fetch_token(
|
|
144
195
|
self,
|
|
145
196
|
*,
|
|
@@ -149,7 +200,6 @@ class OIDCClient:
|
|
|
149
200
|
) -> OIDCTokenResponse:
|
|
150
201
|
if self._session is None:
|
|
151
202
|
msg = 'ClientSession not initialized'
|
|
152
|
-
|
|
153
203
|
raise RuntimeError(msg)
|
|
154
204
|
|
|
155
205
|
data = {
|
|
@@ -163,36 +213,46 @@ class OIDCClient:
|
|
|
163
213
|
if self._client_secret:
|
|
164
214
|
data['client_secret'] = self._client_secret
|
|
165
215
|
|
|
216
|
+
openid_config = await self.get_config()
|
|
217
|
+
|
|
166
218
|
try:
|
|
167
219
|
async with self._session.post(
|
|
168
|
-
|
|
220
|
+
openid_config['token_endpoint'],
|
|
169
221
|
data=data,
|
|
170
222
|
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
|
171
|
-
) as
|
|
172
|
-
payload = await
|
|
173
|
-
decoder = msgspec.json.Decoder(type=OIDCTokenResponse)
|
|
174
|
-
token = decoder.decode(payload)
|
|
223
|
+
) as response:
|
|
224
|
+
payload = await response.read()
|
|
175
225
|
|
|
176
|
-
|
|
177
|
-
|
|
226
|
+
try:
|
|
227
|
+
body = msgspec.json.decode(payload)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
msg = f'Non-JSON response from OIDC provider: {payload[:300]!r}'
|
|
230
|
+
raise OIDCError(msg) from e
|
|
178
231
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
232
|
+
if response.status >= 400:
|
|
233
|
+
error = None
|
|
234
|
+
description = None
|
|
182
235
|
|
|
183
|
-
|
|
236
|
+
if isinstance(body, dict):
|
|
237
|
+
error = body.get('error') or body.get('code')
|
|
238
|
+
description = body.get('error_description') or body.get('message') or ''
|
|
184
239
|
|
|
185
|
-
|
|
186
|
-
error = token.error
|
|
187
|
-
description = token.error_description
|
|
240
|
+
error = error or f'http_{response.status}'
|
|
188
241
|
|
|
189
|
-
|
|
190
|
-
|
|
242
|
+
if error in {'invalid_grant', 'invalid_client'}:
|
|
243
|
+
msg = f'{error}: {description}'
|
|
244
|
+
raise OIDCAuthError(msg)
|
|
191
245
|
|
|
192
|
-
|
|
246
|
+
msg = f'{error}: {description}'
|
|
247
|
+
raise OIDCError(msg)
|
|
193
248
|
|
|
194
|
-
|
|
249
|
+
decoder = msgspec.json.Decoder(OIDCTokenResponse)
|
|
195
250
|
|
|
196
|
-
|
|
251
|
+
return decoder.decode(payload)
|
|
197
252
|
|
|
198
|
-
|
|
253
|
+
except TimeoutError as e:
|
|
254
|
+
msg = 'OIDC request timed out'
|
|
255
|
+
raise OIDCError(msg) from e
|
|
256
|
+
except aiohttp.ClientError as e:
|
|
257
|
+
msg = 'OIDC transport error'
|
|
258
|
+
raise OIDCError(msg) from e
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python3-commons
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.20.0
|
|
4
4
|
Summary: Re-usable Python3 code
|
|
5
5
|
Author-email: Oleg Korsak <kamikaze.is.waiting.you@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0
|
|
@@ -36,7 +36,7 @@ Provides-Extra: cache
|
|
|
36
36
|
Requires-Dist: valkey[libvalkey]~=6.1.1; extra == "cache"
|
|
37
37
|
Provides-Extra: database
|
|
38
38
|
Requires-Dist: asyncpg~=0.31.0; extra == "database"
|
|
39
|
-
Requires-Dist: SQLAlchemy[asyncio]~=2.0.
|
|
39
|
+
Requires-Dist: SQLAlchemy[asyncio]~=2.0.50; extra == "database"
|
|
40
40
|
Provides-Extra: object-storage
|
|
41
41
|
Requires-Dist: aiobotocore~=3.7.0; extra == "object-storage"
|
|
42
42
|
Requires-Dist: object-storage-client==0.0.30; extra == "object-storage"
|
|
@@ -2,7 +2,7 @@ python3_commons/__init__.py,sha256=0KgaYU46H_IMKn-BuasoRN3C4Hi45KlkHHoPbU9cwiA,1
|
|
|
2
2
|
python3_commons/api_client.py,sha256=yerFJNY_SHhYo9FGLv29oHVIGgeXDNzTzMNfFYZpZ0w,5501
|
|
3
3
|
python3_commons/async_functools.py,sha256=A2HvwFzZHxOWTp4IQM5UiBY2yg1S_0U1CWra5BWK0gk,9101
|
|
4
4
|
python3_commons/audit.py,sha256=uGoCwenDJ0Gdwbr_VNOZm5scT8luxW1weprJbbMoHo0,2608
|
|
5
|
-
python3_commons/auth.py,sha256=
|
|
5
|
+
python3_commons/auth.py,sha256=iN5Iu8yDk2ClPjQuyxDudWICnjX6gt8dOw5e0kXoRdc,7657
|
|
6
6
|
python3_commons/cache.py,sha256=lowiXJqFgFy1Yg86wi9IhuoNqIUGP6nc5eNibmf0dfY,8018
|
|
7
7
|
python3_commons/conf.py,sha256=5WzGLwCixc5SMdWvq3j4YBcytqYwcTcCJkFzSPp8fK4,2984
|
|
8
8
|
python3_commons/exceptions.py,sha256=EGjHZVBnsM6CeBfPMqhL0IPMKjDJ_2-Z-aSPXwq91LE,36
|
|
@@ -27,9 +27,9 @@ python3_commons/serializers/common.py,sha256=VkA7C6wODvHk0QBXVX_x2JieDstihx3U__U
|
|
|
27
27
|
python3_commons/serializers/json.py,sha256=UPkC3ps13x2C_NxwVV-K7Ewp4VjkVHSSUkJVw5k7Wiw,712
|
|
28
28
|
python3_commons/serializers/msgpack.py,sha256=zESFBX34GsZ8rDu6Zk5V6CLT6P0mPilU0r04Ka6TblI,1474
|
|
29
29
|
python3_commons/serializers/msgspec.py,sha256=upy5CBmK66-8hYnK5bAM_sZvZY5CAqZmzCw9GIF346I,2988
|
|
30
|
-
python3_commons-0.
|
|
31
|
-
python3_commons-0.
|
|
32
|
-
python3_commons-0.
|
|
33
|
-
python3_commons-0.
|
|
34
|
-
python3_commons-0.
|
|
35
|
-
python3_commons-0.
|
|
30
|
+
python3_commons-0.20.0.dist-info/licenses/AUTHORS.rst,sha256=3R9JnfjfjH5RoPWOeqKFJgxVShSSfzQPIrEr1nxIo9Q,90
|
|
31
|
+
python3_commons-0.20.0.dist-info/licenses/LICENSE,sha256=xxILuojHm4fKQOrMHPSslbyy6WuKAN2RiG74HbrYfzM,34575
|
|
32
|
+
python3_commons-0.20.0.dist-info/METADATA,sha256=LbLpc_NPqyj1HQSCDciYLcfOk31s8RWj942uXvrrCis,2333
|
|
33
|
+
python3_commons-0.20.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
34
|
+
python3_commons-0.20.0.dist-info/top_level.txt,sha256=lJI6sCBf68eUHzupCnn2dzG10lH3jJKTWM_hrN1cQ7M,16
|
|
35
|
+
python3_commons-0.20.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|