python-general-be-lib 0.11.2__tar.gz → 0.12.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.
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/PKG-INFO +11 -4
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/README.md +4 -1
- python_general_be_lib-0.12.0/general/auth/__init__.py +68 -0
- python_general_be_lib-0.12.0/general/auth/jwt_model.py +92 -0
- python_general_be_lib-0.12.0/general/auth/key_provider.py +203 -0
- python_general_be_lib-0.12.0/general/auth/keycloak_auth.py +376 -0
- python_general_be_lib-0.12.0/general/auth/middleware.py +94 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/context.py +18 -4
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/exception/access_exceptions.py +3 -3
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/exception/crud_exceptions.py +2 -1
- python_general_be_lib-0.12.0/general/interface/base/__init__.py +59 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/base/declarative_base.py +2 -1
- python_general_be_lib-0.12.0/general/interface/metadata/__init__.py +58 -0
- python_general_be_lib-0.12.0/general/interface/repository/__init__.py +59 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/crud_repository.py +8 -1
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/handler.py +0 -19
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/statement_manager.py +36 -4
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/logger.py +11 -3
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/pyproject.toml +10 -3
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/python_general_be_lib.egg-info/PKG-INFO +11 -4
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/python_general_be_lib.egg-info/SOURCES.txt +9 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/python_general_be_lib.egg-info/requires.txt +5 -0
- python_general_be_lib-0.12.0/tests/test_auth_chiavi.py +298 -0
- python_general_be_lib-0.12.0/tests/test_auth_fastapi.py +355 -0
- python_general_be_lib-0.12.0/tests/test_auth_token.py +283 -0
- python_general_be_lib-0.12.0/tests/test_costo_import.py +158 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_import_pubblici.py +72 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_logger.py +81 -23
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_pipeline_handler.py +50 -0
- python_general_be_lib-0.11.2/general/interface/base/__init__.py +0 -12
- python_general_be_lib-0.11.2/general/interface/metadata/__init__.py +0 -12
- python_general_be_lib-0.11.2/general/interface/repository/__init__.py +0 -13
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/LICENSE +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/exception/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/exception/exception_interface.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/base/base_model.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/metadata/crud_metadata.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/metadata/geom_metadata.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/mixin/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/mixin/audit_mixin.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/mixin/softdelete_mixin.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/mixin/timestamp_mixin.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/geometry_repository.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/base_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/ilike_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/interval_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/json_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/json_ilike_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/or_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/soft_delete_handler.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/many_to_many_repository.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/view_repository.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/paginator_dto.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/py.typed +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/utils/__init__.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/utils/consts.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/python_general_be_lib.egg-info/dependency_links.txt +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/python_general_be_lib.egg-info/top_level.txt +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/setup.cfg +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_caricamento_parziale.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_compiled_cache.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_engine_defaults.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_entity_has_columns.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_execute_contratto.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_exists.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_find_one.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_get.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_has_columns.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_many_to_many.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_eccezioni.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_geom.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_paginazione.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_pipeline.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_scorciatoie.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_mixin.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_negazione.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_ordinamento.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_query_count.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_retrieve_mapper.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_smoke_crud.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_softdelete.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_sql_snapshot.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_to_dict.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_to_pydantic.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_upsert_chiavi.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_valori_falsy.py +0 -0
- {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_view_repository.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-general-be-lib
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, logger utilities.
|
|
3
|
+
Version: 0.12.0
|
|
4
|
+
Summary: General purpose backend library — SQLAlchemy CRUD/Geometry repositories, FastAPI exceptions, Pydantic base models, Keycloak JWT auth, logger utilities.
|
|
5
5
|
Author-email: Andrea Di Placido <a.diplacido@arpes.it>, "Arpes S.r.l." <it.admin@arpes.it>
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/Arpes-IS/python-general-be-lib
|
|
8
8
|
Project-URL: Repository, https://github.com/Arpes-IS/python-general-be-lib
|
|
9
9
|
Project-URL: Changelog, https://github.com/Arpes-IS/python-general-be-lib/blob/main/CHANGELOG.md
|
|
10
|
-
Keywords: backend,sqlalchemy,fastapi,pydantic,repository,crud,geoalchemy2
|
|
10
|
+
Keywords: backend,sqlalchemy,fastapi,pydantic,repository,crud,geoalchemy2,keycloak,jwt
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -23,7 +23,10 @@ Requires-Dist: fastapi>=0.100.0
|
|
|
23
23
|
Requires-Dist: pydantic>=2.0.0
|
|
24
24
|
Requires-Dist: sqlalchemy>=2.0.0
|
|
25
25
|
Requires-Dist: geoalchemy2>=0.14.0
|
|
26
|
+
Provides-Extra: auth
|
|
27
|
+
Requires-Dist: pyjwt[crypto]>=2.8; extra == "auth"
|
|
26
28
|
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pyjwt[crypto]>=2.8; extra == "dev"
|
|
27
30
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
28
31
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
29
32
|
Requires-Dist: pytest-benchmark>=5; extra == "dev"
|
|
@@ -31,13 +34,14 @@ Requires-Dist: testcontainers[postgres]>=4; extra == "dev"
|
|
|
31
34
|
Requires-Dist: psycopg[binary]>=3; extra == "dev"
|
|
32
35
|
Requires-Dist: build; extra == "dev"
|
|
33
36
|
Requires-Dist: twine; extra == "dev"
|
|
37
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
34
38
|
Dynamic: license-file
|
|
35
39
|
|
|
36
40
|
# python-general-be-lib
|
|
37
41
|
|
|
38
42
|
Libreria backend general purpose per progetti Python: repository CRUD e spaziali
|
|
39
43
|
su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
|
|
40
|
-
**Pydantic** e utility di logging.
|
|
44
|
+
**Pydantic**, autenticazione **Keycloak** e utility di logging.
|
|
41
45
|
|
|
42
46
|
- Distribuzione PyPI: `python-general-be-lib`
|
|
43
47
|
- Pacchetto importabile: `general`
|
|
@@ -45,6 +49,7 @@ su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
|
|
|
45
49
|
|
|
46
50
|
```bash
|
|
47
51
|
pip install python-general-be-lib
|
|
52
|
+
pip install 'python-general-be-lib[auth]' # con l'autenticazione Keycloak (pyjwt[crypto])
|
|
48
53
|
```
|
|
49
54
|
|
|
50
55
|
```python
|
|
@@ -71,6 +76,8 @@ repo.find(active=True, order_by="name", asc=True)
|
|
|
71
76
|
| Basi | `Base` (declarative base estesa), `ExtBaseModel`, `CamelExtBaseModel` |
|
|
72
77
|
| Paginazione | `PaginatorRequestModel`, `PaginatorResponseModel` |
|
|
73
78
|
| Eccezioni | `ExceptionInterface` e sottoclassi HTTP (400/401/403/404/422/500/503) |
|
|
79
|
+
| Autenticazione (extra `[auth]`) | `KeycloakAuth`, `AuthContextMiddleware`, `JWTModel`, `RoleChecker` — verifica dei token Keycloak via JWKS, dependency FastAPI, ruoli |
|
|
80
|
+
| Contesto | `current_user`, `current_jwt` — utente e claim della richiesta, per l'audit |
|
|
74
81
|
| Logging | `init_logger`, decorator `@log` |
|
|
75
82
|
|
|
76
83
|
---
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Libreria backend general purpose per progetti Python: repository CRUD e spaziali
|
|
4
4
|
su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
|
|
5
|
-
**Pydantic** e utility di logging.
|
|
5
|
+
**Pydantic**, autenticazione **Keycloak** e utility di logging.
|
|
6
6
|
|
|
7
7
|
- Distribuzione PyPI: `python-general-be-lib`
|
|
8
8
|
- Pacchetto importabile: `general`
|
|
@@ -10,6 +10,7 @@ su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
|
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
pip install python-general-be-lib
|
|
13
|
+
pip install 'python-general-be-lib[auth]' # con l'autenticazione Keycloak (pyjwt[crypto])
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
```python
|
|
@@ -36,6 +37,8 @@ repo.find(active=True, order_by="name", asc=True)
|
|
|
36
37
|
| Basi | `Base` (declarative base estesa), `ExtBaseModel`, `CamelExtBaseModel` |
|
|
37
38
|
| Paginazione | `PaginatorRequestModel`, `PaginatorResponseModel` |
|
|
38
39
|
| Eccezioni | `ExceptionInterface` e sottoclassi HTTP (400/401/403/404/422/500/503) |
|
|
40
|
+
| Autenticazione (extra `[auth]`) | `KeycloakAuth`, `AuthContextMiddleware`, `JWTModel`, `RoleChecker` — verifica dei token Keycloak via JWKS, dependency FastAPI, ruoli |
|
|
41
|
+
| Contesto | `current_user`, `current_jwt` — utente e claim della richiesta, per l'audit |
|
|
39
42
|
| Logging | `init_logger`, decorator `@log` |
|
|
40
43
|
|
|
41
44
|
---
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Authentication against a Keycloak realm, shared by the backends.
|
|
2
|
+
|
|
3
|
+
``KeycloakAuth`` verifies the access tokens of a realm — signature through the keys of its
|
|
4
|
+
JWKS endpoint, expiry, audience, issuer — and exposes the FastAPI dependencies built on
|
|
5
|
+
it; ``AuthContextMiddleware`` verifies the token once per request and publishes the user
|
|
6
|
+
and the claims in ``general.context``. It needs the ``[auth]`` extra (``pyjwt[crypto]``).
|
|
7
|
+
|
|
8
|
+
The public symbols are resolved lazily (PEP 562): importing this package does not import
|
|
9
|
+
the modules behind them, so reaching one of them does not pay for the heavier siblings.
|
|
10
|
+
The ``TYPE_CHECKING`` block is what keeps the static types — without it a type checker
|
|
11
|
+
would see every symbol as ``Any``, silently, and the ``py.typed`` marker would promise
|
|
12
|
+
types the consumer does not get.
|
|
13
|
+
"""
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AuthContextMiddleware",
|
|
18
|
+
"JWTModel",
|
|
19
|
+
"RealmAccessModel",
|
|
20
|
+
"JwksKeyProvider",
|
|
21
|
+
"KeycloakAuth",
|
|
22
|
+
"RoleChecker",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING: # i type checker leggono da qui, il runtime no
|
|
26
|
+
from .jwt_model import JWTModel, RealmAccessModel
|
|
27
|
+
from .key_provider import JwksKeyProvider
|
|
28
|
+
from .keycloak_auth import KeycloakAuth, RoleChecker
|
|
29
|
+
from .middleware import AuthContextMiddleware
|
|
30
|
+
|
|
31
|
+
_MODULES = {
|
|
32
|
+
"JWTModel": ".jwt_model",
|
|
33
|
+
"RealmAccessModel": ".jwt_model",
|
|
34
|
+
"JwksKeyProvider": ".key_provider",
|
|
35
|
+
"KeycloakAuth": ".keycloak_auth",
|
|
36
|
+
"RoleChecker": ".keycloak_auth",
|
|
37
|
+
"AuthContextMiddleware": ".middleware",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __getattr__(name: str):
|
|
42
|
+
"""Resolve a public symbol, importing its module on first access.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
name (str): Name of the attribute being read.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Any: The requested symbol.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
AttributeError: If the name is not part of the public surface.
|
|
52
|
+
"""
|
|
53
|
+
module = _MODULES.get(name)
|
|
54
|
+
if module is None:
|
|
55
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
56
|
+
|
|
57
|
+
from importlib import import_module
|
|
58
|
+
|
|
59
|
+
return getattr(import_module(module, __name__), name)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def __dir__() -> list[str]:
|
|
63
|
+
"""List the public surface without importing anything.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
list[str]: The names in ``__all__``.
|
|
67
|
+
"""
|
|
68
|
+
return list(__all__)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Pydantic models of the claims of a Keycloak access token.
|
|
2
|
+
|
|
3
|
+
``JWTModel`` declares the standard claims Keycloak puts in an access token. The claims a
|
|
4
|
+
realm adds through its own mappers (``user_id``, ``partner_id``, …) do not belong here:
|
|
5
|
+
each backend declares them in a subclass and passes it to ``KeycloakAuth`` as ``model``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = ["JWTModel", "RealmAccessModel"]
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RealmAccessModel(BaseModel):
|
|
16
|
+
"""Roles granted to the user, in the shape Keycloak uses for both realm and client roles.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
roles (list[str]): Names of the roles. Empty when the claim carries none.
|
|
20
|
+
"""
|
|
21
|
+
roles: list[str] = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JWTModel(BaseModel):
|
|
25
|
+
"""Standard claims of a Keycloak access token.
|
|
26
|
+
|
|
27
|
+
Every claim is optional, so a token lacking one validates anyway, and the unknown ones
|
|
28
|
+
are ignored: a realm mapper added after deployment does not break the validation. To
|
|
29
|
+
read a custom claim, declare it in a subclass.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
aud (str | list[str], optional): Audience. Keycloak emits a string with one
|
|
33
|
+
audience and a list with more than one.
|
|
34
|
+
sub (str, optional): Identifier of the user in the realm.
|
|
35
|
+
realm_access (RealmAccessModel, optional): Realm roles of the user.
|
|
36
|
+
resource_access (dict[str, RealmAccessModel], optional): Client roles of the user,
|
|
37
|
+
keyed by client id.
|
|
38
|
+
"""
|
|
39
|
+
model_config = ConfigDict(extra="ignore")
|
|
40
|
+
|
|
41
|
+
exp: Optional[int] = None
|
|
42
|
+
iat: Optional[int] = None
|
|
43
|
+
jti: Optional[str] = None
|
|
44
|
+
iss: Optional[str] = None
|
|
45
|
+
aud: Optional[str | list[str]] = None
|
|
46
|
+
sub: Optional[str] = None
|
|
47
|
+
typ: Optional[str] = None
|
|
48
|
+
azp: Optional[str] = None
|
|
49
|
+
session_state: Optional[str] = None
|
|
50
|
+
acr: Optional[str] = None
|
|
51
|
+
realm_access: Optional[RealmAccessModel] = None
|
|
52
|
+
resource_access: Optional[dict[str, RealmAccessModel]] = None
|
|
53
|
+
scope: Optional[str] = None
|
|
54
|
+
sid: Optional[str] = None
|
|
55
|
+
email_verified: Optional[bool] = None
|
|
56
|
+
name: Optional[str] = None
|
|
57
|
+
preferred_username: Optional[str] = None
|
|
58
|
+
given_name: Optional[str] = None
|
|
59
|
+
family_name: Optional[str] = None
|
|
60
|
+
email: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
def roles_of(self, client: Optional[str] = None) -> list[str]:
|
|
63
|
+
"""Return the roles of the user, empty when the token carries none.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
client (str, optional): Client id whose roles are read from
|
|
67
|
+
``resource_access``. When omitted, the realm roles are read from
|
|
68
|
+
``realm_access``.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
list[str]: The roles, never ``None``.
|
|
72
|
+
"""
|
|
73
|
+
if client is None:
|
|
74
|
+
return self.realm_access.roles if self.realm_access is not None else []
|
|
75
|
+
access = (self.resource_access or {}).get(client)
|
|
76
|
+
return access.roles if access is not None else []
|
|
77
|
+
|
|
78
|
+
def has_role(self, role: str, client: Optional[str] = None) -> bool:
|
|
79
|
+
"""Tell whether the user holds a role.
|
|
80
|
+
|
|
81
|
+
Unlike ``role in jwt.realm_access.roles`` it does not raise when the token carries
|
|
82
|
+
no roles at all.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
role (str): Name of the role.
|
|
86
|
+
client (str, optional): Client id the role belongs to. When omitted, the role
|
|
87
|
+
is looked up among the realm roles.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
bool: ``True`` if the user holds the role.
|
|
91
|
+
"""
|
|
92
|
+
return role in self.roles_of(client)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Signing keys of a Keycloak realm, read from its JWKS endpoint and cached in memory.
|
|
2
|
+
|
|
3
|
+
The JWKS endpoint (``/protocol/openid-connect/certs``) publishes every active key of the
|
|
4
|
+
realm with its ``kid``, and the token header names the one it was signed with: during a
|
|
5
|
+
key rotation both keys are published, so no token is rejected while it happens. The
|
|
6
|
+
``public_key`` field of the realm endpoint, read by the handlers this module replaces,
|
|
7
|
+
carries one key only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__all__ = ["JwksKeyProvider"]
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
from urllib.request import urlopen
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from jwt import PyJWK, PyJWKSet
|
|
21
|
+
except ImportError as e: # pragma: no cover - dipende dagli extra installati
|
|
22
|
+
raise ImportError("general.auth needs the [auth] extra: pip install 'python-general-be-lib[auth]'") from e
|
|
23
|
+
|
|
24
|
+
from ..exception.crud_exceptions import ServiceUnavailableException
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JwksKeyProvider:
|
|
28
|
+
"""Thread-safe, in-memory cache of the signing keys of a realm.
|
|
29
|
+
|
|
30
|
+
The keys are fetched on the first request, and fetched again when the cache is older
|
|
31
|
+
than ``ttl`` or a token names a ``kid`` the cache does not know, which is how a key
|
|
32
|
+
rotation is picked up. Every fetch, forced ones included, is **rate limited** to one per
|
|
33
|
+
``min_refresh_interval``: a token naming a made-up ``kid`` cannot turn each request
|
|
34
|
+
into a call to Keycloak. A failed fetch keeps the keys already cached, so an
|
|
35
|
+
unreachable Keycloak does not reject tokens it signed before going down.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
jwks_url (str): URL of the JWKS endpoint.
|
|
39
|
+
ttl (float): Seconds after which the cached keys are fetched again.
|
|
40
|
+
min_refresh_interval (float): Minimum seconds between two fetch attempts.
|
|
41
|
+
timeout (float): Timeout in seconds of the HTTP request.
|
|
42
|
+
"""
|
|
43
|
+
__slots__ = (
|
|
44
|
+
"jwks_url",
|
|
45
|
+
"ttl",
|
|
46
|
+
"min_refresh_interval",
|
|
47
|
+
"timeout",
|
|
48
|
+
"_fetch",
|
|
49
|
+
"_lock",
|
|
50
|
+
"_keys",
|
|
51
|
+
"_fetched_at",
|
|
52
|
+
"_last_attempt",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
jwks_url: str,
|
|
58
|
+
ttl: float = 3600,
|
|
59
|
+
min_refresh_interval: float = 30,
|
|
60
|
+
timeout: float = 5,
|
|
61
|
+
fetch: Optional[Callable[[], dict[str, Any]]] = None,
|
|
62
|
+
):
|
|
63
|
+
"""Configure the provider. Nothing is fetched until the first key is requested.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
jwks_url (str): URL of the JWKS endpoint, ``http`` or ``https``.
|
|
67
|
+
ttl (float): Seconds after which the cached keys are fetched again.
|
|
68
|
+
Default: ``3600``.
|
|
69
|
+
min_refresh_interval (float): Minimum seconds between two fetch attempts.
|
|
70
|
+
Default: ``30``.
|
|
71
|
+
timeout (float): Timeout in seconds of the HTTP request. Default: ``5``.
|
|
72
|
+
fetch (Callable[[], dict], optional): Function returning the JWKS document,
|
|
73
|
+
in place of the HTTP request to ``jwks_url``.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If ``jwks_url`` is not an ``http(s)`` URL.
|
|
77
|
+
"""
|
|
78
|
+
if not isinstance(jwks_url, str) or not jwks_url.startswith(("http://", "https://")):
|
|
79
|
+
raise ValueError(f"JwksKeyProvider: jwks_url must be an http(s) URL, got {jwks_url!r}")
|
|
80
|
+
self.jwks_url = jwks_url
|
|
81
|
+
self.ttl = ttl
|
|
82
|
+
self.min_refresh_interval = min_refresh_interval
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
self._fetch = fetch if fetch is not None else self._fetch_jwks
|
|
85
|
+
self._lock = threading.Lock()
|
|
86
|
+
self._keys: dict[Optional[str], PyJWK] = {}
|
|
87
|
+
self._fetched_at: Optional[float] = None
|
|
88
|
+
self._last_attempt: Optional[float] = None
|
|
89
|
+
|
|
90
|
+
def needs_fetch(self, kid: Optional[str]) -> bool:
|
|
91
|
+
"""Tell whether resolving ``kid`` now would make an HTTP request.
|
|
92
|
+
|
|
93
|
+
It lets an async caller keep the common case, a fresh key already cached, on the
|
|
94
|
+
event loop, and move to a worker thread only the calls that would block on the
|
|
95
|
+
network.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
kid (str, optional): Key id read from the token header.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
bool: ``True`` if ``get_key`` would fetch the JWKS.
|
|
102
|
+
"""
|
|
103
|
+
now = time.monotonic()
|
|
104
|
+
fresh = self._fetched_at is not None and now - self._fetched_at < self.ttl
|
|
105
|
+
if fresh and self._select(kid) is not None:
|
|
106
|
+
return False
|
|
107
|
+
return self._last_attempt is None or now - self._last_attempt >= self.min_refresh_interval
|
|
108
|
+
|
|
109
|
+
def get_key(self, kid: Optional[str]) -> Optional[PyJWK]:
|
|
110
|
+
"""Return the signing key named by ``kid``, fetching the JWKS when needed.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
kid (str, optional): Key id read from the token header. A token without one is
|
|
114
|
+
accepted only while the realm publishes a single signing key.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
PyJWK | None: The key, or ``None`` if the realm does not publish it.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ServiceUnavailableException: If no key has ever been fetched, because the
|
|
121
|
+
JWKS endpoint is unreachable.
|
|
122
|
+
"""
|
|
123
|
+
if self.needs_fetch(kid):
|
|
124
|
+
with self._lock:
|
|
125
|
+
# ricontrollato sotto lock: un altro thread può aver appena fatto il fetch
|
|
126
|
+
if self.needs_fetch(kid):
|
|
127
|
+
self._refresh()
|
|
128
|
+
if not self._keys:
|
|
129
|
+
raise ServiceUnavailableException()
|
|
130
|
+
return self._select(kid)
|
|
131
|
+
|
|
132
|
+
def refresh(self) -> bool:
|
|
133
|
+
"""Fetch the JWKS now, ignoring both the TTL and the rate limit.
|
|
134
|
+
|
|
135
|
+
Meant for the startup, to fill the cache before the first request.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
bool: ``True`` if the fetch succeeded. On failure the keys already cached are
|
|
139
|
+
kept.
|
|
140
|
+
"""
|
|
141
|
+
with self._lock:
|
|
142
|
+
return self._refresh()
|
|
143
|
+
|
|
144
|
+
def _refresh(self) -> bool:
|
|
145
|
+
"""Fetch and parse the JWKS, replacing the cache only on success. Call under lock.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
bool: ``True`` if the fetch succeeded.
|
|
149
|
+
"""
|
|
150
|
+
# `_last_attempt` si scrive a tentativo **concluso**: scritto prima, chi arriva durante
|
|
151
|
+
# il primo fetch vedrebbe il rate limit attivo e la cache vuota, e avrebbe un 503
|
|
152
|
+
# invece di aspettare sul lock l'esito del fetch in corso
|
|
153
|
+
try:
|
|
154
|
+
keys = self._parse(self._fetch())
|
|
155
|
+
except Exception as e:
|
|
156
|
+
self._last_attempt = time.monotonic()
|
|
157
|
+
logging.warning(f"JWKS fetch from {self.jwks_url} failed - {e!r}")
|
|
158
|
+
return False
|
|
159
|
+
self._keys = keys
|
|
160
|
+
self._fetched_at = self._last_attempt = time.monotonic()
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
def _select(self, kid: Optional[str]) -> Optional[PyJWK]:
|
|
164
|
+
"""Look ``kid`` up in the cache, without fetching.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
kid (str, optional): Key id read from the token header.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
PyJWK | None: The cached key, or ``None`` if it is not there.
|
|
171
|
+
"""
|
|
172
|
+
keys = self._keys
|
|
173
|
+
if kid is None:
|
|
174
|
+
return next(iter(keys.values())) if len(keys) == 1 else None
|
|
175
|
+
return keys.get(kid)
|
|
176
|
+
|
|
177
|
+
def _fetch_jwks(self) -> dict[str, Any]:
|
|
178
|
+
"""Download the JWKS document from ``jwks_url``.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
dict[str, Any]: The parsed JSON document.
|
|
182
|
+
"""
|
|
183
|
+
with urlopen(self.jwks_url, timeout=self.timeout) as response:
|
|
184
|
+
return json.load(response)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _parse(jwks: dict[str, Any]) -> dict[Optional[str], PyJWK]:
|
|
188
|
+
"""Keep the signing keys of a JWKS document, keyed by ``kid``.
|
|
189
|
+
|
|
190
|
+
Keycloak publishes the encryption keys (``"use": "enc"``) at the same endpoint;
|
|
191
|
+
they cannot verify a signature, so they are left out.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
jwks (dict[str, Any]): The JWKS document.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
dict[str | None, PyJWK]: The signing keys.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
PyJWKSetError: If the document holds no usable signing key.
|
|
201
|
+
"""
|
|
202
|
+
signing = [key for key in jwks.get("keys", []) if key.get("use", "sig") == "sig"]
|
|
203
|
+
return {key.key_id: key for key in PyJWKSet.from_dict({"keys": signing}).keys}
|