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 CHANGED
@@ -1,9 +1,8 @@
1
- from __future__ import annotations
2
-
3
1
  import logging
4
- from collections.abc import Sequence
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: str,
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
- session: aiohttp.ClientSession | None = None,
80
+ verify_ssl: bool = True,
81
+ connection_limit: int = 100,
82
+ authority_internal_host: HttpUrl | None = None,
126
83
  ) -> None:
127
- self._token_url = f'{authority_url}/protocol/openid-connect/token' # TODO: get it from openid-configuration
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
- self._timeout = aiohttp.ClientTimeout(total=timeout)
131
- self._session = session
132
- self._owns_session = session is None
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
- if self._session is None:
136
- self._session = aiohttp.ClientSession(timeout=self._timeout)
116
+ self.get_session()
117
+
137
118
  return self
138
119
 
139
120
  async def __aexit__(self, *_: object) -> None:
140
- if self._owns_session and self._session:
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
- self._token_url,
220
+ openid_config['token_endpoint'],
169
221
  data=data,
170
222
  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)
223
+ ) as response:
224
+ payload = await response.read()
175
225
 
176
- except TimeoutError as e:
177
- msg = 'OIDC request timed out'
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
- raise OIDCError(msg) from e
180
- except aiohttp.ClientError as e:
181
- msg = 'OIDC transport error'
232
+ if response.status >= 400:
233
+ error = None
234
+ description = None
182
235
 
183
- raise OIDCError(msg) from e
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
- if not resp.ok:
186
- error = token.error
187
- description = token.error_description
240
+ error = error or f'http_{response.status}'
188
241
 
189
- if error in {'invalid_grant', 'invalid_client'}:
190
- msg = f'{error}: {description}'
242
+ if error in {'invalid_grant', 'invalid_client'}:
243
+ msg = f'{error}: {description}'
244
+ raise OIDCAuthError(msg)
191
245
 
192
- raise OIDCAuthError(msg)
246
+ msg = f'{error}: {description}'
247
+ raise OIDCError(msg)
193
248
 
194
- msg = f'{error}: {description}'
249
+ decoder = msgspec.json.Decoder(OIDCTokenResponse)
195
250
 
196
- raise OIDCError(msg)
251
+ return decoder.decode(payload)
197
252
 
198
- return token
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.18.22
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.49; extra == "database"
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=1dSD5GhcI9hPcomIEkf6fRM8c4LrXqwkqpJI-pDzfFE,5625
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.18.22.dist-info/licenses/AUTHORS.rst,sha256=3R9JnfjfjH5RoPWOeqKFJgxVShSSfzQPIrEr1nxIo9Q,90
31
- python3_commons-0.18.22.dist-info/licenses/LICENSE,sha256=xxILuojHm4fKQOrMHPSslbyy6WuKAN2RiG74HbrYfzM,34575
32
- python3_commons-0.18.22.dist-info/METADATA,sha256=2FabtGqH5D6_GjdPMpT6E98vi0XAQqvdh4gMRKyteI0,2334
33
- python3_commons-0.18.22.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
34
- python3_commons-0.18.22.dist-info/top_level.txt,sha256=lJI6sCBf68eUHzupCnn2dzG10lH3jJKTWM_hrN1cQ7M,16
35
- python3_commons-0.18.22.dist-info/RECORD,,
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,,