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.
Files changed (77) hide show
  1. {python3_commons-0.18.21 → python3_commons-0.19.0}/.env_template +6 -0
  2. {python3_commons-0.18.21/src/python3_commons.egg-info → python3_commons-0.19.0}/PKG-INFO +1 -1
  3. python3_commons-0.19.0/src/python3_commons/auth.py +241 -0
  4. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/helpers.py +1 -1
  5. {python3_commons-0.18.21 → python3_commons-0.19.0/src/python3_commons.egg-info}/PKG-INFO +1 -1
  6. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/SOURCES.txt +2 -0
  7. python3_commons-0.19.0/tests/integration/conftest.py +29 -0
  8. python3_commons-0.19.0/tests/integration/test_auth.py +29 -0
  9. python3_commons-0.18.21/src/python3_commons/auth.py +0 -198
  10. {python3_commons-0.18.21 → python3_commons-0.19.0}/.coveragerc +0 -0
  11. {python3_commons-0.18.21 → python3_commons-0.19.0}/.devcontainer/Dockerfile +0 -0
  12. {python3_commons-0.18.21 → python3_commons-0.19.0}/.devcontainer/devcontainer.json +0 -0
  13. {python3_commons-0.18.21 → python3_commons-0.19.0}/.devcontainer/docker-compose.yml +0 -0
  14. {python3_commons-0.18.21 → python3_commons-0.19.0}/.github/workflows/checks.yml +0 -0
  15. {python3_commons-0.18.21 → python3_commons-0.19.0}/.github/workflows/python-publish.yaml +0 -0
  16. {python3_commons-0.18.21 → python3_commons-0.19.0}/.github/workflows/release-on-tag-push.yml +0 -0
  17. {python3_commons-0.18.21 → python3_commons-0.19.0}/.gitignore +0 -0
  18. {python3_commons-0.18.21 → python3_commons-0.19.0}/.pre-commit-config.yaml +0 -0
  19. {python3_commons-0.18.21 → python3_commons-0.19.0}/.python-version +0 -0
  20. {python3_commons-0.18.21 → python3_commons-0.19.0}/AUTHORS.rst +0 -0
  21. {python3_commons-0.18.21 → python3_commons-0.19.0}/CHANGELOG.rst +0 -0
  22. {python3_commons-0.18.21 → python3_commons-0.19.0}/LICENSE +0 -0
  23. {python3_commons-0.18.21 → python3_commons-0.19.0}/README.md +0 -0
  24. {python3_commons-0.18.21 → python3_commons-0.19.0}/README.rst +0 -0
  25. {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/Makefile +0 -0
  26. {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/_static/.gitignore +0 -0
  27. {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/authors.rst +0 -0
  28. {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/changelog.rst +0 -0
  29. {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/conf.py +0 -0
  30. {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/index.rst +0 -0
  31. {python3_commons-0.18.21 → python3_commons-0.19.0}/docs/license.rst +0 -0
  32. {python3_commons-0.18.21 → python3_commons-0.19.0}/pyproject.toml +0 -0
  33. {python3_commons-0.18.21 → python3_commons-0.19.0}/setup.cfg +0 -0
  34. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/__init__.py +0 -0
  35. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/api_client.py +0 -0
  36. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/async_functools.py +0 -0
  37. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/audit.py +0 -0
  38. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/cache.py +0 -0
  39. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/conf.py +0 -0
  40. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/__init__.py +0 -0
  41. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/helpers.py +0 -0
  42. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/__init__.py +0 -0
  43. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/auth.py +0 -0
  44. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/common.py +0 -0
  45. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/rbac.py +0 -0
  46. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/db/models/users.py +0 -0
  47. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/exceptions.py +0 -0
  48. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/fs.py +0 -0
  49. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/generators.py +0 -0
  50. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/log/__init__.py +0 -0
  51. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/log/filters.py +0 -0
  52. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/log/formatters.py +0 -0
  53. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/object_storage.py +0 -0
  54. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/permissions.py +0 -0
  55. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/__init__.py +0 -0
  56. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/common.py +0 -0
  57. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/json.py +0 -0
  58. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/msgpack.py +0 -0
  59. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/serializers/msgspec.py +0 -0
  60. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons/soap_client.py +0 -0
  61. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/dependency_links.txt +0 -0
  62. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/requires.txt +0 -0
  63. {python3_commons-0.18.21 → python3_commons-0.19.0}/src/python3_commons.egg-info/top_level.txt +0 -0
  64. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/__init__.py +0 -0
  65. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/integration/__init__.py +0 -0
  66. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/integration/test_cache.py +0 -0
  67. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/integration/test_osc.py +0 -0
  68. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/__init__.py +0 -0
  69. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/conftest.py +0 -0
  70. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/log/__init__.py +0 -0
  71. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/log/test_formatters.py +0 -0
  72. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_async_functools.py +0 -0
  73. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_audit.py +0 -0
  74. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_helpers.py +0 -0
  75. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_msgpack.py +0 -0
  76. {python3_commons-0.18.21 → python3_commons-0.19.0}/tests/unit/test_msgspec.py +0 -0
  77. {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=
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.18.21
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
@@ -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
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python3-commons
3
- Version: 0.18.21
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
@@ -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