python3-commons 0.18.22__py3-none-any.whl → 0.19.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 +120 -77
- {python3_commons-0.18.22.dist-info → python3_commons-0.19.0.dist-info}/METADATA +1 -1
- {python3_commons-0.18.22.dist-info → python3_commons-0.19.0.dist-info}/RECORD +7 -7
- {python3_commons-0.18.22.dist-info → python3_commons-0.19.0.dist-info}/WHEEL +0 -0
- {python3_commons-0.18.22.dist-info → python3_commons-0.19.0.dist-info}/licenses/AUTHORS.rst +0 -0
- {python3_commons-0.18.22.dist-info → python3_commons-0.19.0.dist-info}/licenses/LICENSE +0 -0
- {python3_commons-0.18.22.dist-info → python3_commons-0.19.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
|
|
|
@@ -20,6 +19,7 @@ import msgspec
|
|
|
20
19
|
from python3_commons.conf import oidc_settings
|
|
21
20
|
|
|
22
21
|
logger = logging.getLogger(__name__)
|
|
22
|
+
_OIDC_LOCK = threading.Lock()
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class TokenData(msgspec.Struct):
|
|
@@ -62,49 +62,6 @@ class OIDCTokenResponse(msgspec.Struct):
|
|
|
62
62
|
error_description: str | None = None
|
|
63
63
|
|
|
64
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
65
|
class OIDCError(Exception):
|
|
109
66
|
pass
|
|
110
67
|
|
|
@@ -117,29 +74,106 @@ class OIDCAuthError(OIDCError):
|
|
|
117
74
|
class OIDCClient:
|
|
118
75
|
def __init__(
|
|
119
76
|
self,
|
|
120
|
-
authority_url:
|
|
77
|
+
authority_url: HttpUrl,
|
|
121
78
|
client_id: str,
|
|
122
79
|
client_secret: str | None = None,
|
|
123
80
|
*,
|
|
124
81
|
timeout: float = 10.0,
|
|
125
|
-
|
|
82
|
+
verify_ssl: bool = True,
|
|
83
|
+
connection_limit: int = 100,
|
|
126
84
|
) -> None:
|
|
127
|
-
|
|
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
|
|
128
89
|
self._client_id = client_id
|
|
129
90
|
self._client_secret = client_secret
|
|
130
|
-
self.
|
|
131
|
-
|
|
132
|
-
self.
|
|
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
|
|
133
112
|
|
|
134
113
|
async def __aenter__(self) -> Self:
|
|
135
|
-
|
|
136
|
-
|
|
114
|
+
self.get_session()
|
|
115
|
+
|
|
137
116
|
return self
|
|
138
117
|
|
|
139
118
|
async def __aexit__(self, *_: object) -> None:
|
|
140
|
-
if self.
|
|
119
|
+
if self._session:
|
|
141
120
|
await self._session.close()
|
|
142
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
|
+
|
|
143
177
|
async def fetch_token(
|
|
144
178
|
self,
|
|
145
179
|
*,
|
|
@@ -149,7 +183,6 @@ class OIDCClient:
|
|
|
149
183
|
) -> OIDCTokenResponse:
|
|
150
184
|
if self._session is None:
|
|
151
185
|
msg = 'ClientSession not initialized'
|
|
152
|
-
|
|
153
186
|
raise RuntimeError(msg)
|
|
154
187
|
|
|
155
188
|
data = {
|
|
@@ -163,36 +196,46 @@ class OIDCClient:
|
|
|
163
196
|
if self._client_secret:
|
|
164
197
|
data['client_secret'] = self._client_secret
|
|
165
198
|
|
|
199
|
+
openid_config = await self.get_openid_config()
|
|
200
|
+
|
|
166
201
|
try:
|
|
167
202
|
async with self._session.post(
|
|
168
|
-
|
|
203
|
+
openid_config['token_endpoint'],
|
|
169
204
|
data=data,
|
|
170
205
|
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)
|
|
206
|
+
) as response:
|
|
207
|
+
payload = await response.read()
|
|
175
208
|
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
178
214
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
215
|
+
if response.status >= 400:
|
|
216
|
+
error = None
|
|
217
|
+
description = None
|
|
182
218
|
|
|
183
|
-
|
|
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 ''
|
|
184
222
|
|
|
185
|
-
|
|
186
|
-
error = token.error
|
|
187
|
-
description = token.error_description
|
|
223
|
+
error = error or f'http_{response.status}'
|
|
188
224
|
|
|
189
|
-
|
|
190
|
-
|
|
225
|
+
if error in {'invalid_grant', 'invalid_client'}:
|
|
226
|
+
msg = f'{error}: {description}'
|
|
227
|
+
raise OIDCAuthError(msg)
|
|
191
228
|
|
|
192
|
-
|
|
229
|
+
msg = f'{error}: {description}'
|
|
230
|
+
raise OIDCError(msg)
|
|
193
231
|
|
|
194
|
-
|
|
232
|
+
decoder = msgspec.json.Decoder(OIDCTokenResponse)
|
|
195
233
|
|
|
196
|
-
|
|
234
|
+
return decoder.decode(payload)
|
|
197
235
|
|
|
198
|
-
|
|
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
|
|
@@ -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=rrjkUAvOb7C6h38lJoD7DDlrX6QaSluN9qM9rox6Wh4,7274
|
|
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.19.0.dist-info/licenses/AUTHORS.rst,sha256=3R9JnfjfjH5RoPWOeqKFJgxVShSSfzQPIrEr1nxIo9Q,90
|
|
31
|
+
python3_commons-0.19.0.dist-info/licenses/LICENSE,sha256=xxILuojHm4fKQOrMHPSslbyy6WuKAN2RiG74HbrYfzM,34575
|
|
32
|
+
python3_commons-0.19.0.dist-info/METADATA,sha256=UXJKgK1zXPhoqS4SBGnZJoxgvPO0Z1_aXCKL5Rp04kQ,2333
|
|
33
|
+
python3_commons-0.19.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
34
|
+
python3_commons-0.19.0.dist-info/top_level.txt,sha256=lJI6sCBf68eUHzupCnn2dzG10lH3jJKTWM_hrN1cQ7M,16
|
|
35
|
+
python3_commons-0.19.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|