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.
Files changed (90) hide show
  1. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/PKG-INFO +11 -4
  2. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/README.md +4 -1
  3. python_general_be_lib-0.12.0/general/auth/__init__.py +68 -0
  4. python_general_be_lib-0.12.0/general/auth/jwt_model.py +92 -0
  5. python_general_be_lib-0.12.0/general/auth/key_provider.py +203 -0
  6. python_general_be_lib-0.12.0/general/auth/keycloak_auth.py +376 -0
  7. python_general_be_lib-0.12.0/general/auth/middleware.py +94 -0
  8. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/context.py +18 -4
  9. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/exception/access_exceptions.py +3 -3
  10. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/exception/crud_exceptions.py +2 -1
  11. python_general_be_lib-0.12.0/general/interface/base/__init__.py +59 -0
  12. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/base/declarative_base.py +2 -1
  13. python_general_be_lib-0.12.0/general/interface/metadata/__init__.py +58 -0
  14. python_general_be_lib-0.12.0/general/interface/repository/__init__.py +59 -0
  15. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/crud_repository.py +8 -1
  16. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/handler.py +0 -19
  17. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/statement_manager.py +36 -4
  18. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/logger.py +11 -3
  19. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/pyproject.toml +10 -3
  20. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/python_general_be_lib.egg-info/PKG-INFO +11 -4
  21. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/python_general_be_lib.egg-info/SOURCES.txt +9 -0
  22. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/python_general_be_lib.egg-info/requires.txt +5 -0
  23. python_general_be_lib-0.12.0/tests/test_auth_chiavi.py +298 -0
  24. python_general_be_lib-0.12.0/tests/test_auth_fastapi.py +355 -0
  25. python_general_be_lib-0.12.0/tests/test_auth_token.py +283 -0
  26. python_general_be_lib-0.12.0/tests/test_costo_import.py +158 -0
  27. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_import_pubblici.py +72 -0
  28. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_logger.py +81 -23
  29. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_pipeline_handler.py +50 -0
  30. python_general_be_lib-0.11.2/general/interface/base/__init__.py +0 -12
  31. python_general_be_lib-0.11.2/general/interface/metadata/__init__.py +0 -12
  32. python_general_be_lib-0.11.2/general/interface/repository/__init__.py +0 -13
  33. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/LICENSE +0 -0
  34. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/__init__.py +0 -0
  35. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/exception/__init__.py +0 -0
  36. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/exception/exception_interface.py +0 -0
  37. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/__init__.py +0 -0
  38. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/base/base_model.py +0 -0
  39. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/metadata/crud_metadata.py +0 -0
  40. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/metadata/geom_metadata.py +0 -0
  41. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/mixin/__init__.py +0 -0
  42. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/mixin/audit_mixin.py +0 -0
  43. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/mixin/softdelete_mixin.py +0 -0
  44. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/mixin/timestamp_mixin.py +0 -0
  45. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/geometry_repository.py +0 -0
  46. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/__init__.py +0 -0
  47. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/base_handler.py +0 -0
  48. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/ilike_handler.py +0 -0
  49. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/interval_handler.py +0 -0
  50. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/json_handler.py +0 -0
  51. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/json_ilike_handler.py +0 -0
  52. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/or_handler.py +0 -0
  53. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/handler/soft_delete_handler.py +0 -0
  54. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/many_to_many_repository.py +0 -0
  55. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/interface/repository/view_repository.py +0 -0
  56. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/paginator_dto.py +0 -0
  57. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/py.typed +0 -0
  58. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/utils/__init__.py +0 -0
  59. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/general/utils/consts.py +0 -0
  60. {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
  61. {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
  62. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/setup.cfg +0 -0
  63. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_caricamento_parziale.py +0 -0
  64. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_compiled_cache.py +0 -0
  65. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_engine_defaults.py +0 -0
  66. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_entity_has_columns.py +0 -0
  67. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_execute_contratto.py +0 -0
  68. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_exists.py +0 -0
  69. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_find_one.py +0 -0
  70. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_get.py +0 -0
  71. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_has_columns.py +0 -0
  72. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_many_to_many.py +0 -0
  73. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_eccezioni.py +0 -0
  74. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_geom.py +0 -0
  75. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_paginazione.py +0 -0
  76. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_pipeline.py +0 -0
  77. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_metadata_scorciatoie.py +0 -0
  78. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_mixin.py +0 -0
  79. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_negazione.py +0 -0
  80. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_ordinamento.py +0 -0
  81. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_query_count.py +0 -0
  82. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_retrieve_mapper.py +0 -0
  83. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_smoke_crud.py +0 -0
  84. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_softdelete.py +0 -0
  85. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_sql_snapshot.py +0 -0
  86. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_to_dict.py +0 -0
  87. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_to_pydantic.py +0 -0
  88. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_upsert_chiavi.py +0 -0
  89. {python_general_be_lib-0.11.2 → python_general_be_lib-0.12.0}/tests/test_valori_falsy.py +0 -0
  90. {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.11.2
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}