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 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
 
@@ -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: str,
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
- session: aiohttp.ClientSession | None = None,
82
+ verify_ssl: bool = True,
83
+ connection_limit: int = 100,
126
84
  ) -> None:
127
- self._token_url = f'{authority_url}/protocol/openid-connect/token' # TODO: get it from openid-configuration
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._timeout = aiohttp.ClientTimeout(total=timeout)
131
- self._session = session
132
- self._owns_session = session is None
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
- if self._session is None:
136
- self._session = aiohttp.ClientSession(timeout=self._timeout)
114
+ self.get_session()
115
+
137
116
  return self
138
117
 
139
118
  async def __aexit__(self, *_: object) -> None:
140
- if self._owns_session and self._session:
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
- self._token_url,
203
+ openid_config['token_endpoint'],
169
204
  data=data,
170
205
  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)
206
+ ) as response:
207
+ payload = await response.read()
175
208
 
176
- except TimeoutError as e:
177
- msg = 'OIDC request timed out'
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
- raise OIDCError(msg) from e
180
- except aiohttp.ClientError as e:
181
- msg = 'OIDC transport error'
215
+ if response.status >= 400:
216
+ error = None
217
+ description = None
182
218
 
183
- raise OIDCError(msg) from e
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
- if not resp.ok:
186
- error = token.error
187
- description = token.error_description
223
+ error = error or f'http_{response.status}'
188
224
 
189
- if error in {'invalid_grant', 'invalid_client'}:
190
- msg = f'{error}: {description}'
225
+ if error in {'invalid_grant', 'invalid_client'}:
226
+ msg = f'{error}: {description}'
227
+ raise OIDCAuthError(msg)
191
228
 
192
- raise OIDCAuthError(msg)
229
+ msg = f'{error}: {description}'
230
+ raise OIDCError(msg)
193
231
 
194
- msg = f'{error}: {description}'
232
+ decoder = msgspec.json.Decoder(OIDCTokenResponse)
195
233
 
196
- raise OIDCError(msg)
234
+ return decoder.decode(payload)
197
235
 
198
- return token
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.18.22
3
+ Version: 0.19.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
@@ -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=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.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.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,,