python-general-be-lib 0.11.3__py3-none-any.whl → 0.12.1__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.
- general/auth/__init__.py +68 -0
- general/auth/jwt_model.py +92 -0
- general/auth/key_provider.py +203 -0
- general/auth/keycloak_auth.py +376 -0
- general/auth/middleware.py +94 -0
- general/context.py +18 -4
- general/exception/access_exceptions.py +3 -3
- general/interface/repository/crud_repository.py +28 -12
- {python_general_be_lib-0.11.3.dist-info → python_general_be_lib-0.12.1.dist-info}/METADATA +10 -4
- {python_general_be_lib-0.11.3.dist-info → python_general_be_lib-0.12.1.dist-info}/RECORD +13 -8
- {python_general_be_lib-0.11.3.dist-info → python_general_be_lib-0.12.1.dist-info}/WHEEL +1 -1
- {python_general_be_lib-0.11.3.dist-info → python_general_be_lib-0.12.1.dist-info}/licenses/LICENSE +0 -0
- {python_general_be_lib-0.11.3.dist-info → python_general_be_lib-0.12.1.dist-info}/top_level.txt +0 -0
general/auth/__init__.py
ADDED
|
@@ -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}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Verification of Keycloak access tokens and the FastAPI dependencies built on it.
|
|
2
|
+
|
|
3
|
+
``KeycloakAuth`` holds the configuration of one backend — realm, audience, claims model —
|
|
4
|
+
and exposes what the backends used to copy in their own ``jwt_handler.py``: the bearer
|
|
5
|
+
scheme, the dependency returning the verified claims, the role checks. ``RoleChecker`` is
|
|
6
|
+
the role check alone, callable by hand where a dependency does not fit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__all__ = ["KeycloakAuth", "RoleChecker"]
|
|
10
|
+
|
|
11
|
+
from contextvars import ContextVar
|
|
12
|
+
from typing import Annotated, Any, Awaitable, Callable, Optional, Sequence, overload
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, HTTPException
|
|
15
|
+
from fastapi.params import Depends as DependsParam
|
|
16
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
from starlette.concurrency import run_in_threadpool
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import jwt
|
|
22
|
+
except ImportError as e: # pragma: no cover - dipende dagli extra installati
|
|
23
|
+
raise ImportError("general.auth needs the [auth] extra: pip install 'python-general-be-lib[auth]'") from e
|
|
24
|
+
|
|
25
|
+
from ..exception.access_exceptions import ForbiddenException, UnauthorizedException
|
|
26
|
+
from ..exception.crud_exceptions import ServiceUnavailableException
|
|
27
|
+
from .jwt_model import JWTModel
|
|
28
|
+
from .key_provider import JwksKeyProvider
|
|
29
|
+
|
|
30
|
+
# esito della verifica fatta da `AuthContextMiddleware`, letto dalla dependency `current`:
|
|
31
|
+
# (auth, token, claims oppure l'eccezione). Il token sta nella tupla perché la dependency
|
|
32
|
+
# riusi l'esito solo per lo stesso header che legge lei, e l'auth perché lo riusi solo
|
|
33
|
+
# l'istanza che l'ha prodotto.
|
|
34
|
+
_verified: ContextVar[Optional[tuple["KeycloakAuth[Any]", str, Any]]] = ContextVar("general_auth_verified", default=None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RoleChecker:
|
|
38
|
+
"""Check that the claims of a token grant some roles.
|
|
39
|
+
|
|
40
|
+
It is the policy alone, with no dependency injection: call it on already verified
|
|
41
|
+
claims, e.g. for a check that applies only to some requests. To guard an endpoint, use
|
|
42
|
+
``KeycloakAuth.require_roles``, which wraps it in a dependency.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
roles (frozenset[str]): Roles to look for.
|
|
46
|
+
require_all (bool): If ``True`` every role is required, otherwise one is enough.
|
|
47
|
+
client (str | None): Client id the roles belong to; ``None`` for realm roles.
|
|
48
|
+
"""
|
|
49
|
+
__slots__ = "roles", "require_all", "client"
|
|
50
|
+
|
|
51
|
+
def __init__(self, *roles: str, require_all: bool = False, client: Optional[str] = None):
|
|
52
|
+
"""Configure the check.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
*roles (str): Roles to look for.
|
|
56
|
+
require_all (bool): If ``True`` every role is required, otherwise one is
|
|
57
|
+
enough. Default: ``False``.
|
|
58
|
+
client (str, optional): Client id whose roles are read from
|
|
59
|
+
``resource_access``. When omitted, the realm roles are read.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If no role is given.
|
|
63
|
+
"""
|
|
64
|
+
if not roles:
|
|
65
|
+
raise ValueError("RoleChecker needs at least one role")
|
|
66
|
+
self.roles = frozenset(roles)
|
|
67
|
+
self.require_all = require_all
|
|
68
|
+
self.client = client
|
|
69
|
+
|
|
70
|
+
def __call__(self, claims: JWTModel) -> bool:
|
|
71
|
+
"""Run the check.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
claims (JWTModel): Verified claims of the token.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
bool: Always ``True``: a failed check raises instead.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ForbiddenException: If the roles are not granted, including when the token
|
|
81
|
+
carries no roles at all.
|
|
82
|
+
"""
|
|
83
|
+
held = set(claims.roles_of(self.client))
|
|
84
|
+
granted = held >= self.roles if self.require_all else not held.isdisjoint(self.roles)
|
|
85
|
+
if not granted:
|
|
86
|
+
raise ForbiddenException("Operazione selezionata non consentita")
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class KeycloakAuth[M: JWTModel]:
|
|
91
|
+
"""Verification of the access tokens of one Keycloak realm, configured per backend.
|
|
92
|
+
|
|
93
|
+
Built once per application, it verifies signature, expiry, audience and issuer of a
|
|
94
|
+
token against the signing keys of the realm, and validates the claims into ``model``.
|
|
95
|
+
**Every** invalid token yields a 401, audience and issuer included; a 503 is left for
|
|
96
|
+
a JWKS endpoint that is unreachable while no key has been cached yet.
|
|
97
|
+
|
|
98
|
+
Attributes:
|
|
99
|
+
realm_url (str): URL of the realm, without the trailing slash.
|
|
100
|
+
issuer (str | None): Expected ``iss`` claim; ``None`` when not verified.
|
|
101
|
+
audience (list[str]): Accepted audiences; the token must name at least one.
|
|
102
|
+
algorithms (list[str]): Accepted signing algorithms.
|
|
103
|
+
leeway (float): Seconds of clock skew tolerated on ``exp`` and ``iat``.
|
|
104
|
+
model (type[M]): Model the claims are validated into.
|
|
105
|
+
user_claim (str | Callable[[M], Any]): Claim, or function of the claims,
|
|
106
|
+
identifying the user for the audit.
|
|
107
|
+
key_provider (JwksKeyProvider): Cache of the signing keys of the realm.
|
|
108
|
+
oauth2_scheme (OAuth2PasswordBearer): Bearer scheme, which also documents the
|
|
109
|
+
authentication in the OpenAPI schema.
|
|
110
|
+
bearer (Depends): Dependency on ``oauth2_scheme``, to guard a router with
|
|
111
|
+
``dependencies=[auth.bearer]``.
|
|
112
|
+
current (Callable[..., Awaitable[M]]): Dependency returning the verified claims
|
|
113
|
+
of the request. Behind ``AuthContextMiddleware`` it reuses the verification the
|
|
114
|
+
middleware already made, error included, instead of verifying the token again.
|
|
115
|
+
"""
|
|
116
|
+
__slots__ = (
|
|
117
|
+
"realm_url",
|
|
118
|
+
"issuer",
|
|
119
|
+
"audience",
|
|
120
|
+
"algorithms",
|
|
121
|
+
"leeway",
|
|
122
|
+
"model",
|
|
123
|
+
"user_claim",
|
|
124
|
+
"key_provider",
|
|
125
|
+
"oauth2_scheme",
|
|
126
|
+
"bearer",
|
|
127
|
+
"current",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
realm_url: str
|
|
131
|
+
issuer: Optional[str]
|
|
132
|
+
audience: list[str]
|
|
133
|
+
algorithms: list[str]
|
|
134
|
+
leeway: float
|
|
135
|
+
model: type[M]
|
|
136
|
+
user_claim: str | Callable[[M], Any]
|
|
137
|
+
key_provider: JwksKeyProvider
|
|
138
|
+
oauth2_scheme: OAuth2PasswordBearer
|
|
139
|
+
bearer: DependsParam
|
|
140
|
+
current: Callable[..., Awaitable[M]]
|
|
141
|
+
|
|
142
|
+
# senza `model` il type checker non ha da dove ricavare M: il primo overload lo fissa a
|
|
143
|
+
# JWTModel, altrimenti chi omette il modello si vede chiedere un'annotazione
|
|
144
|
+
@overload
|
|
145
|
+
def __init__(
|
|
146
|
+
self: "KeycloakAuth[JWTModel]",
|
|
147
|
+
realm_url: str,
|
|
148
|
+
audience: str | Sequence[str],
|
|
149
|
+
*,
|
|
150
|
+
user_claim: str | Callable[[JWTModel], Any] = ...,
|
|
151
|
+
token_url: str = ...,
|
|
152
|
+
algorithms: Sequence[str] = ...,
|
|
153
|
+
issuer: Optional[str] = ...,
|
|
154
|
+
verify_issuer: bool = ...,
|
|
155
|
+
leeway: float = ...,
|
|
156
|
+
key_provider: Optional[JwksKeyProvider] = ...,
|
|
157
|
+
) -> None: ...
|
|
158
|
+
|
|
159
|
+
@overload
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
realm_url: str,
|
|
163
|
+
audience: str | Sequence[str],
|
|
164
|
+
model: type[M],
|
|
165
|
+
user_claim: str | Callable[[M], Any] = ...,
|
|
166
|
+
token_url: str = ...,
|
|
167
|
+
algorithms: Sequence[str] = ...,
|
|
168
|
+
issuer: Optional[str] = ...,
|
|
169
|
+
verify_issuer: bool = ...,
|
|
170
|
+
leeway: float = ...,
|
|
171
|
+
key_provider: Optional[JwksKeyProvider] = ...,
|
|
172
|
+
) -> None: ...
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
realm_url: str,
|
|
177
|
+
audience: str | Sequence[str],
|
|
178
|
+
model: type[M] = JWTModel, # type: ignore[assignment]
|
|
179
|
+
user_claim: str | Callable[[M], Any] = "sub",
|
|
180
|
+
token_url: str = "token",
|
|
181
|
+
algorithms: Sequence[str] = ("RS256",),
|
|
182
|
+
issuer: Optional[str] = None,
|
|
183
|
+
verify_issuer: bool = True,
|
|
184
|
+
leeway: float = 0,
|
|
185
|
+
key_provider: Optional[JwksKeyProvider] = None,
|
|
186
|
+
):
|
|
187
|
+
"""Configure the verification. Nothing is fetched until ``warm_up`` or the first token.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
realm_url (str): URL of the realm, ``{KC_URL}/realms/{realm}``, with or without
|
|
191
|
+
the trailing slash. Issuer and JWKS endpoint are derived from it.
|
|
192
|
+
audience (str | Sequence[str]): Accepted audiences, e.g.
|
|
193
|
+
``[KC_CLIENT_ID, "account"]``.
|
|
194
|
+
model (type[M]): Model the claims are validated into; a subclass of
|
|
195
|
+
``JWTModel`` declaring the custom claims of the realm. Default: ``JWTModel``.
|
|
196
|
+
user_claim (str | Callable[[M], Any]): Name of the claim identifying the user,
|
|
197
|
+
or a function computing it from the claims. The value is turned into a
|
|
198
|
+
string, which is the type of the audit columns. Default: ``"sub"``.
|
|
199
|
+
token_url (str): ``tokenUrl`` of the bearer scheme, the endpoint Swagger asks
|
|
200
|
+
the token to. Default: ``"token"``.
|
|
201
|
+
algorithms (Sequence[str]): Accepted signing algorithms. Default: ``("RS256",)``.
|
|
202
|
+
issuer (str, optional): Expected ``iss`` claim, in place of the one derived
|
|
203
|
+
from ``realm_url``. Needed when the backend reaches Keycloak through a URL
|
|
204
|
+
other than the public one the tokens are issued with.
|
|
205
|
+
verify_issuer (bool): If ``False`` the ``iss`` claim is not verified.
|
|
206
|
+
Default: ``True``.
|
|
207
|
+
leeway (float): Seconds of clock skew tolerated on ``exp`` and ``iat``.
|
|
208
|
+
Default: ``0``.
|
|
209
|
+
key_provider (JwksKeyProvider, optional): Cache of the signing keys, in place
|
|
210
|
+
of the one built on the JWKS endpoint of ``realm_url``.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ValueError: If ``realm_url`` is not an ``http(s)`` URL, or ``audience`` or
|
|
214
|
+
``algorithms`` are empty or hold an empty value — typically an environment
|
|
215
|
+
variable that is not set.
|
|
216
|
+
"""
|
|
217
|
+
if not isinstance(realm_url, str) or not realm_url.startswith(("http://", "https://")):
|
|
218
|
+
raise ValueError(f"KeycloakAuth: realm_url must be an http(s) URL, got {realm_url!r}")
|
|
219
|
+
audiences = [audience] if isinstance(audience, str) or audience is None else list(audience)
|
|
220
|
+
if not audiences or not all(audiences):
|
|
221
|
+
raise ValueError(f"KeycloakAuth: audience must hold non-empty values, got {audience!r}")
|
|
222
|
+
if not algorithms or not all(algorithms) or isinstance(algorithms, str):
|
|
223
|
+
raise ValueError(f"KeycloakAuth: algorithms must be a non-empty sequence, got {algorithms!r}")
|
|
224
|
+
|
|
225
|
+
self.realm_url = realm_url.rstrip("/")
|
|
226
|
+
self.issuer = (issuer or self.realm_url) if verify_issuer else None
|
|
227
|
+
self.audience = audiences
|
|
228
|
+
self.algorithms = list(algorithms)
|
|
229
|
+
self.leeway = leeway
|
|
230
|
+
self.model = model
|
|
231
|
+
self.user_claim = user_claim
|
|
232
|
+
self.key_provider = key_provider or JwksKeyProvider(f"{self.realm_url}/protocol/openid-connect/certs")
|
|
233
|
+
self.oauth2_scheme = OAuth2PasswordBearer(tokenUrl=token_url)
|
|
234
|
+
self.bearer = Depends(self.oauth2_scheme)
|
|
235
|
+
|
|
236
|
+
# una closure e non un metodo: FastAPI legge la dependency dalla firma, e il default
|
|
237
|
+
# `Depends(self.oauth2_scheme)` esiste solo per istanza
|
|
238
|
+
async def current(token: Annotated[str, Depends(self.oauth2_scheme)]) -> M:
|
|
239
|
+
verified = _verified.get()
|
|
240
|
+
if verified is not None and verified[0] is self and verified[1] == token:
|
|
241
|
+
outcome = verified[2]
|
|
242
|
+
if isinstance(outcome, HTTPException):
|
|
243
|
+
raise outcome
|
|
244
|
+
return outcome
|
|
245
|
+
return await self.adecode(token)
|
|
246
|
+
|
|
247
|
+
self.current = current
|
|
248
|
+
|
|
249
|
+
def decode(self, token: str) -> M:
|
|
250
|
+
"""Verify a token and validate its claims.
|
|
251
|
+
|
|
252
|
+
It is synchronous and may block on the JWKS fetch, at most once per
|
|
253
|
+
``min_refresh_interval`` of the key provider.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
token (str): The encoded token, without the ``Bearer`` prefix.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
M: The verified claims.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
UnauthorizedException: If the token is expired, malformed, signed by a key the
|
|
263
|
+
realm does not publish, or its audience, issuer or claims are not valid.
|
|
264
|
+
ServiceUnavailableException: If the JWKS endpoint is unreachable and no key has
|
|
265
|
+
been cached yet.
|
|
266
|
+
"""
|
|
267
|
+
key = self.key_provider.get_key(self._kid_of(token))
|
|
268
|
+
if key is None:
|
|
269
|
+
raise UnauthorizedException("Token non valido")
|
|
270
|
+
try:
|
|
271
|
+
payload = jwt.decode(
|
|
272
|
+
token,
|
|
273
|
+
key=key.key,
|
|
274
|
+
algorithms=self.algorithms,
|
|
275
|
+
audience=self.audience,
|
|
276
|
+
issuer=self.issuer,
|
|
277
|
+
leeway=self.leeway,
|
|
278
|
+
options={"require": ["exp", "iat"]},
|
|
279
|
+
)
|
|
280
|
+
except jwt.ExpiredSignatureError:
|
|
281
|
+
raise UnauthorizedException("Token scaduto")
|
|
282
|
+
except jwt.PyJWTError:
|
|
283
|
+
raise UnauthorizedException("Token non valido")
|
|
284
|
+
try:
|
|
285
|
+
return self.model.model_validate(payload)
|
|
286
|
+
except ValidationError:
|
|
287
|
+
raise UnauthorizedException("Token non valido")
|
|
288
|
+
|
|
289
|
+
async def adecode(self, token: str) -> M:
|
|
290
|
+
"""Verify a token like ``decode``, without blocking the event loop.
|
|
291
|
+
|
|
292
|
+
The common case — a fresh key already cached — runs on the event loop, with no
|
|
293
|
+
thread hop; only a verification that would fetch the JWKS moves to a worker thread.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
token (str): The encoded token, without the ``Bearer`` prefix.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
M: The verified claims.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
UnauthorizedException: As ``decode``.
|
|
303
|
+
ServiceUnavailableException: As ``decode``.
|
|
304
|
+
"""
|
|
305
|
+
if self.key_provider.needs_fetch(self._kid_of(token)):
|
|
306
|
+
return await run_in_threadpool(self.decode, token)
|
|
307
|
+
return self.decode(token)
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _kid_of(token: str) -> Optional[str]:
|
|
311
|
+
"""Read the key id from the header of a token, without verifying it.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
token (str): The encoded token.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
str | None: The ``kid`` of the header, ``None`` when missing.
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
UnauthorizedException: If the header cannot be read.
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
return jwt.get_unverified_header(token).get("kid")
|
|
324
|
+
except jwt.PyJWTError:
|
|
325
|
+
raise UnauthorizedException("Token non valido")
|
|
326
|
+
|
|
327
|
+
def user_of(self, claims: M) -> Optional[str]:
|
|
328
|
+
"""Identify the user the claims belong to, as the audit columns store it.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
claims (M): Verified claims of the token.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
str | None: The value of ``user_claim`` as a string, or ``None`` if missing.
|
|
335
|
+
"""
|
|
336
|
+
value = self.user_claim(claims) if callable(self.user_claim) else getattr(claims, self.user_claim, None)
|
|
337
|
+
return None if value is None else str(value)
|
|
338
|
+
|
|
339
|
+
def require_roles(self, *roles: str, require_all: bool = False, client: Optional[str] = None) -> DependsParam:
|
|
340
|
+
"""Build a dependency guarding an endpoint by role.
|
|
341
|
+
|
|
342
|
+
It goes through ``current``, which FastAPI resolves once per request: several role
|
|
343
|
+
checks on the same endpoint verify the token once.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
*roles (str): Roles to look for.
|
|
347
|
+
require_all (bool): If ``True`` every role is required, otherwise one is
|
|
348
|
+
enough. Default: ``False``.
|
|
349
|
+
client (str, optional): Client id whose roles are read from
|
|
350
|
+
``resource_access``. When omitted, the realm roles are read.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Depends: The dependency, for an endpoint parameter or ``dependencies=[...]``.
|
|
354
|
+
|
|
355
|
+
Raises:
|
|
356
|
+
ValueError: If no role is given.
|
|
357
|
+
"""
|
|
358
|
+
checker = RoleChecker(*roles, require_all=require_all, client=client)
|
|
359
|
+
|
|
360
|
+
# async: il controllo non blocca, e una dependency sincrona costerebbe un thread hop
|
|
361
|
+
async def check(claims: Annotated[JWTModel, Depends(self.current)]) -> bool:
|
|
362
|
+
return checker(claims)
|
|
363
|
+
|
|
364
|
+
return Depends(check)
|
|
365
|
+
|
|
366
|
+
def warm_up(self) -> None:
|
|
367
|
+
"""Fetch the signing keys now, so that the first request does not wait for them.
|
|
368
|
+
|
|
369
|
+
Call it at startup, in the lifespan of the application: with several workers each
|
|
370
|
+
process has its own cache, so a call made before the workers start fills none.
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
ServiceUnavailableException: If the JWKS endpoint cannot be reached.
|
|
374
|
+
"""
|
|
375
|
+
if not self.key_provider.refresh():
|
|
376
|
+
raise ServiceUnavailableException()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""ASGI middleware setting the context of the request from its bearer token.
|
|
2
|
+
|
|
3
|
+
It verifies the token once per request and publishes the outcome: the user in
|
|
4
|
+
``current_user`` (read by ``AuditMixin``), the claims in ``current_jwt``, and the result,
|
|
5
|
+
error included, for the ``KeycloakAuth.current`` dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = ["AuthContextMiddleware"]
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
from fastapi.security.utils import get_authorization_scheme_param
|
|
14
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
15
|
+
|
|
16
|
+
from ..context import current_jwt, current_user
|
|
17
|
+
from .keycloak_auth import KeycloakAuth, _verified
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthContextMiddleware:
|
|
21
|
+
"""Verify the bearer token of each request and set the context from it.
|
|
22
|
+
|
|
23
|
+
It is a pure ASGI middleware, not a ``BaseHTTPMiddleware``, and it **never answers on
|
|
24
|
+
its own**: an invalid token leaves the context empty and records the error, which the
|
|
25
|
+
``KeycloakAuth.current`` dependency raises inside the application. So the 401 goes
|
|
26
|
+
through the exception handlers and the CORS middleware, whatever the order the
|
|
27
|
+
middlewares were added in, and an endpoint without an auth dependency stays reachable
|
|
28
|
+
even with an expired token.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
app (ASGIApp): The wrapped application.
|
|
32
|
+
auth (KeycloakAuth): The verification the tokens go through.
|
|
33
|
+
"""
|
|
34
|
+
__slots__ = "app", "auth"
|
|
35
|
+
|
|
36
|
+
def __init__(self, app: ASGIApp, auth: KeycloakAuth[Any]):
|
|
37
|
+
"""Wrap the application.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
app (ASGIApp): The application, passed by ``add_middleware``.
|
|
41
|
+
auth (KeycloakAuth): The verification the tokens go through, the same instance
|
|
42
|
+
whose dependencies guard the endpoints.
|
|
43
|
+
"""
|
|
44
|
+
self.app = app
|
|
45
|
+
self.auth = auth
|
|
46
|
+
|
|
47
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
48
|
+
"""Verify the token, if any, then call the application with the context set.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
scope (Scope): ASGI connection scope.
|
|
52
|
+
receive (Receive): ASGI receive channel.
|
|
53
|
+
send (Send): ASGI send channel.
|
|
54
|
+
"""
|
|
55
|
+
token = self._bearer(scope) if scope["type"] in ("http", "websocket") else None
|
|
56
|
+
if token is None:
|
|
57
|
+
await self.app(scope, receive, send)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
claims = None
|
|
61
|
+
outcome: Any
|
|
62
|
+
try:
|
|
63
|
+
claims = outcome = await self.auth.adecode(token)
|
|
64
|
+
except HTTPException as e:
|
|
65
|
+
outcome = e
|
|
66
|
+
|
|
67
|
+
user_token = current_user.set(self.auth.user_of(claims) if claims is not None else None)
|
|
68
|
+
jwt_token = current_jwt.set(claims)
|
|
69
|
+
verified_token = _verified.set((self.auth, token, outcome))
|
|
70
|
+
try:
|
|
71
|
+
await self.app(scope, receive, send)
|
|
72
|
+
finally:
|
|
73
|
+
_verified.reset(verified_token)
|
|
74
|
+
current_jwt.reset(jwt_token)
|
|
75
|
+
current_user.reset(user_token)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _bearer(scope: Scope) -> str | None:
|
|
79
|
+
"""Extract the bearer token from the ``Authorization`` header.
|
|
80
|
+
|
|
81
|
+
It parses the header the way ``OAuth2PasswordBearer`` does, so that the dependency
|
|
82
|
+
finds the very same token and reuses the outcome.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
scope (Scope): ASGI connection scope.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
str | None: The token, or ``None`` if the header is missing or not a bearer.
|
|
89
|
+
"""
|
|
90
|
+
for name, value in scope.get("headers", ()):
|
|
91
|
+
if name == b"authorization":
|
|
92
|
+
scheme, param = get_authorization_scheme_param(value.decode("latin-1"))
|
|
93
|
+
return param if scheme.lower() == "bearer" and param else None
|
|
94
|
+
return None
|
general/context.py
CHANGED
|
@@ -8,16 +8,20 @@ The module imports nothing from the library and nothing from SQLAlchemy, so a mi
|
|
|
8
8
|
can set the context without paying for the data access layer.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
__all__ = ["current_user"]
|
|
11
|
+
__all__ = ["current_user", "current_jwt"]
|
|
12
12
|
|
|
13
13
|
from contextvars import ContextVar
|
|
14
|
-
from typing import Optional
|
|
14
|
+
from typing import TYPE_CHECKING, Optional
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # solo per il tipo: a runtime il modulo non importa niente della libreria
|
|
17
|
+
from .auth.jwt_model import JWTModel
|
|
15
18
|
|
|
16
19
|
current_user: ContextVar[Optional[str]] = ContextVar("current_user", default=None)
|
|
17
20
|
"""Identifier of the user the current operation is attributed to.
|
|
18
21
|
|
|
19
|
-
Read by ``AuditMixin`` to fill ``create_uid``/``write_uid``.
|
|
20
|
-
|
|
22
|
+
Read by ``AuditMixin`` to fill ``create_uid``/``write_uid``. ``general.auth.AuthContextMiddleware``
|
|
23
|
+
sets it from the verified token; without it, setting it is up to the consumer, typically
|
|
24
|
+
in its own authentication middleware::
|
|
21
25
|
|
|
22
26
|
from general.context import current_user
|
|
23
27
|
|
|
@@ -29,3 +33,13 @@ consumer, typically in the authentication middleware::
|
|
|
29
33
|
Left unset it holds ``None``, which is what ends up in the columns for the operations
|
|
30
34
|
having no user behind them: scheduled jobs, migrations, a console session.
|
|
31
35
|
"""
|
|
36
|
+
|
|
37
|
+
current_jwt: ContextVar[Optional["JWTModel"]] = ContextVar("current_jwt", default=None)
|
|
38
|
+
"""Verified claims of the token of the current request.
|
|
39
|
+
|
|
40
|
+
Set by ``general.auth.AuthContextMiddleware`` when the request carries a valid bearer
|
|
41
|
+
token, and left ``None`` otherwise — no token, an invalid one, a job with no request
|
|
42
|
+
behind it. It holds an instance of the ``model`` the ``KeycloakAuth`` was configured with,
|
|
43
|
+
so code outside the dependency injection reads the claims without verifying the token
|
|
44
|
+
again.
|
|
45
|
+
"""
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""HTTP exceptions for authentication and authorization.
|
|
2
2
|
|
|
3
|
-
Unlike ``crud_exceptions``, these exceptions are never raised
|
|
4
|
-
library
|
|
5
|
-
projects using it.
|
|
3
|
+
Unlike ``crud_exceptions``, these exceptions are never raised by the data access layer:
|
|
4
|
+
the library raises them only in ``general.auth``, for a rejected token (401) or a missing
|
|
5
|
+
role (403), and they are available to the authorization logic of the projects using it.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
__all__ = ["ForbiddenException", "UnauthorizedException"]
|
|
@@ -169,14 +169,23 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
169
169
|
return _reduced_model(self.entity, frozenset(selected_columns))
|
|
170
170
|
|
|
171
171
|
def _handle_exception(self, session: Session, e: Exception):
|
|
172
|
-
"""
|
|
172
|
+
"""Roll back and close the session, log the error and raise an HTTP-friendly exception.
|
|
173
173
|
|
|
174
174
|
An ``OperationalError`` becomes a ``ServiceUnavailableException``, a
|
|
175
175
|
``NoResultFound`` an ``EntityNotFoundException``, a ``DataError`` or an
|
|
176
176
|
``IntegrityError`` a ``BadRequestException``; any other exception is re-raised as is.
|
|
177
177
|
|
|
178
|
+
``close`` alone already rolls back the database transaction. What the explicit
|
|
179
|
+
``rollback`` adds is the expiry of the ORM instances: an entity still held by the
|
|
180
|
+
caller raises ``DetachedInstanceError`` on access instead of exposing values that
|
|
181
|
+
were flushed but never committed. It runs on every error, a failed SELECT included,
|
|
182
|
+
since on PostgreSQL that aborts the transaction as well. An external session is
|
|
183
|
+
rolled back and closed too, so the caller's transaction is lost. A failure of the
|
|
184
|
+
rollback itself is logged and does not replace ``e``, and the session is closed
|
|
185
|
+
anyway.
|
|
186
|
+
|
|
178
187
|
Args:
|
|
179
|
-
session (Session): Session to close.
|
|
188
|
+
session (Session): Session to roll back and close.
|
|
180
189
|
e (Exception): Caught exception to handle.
|
|
181
190
|
|
|
182
191
|
Raises:
|
|
@@ -184,6 +193,11 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
184
193
|
EntityNotFoundException: If no record matches.
|
|
185
194
|
BadRequestException: If the data is malformed or violates a constraint.
|
|
186
195
|
"""
|
|
196
|
+
try:
|
|
197
|
+
session.rollback()
|
|
198
|
+
except Exception:
|
|
199
|
+
# un rollback fallito non deve sostituire l'errore originale, né saltare la close
|
|
200
|
+
logging.exception("Rollback failed")
|
|
187
201
|
session.close()
|
|
188
202
|
logging.error(e)
|
|
189
203
|
raise_http_exception(e)
|
|
@@ -195,12 +209,13 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
195
209
|
session (Session): Session to commit.
|
|
196
210
|
|
|
197
211
|
Raises:
|
|
198
|
-
ServiceUnavailableException | BadRequestException: On error during the commit
|
|
212
|
+
ServiceUnavailableException | BadRequestException: On error during the commit,
|
|
213
|
+
after rolling back and closing the session.
|
|
199
214
|
"""
|
|
200
215
|
try:
|
|
201
216
|
session.commit()
|
|
202
217
|
except Exception as e:
|
|
203
|
-
self._handle_exception(session, e)
|
|
218
|
+
self._handle_exception(session=session, e=e)
|
|
204
219
|
|
|
205
220
|
def _flush(self, session: Session):
|
|
206
221
|
"""Flush the session, handling the possible exceptions.
|
|
@@ -214,12 +229,13 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
214
229
|
session (Session): Session to flush.
|
|
215
230
|
|
|
216
231
|
Raises:
|
|
217
|
-
ServiceUnavailableException | BadRequestException: On error during the flush
|
|
232
|
+
ServiceUnavailableException | BadRequestException: On error during the flush,
|
|
233
|
+
after rolling back and closing the session.
|
|
218
234
|
"""
|
|
219
235
|
try:
|
|
220
236
|
session.flush()
|
|
221
237
|
except Exception as e:
|
|
222
|
-
self._handle_exception(session, e)
|
|
238
|
+
self._handle_exception(session=session, e=e)
|
|
223
239
|
|
|
224
240
|
def _entity_has_columns(self, *columns: str, entity: Type[Base] | Base = None) -> bool:
|
|
225
241
|
"""Check that every given name is a column of the entity.
|
|
@@ -441,7 +457,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
441
457
|
try:
|
|
442
458
|
entity = session.get(self.entity, values)
|
|
443
459
|
except Exception as e:
|
|
444
|
-
self._handle_exception(session, e)
|
|
460
|
+
self._handle_exception(session=session, e=e)
|
|
445
461
|
else:
|
|
446
462
|
return [entity] if entity is not None else []
|
|
447
463
|
|
|
@@ -607,7 +623,7 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
607
623
|
try:
|
|
608
624
|
session.add_all(entities)
|
|
609
625
|
except Exception as e:
|
|
610
|
-
self._handle_exception(session, e)
|
|
626
|
+
self._handle_exception(session=session, e=e)
|
|
611
627
|
return self._return(session=session, entities=entities, keep_open=keep_open, commit=True, parsing_model=parsing_model)
|
|
612
628
|
|
|
613
629
|
def update(self, session: Session = None, where: dict[str, Any] = None, returning: bool = True, parsing_model: Type[BaseModel] | BaseModel = None, **kwargs) -> list[Entity] | list[BaseModel]:
|
|
@@ -1049,9 +1065,9 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
1049
1065
|
"""Execute a SQLAlchemy statement and return the result as it is.
|
|
1050
1066
|
|
|
1051
1067
|
The method owns the error handling and nothing else: every exception goes to
|
|
1052
|
-
``_handle_exception``, which closes the session and maps the error
|
|
1053
|
-
status. Reading the result is left to the caller, which is the only one
|
|
1054
|
-
shape it needs — ``.scalars().all()`` for entities, ``.scalar()`` for an aggregate,
|
|
1068
|
+
``_handle_exception``, which rolls back and closes the session and maps the error
|
|
1069
|
+
to an HTTP status. Reading the result is left to the caller, which is the only one
|
|
1070
|
+
knowing the shape it needs — ``.scalars().all()`` for entities, ``.scalar()`` for an aggregate,
|
|
1055
1071
|
``.all()`` for the multi-column rows of a statement built with ``add_columns``,
|
|
1056
1072
|
``.rowcount`` for a DML without ``RETURNING``. The result is bound to the session,
|
|
1057
1073
|
so it has to be consumed before the session is closed. ``Session.execute`` returns
|
|
@@ -1076,6 +1092,6 @@ class CrudRepository[Entity: Base | Type[Base]]:
|
|
|
1076
1092
|
try:
|
|
1077
1093
|
result = session.execute(statement=stmt, params=values)
|
|
1078
1094
|
except Exception as e:
|
|
1079
|
-
self._handle_exception(session, e)
|
|
1095
|
+
self._handle_exception(session=session, e=e)
|
|
1080
1096
|
else:
|
|
1081
1097
|
return result
|
|
@@ -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.1
|
|
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"
|
|
@@ -38,7 +41,7 @@ Dynamic: license-file
|
|
|
38
41
|
|
|
39
42
|
Libreria backend general purpose per progetti Python: repository CRUD e spaziali
|
|
40
43
|
su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
|
|
41
|
-
**Pydantic** e utility di logging.
|
|
44
|
+
**Pydantic**, autenticazione **Keycloak** e utility di logging.
|
|
42
45
|
|
|
43
46
|
- Distribuzione PyPI: `python-general-be-lib`
|
|
44
47
|
- Pacchetto importabile: `general`
|
|
@@ -46,6 +49,7 @@ su **SQLAlchemy** / **GeoAlchemy2**, eccezioni **FastAPI**, modelli base
|
|
|
46
49
|
|
|
47
50
|
```bash
|
|
48
51
|
pip install python-general-be-lib
|
|
52
|
+
pip install 'python-general-be-lib[auth]' # con l'autenticazione Keycloak (pyjwt[crypto])
|
|
49
53
|
```
|
|
50
54
|
|
|
51
55
|
```python
|
|
@@ -72,6 +76,8 @@ repo.find(active=True, order_by="name", asc=True)
|
|
|
72
76
|
| Basi | `Base` (declarative base estesa), `ExtBaseModel`, `CamelExtBaseModel` |
|
|
73
77
|
| Paginazione | `PaginatorRequestModel`, `PaginatorResponseModel` |
|
|
74
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 |
|
|
75
81
|
| Logging | `init_logger`, decorator `@log` |
|
|
76
82
|
|
|
77
83
|
---
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
general/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
general/context.py,sha256=
|
|
2
|
+
general/context.py,sha256=AbASV-jPnDM-M-Nih5c9vKHFlIS0kjp8Q1yuHC34FeA,1925
|
|
3
3
|
general/logger.py,sha256=MnK8ZDA9x4xhX0KcpOxTieosnFIhFVwSa8b52Q4T9tA,5039
|
|
4
4
|
general/paginator_dto.py,sha256=DB2hfcrckuEiHEIORLT4EgCcnX4AYYKGlHRr-l-Q1Cw,3741
|
|
5
5
|
general/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
general/auth/__init__.py,sha256=6cYCA6Ir-nnBK9z_bUfh_io3wEYjJUwHAN8mdhjtKeI,2239
|
|
7
|
+
general/auth/jwt_model.py,sha256=KcopeNUPRSQOLvreoIuSo6kJybRtrmhzLoJ5oHFngGw,3447
|
|
8
|
+
general/auth/key_provider.py,sha256=QsyLEZ5sYkIPchraMt-l79IvwvqLAyF84Hk-415gYjI,8012
|
|
9
|
+
general/auth/keycloak_auth.py,sha256=G909oFHpFCMbj2cO9x_HUVb56RqMtKYpBVaiptPVO0o,16162
|
|
10
|
+
general/auth/middleware.py,sha256=nJJHOtlpzDjYdBqgl0TD1_GQZFxKXKMlO_lECcjQdxU,3645
|
|
6
11
|
general/exception/__init__.py,sha256=MZpSDUKeopct7ZqMd1g4XlNS8sM9mmXIlHTIYPOhu9c,849
|
|
7
|
-
general/exception/access_exceptions.py,sha256=
|
|
12
|
+
general/exception/access_exceptions.py,sha256=j5fas9f24l6a6FdKCrNMP3Djv5DD6I1LRYo3CATcMRU,826
|
|
8
13
|
general/exception/crud_exceptions.py,sha256=7zA9J8KPlvK9MpHbhFEmFpdGVMcqw7hCyRGSJB4-K7U,4356
|
|
9
14
|
general/exception/exception_interface.py,sha256=k7KreAYMwKPQQghkCissdu3ASZZqrRFWM1lAGzeR8sM,1294
|
|
10
15
|
general/interface/__init__.py,sha256=G11sI1Oxtb-Z_-ORAuKS_Kkdwopt1uXmEXYtZEJYiLs,445
|
|
@@ -19,7 +24,7 @@ general/interface/mixin/audit_mixin.py,sha256=N2JDD9JxXDMeBqu0yAlhVWFOK3EeUA2b21
|
|
|
19
24
|
general/interface/mixin/softdelete_mixin.py,sha256=526luzM4DBCorcq99kZ6BYqtQKKJCpnxX9rSEigfyIU,2148
|
|
20
25
|
general/interface/mixin/timestamp_mixin.py,sha256=wmcIx2Y8jgVqg9A-1E61BvkcKCo52ZjKNHeKfXJmUl0,2220
|
|
21
26
|
general/interface/repository/__init__.py,sha256=QQD6okLfRozC0uOZPGrwOUDKJyZTgpObvwJ3OOSJdbM,1813
|
|
22
|
-
general/interface/repository/crud_repository.py,sha256=
|
|
27
|
+
general/interface/repository/crud_repository.py,sha256=Z7IgLz7dtfIyzZMJO7T_IDunHH1qTDYRApdWlbMkE2A,57031
|
|
23
28
|
general/interface/repository/geometry_repository.py,sha256=Fmv0qhfqumnU5fiETMMAmiB0UyG43b19jlPXQ8cJ9CU,11289
|
|
24
29
|
general/interface/repository/many_to_many_repository.py,sha256=KRJ2SMUGDYNoGV-ea541wlWg0FW4NQ_Ry85aJBarmC8,11183
|
|
25
30
|
general/interface/repository/view_repository.py,sha256=wxDGw7kenuVrDd9nfh7-omw2m9Jex5WzF_lhEiqPoA0,7902
|
|
@@ -35,8 +40,8 @@ general/interface/repository/handler/soft_delete_handler.py,sha256=1WD1n6q5plyJ0
|
|
|
35
40
|
general/interface/repository/handler/statement_manager.py,sha256=JSj6K-iQJ06kLdsMTOqK62cIvR87ioOA1LmLTGpqrB0,8205
|
|
36
41
|
general/utils/__init__.py,sha256=_SPe88reIZ0JxwK7mp-u_p-9c4hCcZ-daUihi0gymNk,103
|
|
37
42
|
general/utils/consts.py,sha256=nE75Ud7sVuDw9K_xmIJNjL_5f_BVJmwGNPGFYc26H3Y,264
|
|
38
|
-
python_general_be_lib-0.
|
|
39
|
-
python_general_be_lib-0.
|
|
40
|
-
python_general_be_lib-0.
|
|
41
|
-
python_general_be_lib-0.
|
|
42
|
-
python_general_be_lib-0.
|
|
43
|
+
python_general_be_lib-0.12.1.dist-info/licenses/LICENSE,sha256=iUaO1XZyB9P3Tmog0OILuTisP6vXGe3QKz-4yRTxOFk,1069
|
|
44
|
+
python_general_be_lib-0.12.1.dist-info/METADATA,sha256=8T5p8u9J9MUHT1xZjRf_Ss29Y97hEeJX5wZD71aVTyI,3995
|
|
45
|
+
python_general_be_lib-0.12.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
46
|
+
python_general_be_lib-0.12.1.dist-info/top_level.txt,sha256=tTZePW8_CNUqSgKFd2SEH72ZbnhS0OYjRsgcv0ikSFY,8
|
|
47
|
+
python_general_be_lib-0.12.1.dist-info/RECORD,,
|
{python_general_be_lib-0.11.3.dist-info → python_general_be_lib-0.12.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{python_general_be_lib-0.11.3.dist-info → python_general_be_lib-0.12.1.dist-info}/top_level.txt
RENAMED
|
File without changes
|