sysnet-auth 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- auth_lib/__init__.py +74 -0
- auth_lib/config.py +114 -0
- auth_lib/dependencies.py +172 -0
- auth_lib/exceptions.py +56 -0
- auth_lib/jwks.py +178 -0
- auth_lib/models.py +105 -0
- auth_lib/observability.py +128 -0
- auth_lib/py.typed +0 -0
- auth_lib/roles.py +83 -0
- sysnet_auth-0.2.0.dist-info/METADATA +266 -0
- sysnet_auth-0.2.0.dist-info/RECORD +13 -0
- sysnet_auth-0.2.0.dist-info/WHEEL +5 -0
- sysnet_auth-0.2.0.dist-info/top_level.txt +1 -0
auth_lib/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auth_lib -- sdilena autentizacni knihovna pro FastAPI mikrosluzby s Keycloackem.
|
|
3
|
+
|
|
4
|
+
Distribuce (PyPi): ``sysnet-auth``. Import: ``auth_lib``.
|
|
5
|
+
Python >= 3.11 (testovano az do 3.14).
|
|
6
|
+
|
|
7
|
+
Verzovani:
|
|
8
|
+
Jediny zdroj pravdy je ``version`` v ``pyproject.toml``. Runtime verze
|
|
9
|
+
se cte pres ``importlib.metadata``, aby nedochazelo k drift mezi nimi.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
13
|
+
|
|
14
|
+
from auth_lib.config import AuthSettings, get_settings
|
|
15
|
+
from auth_lib.dependencies import (
|
|
16
|
+
get_current_user,
|
|
17
|
+
install_exception_handlers,
|
|
18
|
+
)
|
|
19
|
+
from auth_lib.exceptions import (
|
|
20
|
+
AuthConfigurationError,
|
|
21
|
+
AuthError,
|
|
22
|
+
InvalidTokenError,
|
|
23
|
+
MissingRoleError,
|
|
24
|
+
)
|
|
25
|
+
from auth_lib.jwks import JWKSClient, get_jwks_client, reset_jwks_client
|
|
26
|
+
from auth_lib.models import AuthenticatedUser
|
|
27
|
+
from auth_lib.observability import (
|
|
28
|
+
clear_listeners,
|
|
29
|
+
install_sysnet_logging,
|
|
30
|
+
on_jwks_refresh,
|
|
31
|
+
on_token_rejected,
|
|
32
|
+
on_token_validated,
|
|
33
|
+
remove_listener,
|
|
34
|
+
)
|
|
35
|
+
from auth_lib.roles import require_all_roles, require_any_role, require_role
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
__version__: str = _pkg_version("sysnet-auth")
|
|
39
|
+
except PackageNotFoundError:
|
|
40
|
+
# Editable checkout bez instalace (napr. v nekterych CI fazich nebo
|
|
41
|
+
# primy PYTHONPATH). Nezastavujeme import, jen signalizujeme "dev".
|
|
42
|
+
__version__ = "0.0.0+dev"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
# modely a vyjimky
|
|
46
|
+
"AuthenticatedUser",
|
|
47
|
+
"AuthError",
|
|
48
|
+
"AuthConfigurationError",
|
|
49
|
+
"InvalidTokenError",
|
|
50
|
+
"MissingRoleError",
|
|
51
|
+
# konfigurace
|
|
52
|
+
"AuthSettings",
|
|
53
|
+
"get_settings",
|
|
54
|
+
# FastAPI integrace
|
|
55
|
+
"get_current_user",
|
|
56
|
+
"install_exception_handlers",
|
|
57
|
+
# role guards
|
|
58
|
+
"require_role",
|
|
59
|
+
"require_all_roles",
|
|
60
|
+
"require_any_role",
|
|
61
|
+
# JWKS
|
|
62
|
+
"JWKSClient",
|
|
63
|
+
"get_jwks_client",
|
|
64
|
+
"reset_jwks_client",
|
|
65
|
+
# observability
|
|
66
|
+
"on_token_validated",
|
|
67
|
+
"on_token_rejected",
|
|
68
|
+
"on_jwks_refresh",
|
|
69
|
+
"remove_listener",
|
|
70
|
+
"clear_listeners",
|
|
71
|
+
"install_sysnet_logging",
|
|
72
|
+
# metadata
|
|
73
|
+
"__version__",
|
|
74
|
+
]
|
auth_lib/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Konfigurace knihovny pres environment promenne.
|
|
3
|
+
|
|
4
|
+
Prefix: ``AUTH_``. Pouziva pydantic-settings (pydantic v2).
|
|
5
|
+
Python >= 3.11 (testovano az 3.14).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
|
|
12
|
+
from pydantic import Field, field_validator
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthSettings(BaseSettings):
|
|
17
|
+
"""Konfigurace auth_lib."""
|
|
18
|
+
|
|
19
|
+
model_config = SettingsConfigDict(
|
|
20
|
+
env_prefix="AUTH_",
|
|
21
|
+
env_file=None,
|
|
22
|
+
case_sensitive=False,
|
|
23
|
+
extra="ignore",
|
|
24
|
+
# Vypne implicitni JSON dekodovani env stringu u kolekci -- chceme si
|
|
25
|
+
# parsing AUTH_ALGORITHMS ridit sami (podporujeme CSV i JSON).
|
|
26
|
+
enable_decoding=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
keycloak_url: str = Field(
|
|
30
|
+
...,
|
|
31
|
+
description="Zakladni URL Keycloaku.",
|
|
32
|
+
)
|
|
33
|
+
realm: str = Field(
|
|
34
|
+
...,
|
|
35
|
+
description="Nazev Keycloak realm.",
|
|
36
|
+
)
|
|
37
|
+
audience: str = Field(
|
|
38
|
+
...,
|
|
39
|
+
description="Ocekavana hodnota aud claimu.",
|
|
40
|
+
)
|
|
41
|
+
algorithms: list[str] = Field(
|
|
42
|
+
default_factory=lambda: ["RS256"],
|
|
43
|
+
description="Povolene podepisovaci algoritmy.",
|
|
44
|
+
)
|
|
45
|
+
jwks_cache_seconds: int = Field(
|
|
46
|
+
default=300,
|
|
47
|
+
ge=1,
|
|
48
|
+
description="TTL JWKS cache v sekundach.",
|
|
49
|
+
)
|
|
50
|
+
jwks_http_timeout_seconds: float = Field(
|
|
51
|
+
default=5.0,
|
|
52
|
+
gt=0,
|
|
53
|
+
description="HTTP timeout pro fetch JWKS endpointu.",
|
|
54
|
+
)
|
|
55
|
+
leeway_seconds: int = Field(
|
|
56
|
+
default=0,
|
|
57
|
+
ge=0,
|
|
58
|
+
description="Tolerance hodin (clock skew).",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
resource_client: str | None = Field(
|
|
62
|
+
default=None,
|
|
63
|
+
description=(
|
|
64
|
+
"Nepovinne: pokud je uveden, AuthenticatedUser bude obsahovat i role "
|
|
65
|
+
"z resource_access.<resource_client>.roles (Keycloak client-level role)."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
kid_miss_refresh_cooldown_seconds: int = Field(
|
|
70
|
+
default=0,
|
|
71
|
+
ge=0,
|
|
72
|
+
description=(
|
|
73
|
+
"Opt-in: pokud >0, pri kid miss ve fresh JWKS cache povolime 1 refresh "
|
|
74
|
+
"za tento pocet sekund (responzivnejsi reakce na rotaci klicu v Keycloaku "
|
|
75
|
+
"s kontrolovanym DoS riskem). Default 0 = vypnuto."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@field_validator("keycloak_url")
|
|
80
|
+
@classmethod
|
|
81
|
+
def _strip_trailing_slash(cls, v: str) -> str:
|
|
82
|
+
return v.rstrip("/")
|
|
83
|
+
|
|
84
|
+
@field_validator("algorithms", mode="before")
|
|
85
|
+
@classmethod
|
|
86
|
+
def _parse_algorithms(cls, v: object) -> object:
|
|
87
|
+
# AUTH_ALGORITHMS=RS256,RS512 nebo AUTH_ALGORITHMS=["RS256","RS512"]
|
|
88
|
+
if isinstance(v, str):
|
|
89
|
+
s = v.strip()
|
|
90
|
+
if s.startswith("["):
|
|
91
|
+
import json
|
|
92
|
+
try:
|
|
93
|
+
parsed = json.loads(s)
|
|
94
|
+
if isinstance(parsed, list):
|
|
95
|
+
return [str(x).strip() for x in parsed if str(x).strip()]
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
pass
|
|
98
|
+
parts = [p.strip().strip('"').strip("'") for p in s.split(",")]
|
|
99
|
+
return [p for p in parts if p]
|
|
100
|
+
return v
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def issuer(self) -> str:
|
|
104
|
+
return f"{self.keycloak_url}/realms/{self.realm}"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def jwks_url(self) -> str:
|
|
108
|
+
return f"{self.issuer}/protocol/openid-connect/certs"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@lru_cache(maxsize=1)
|
|
112
|
+
def get_settings() -> AuthSettings:
|
|
113
|
+
"""Vrati cachovanou instanci settings. Reset pres get_settings.cache_clear()."""
|
|
114
|
+
return AuthSettings() # type: ignore[call-arg]
|
auth_lib/dependencies.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI integrace.
|
|
3
|
+
|
|
4
|
+
Verejne API:
|
|
5
|
+
- get_current_user -- FastAPI dependency; vrati AuthenticatedUser.
|
|
6
|
+
- install_exception_handlers -- zaregistruje handlery AuthError na app.
|
|
7
|
+
|
|
8
|
+
Observability:
|
|
9
|
+
- po uspesne validaci se vola observability._emit_validated(user),
|
|
10
|
+
- pri odmitnuti tokenu observability._emit_rejected(exc).
|
|
11
|
+
|
|
12
|
+
Integrace se sysnet-pyutils:
|
|
13
|
+
- install_exception_handlers pouziva ``AuthError.to_error_model()``
|
|
14
|
+
(ErrorModel z sysnet-pyutils) jako telo JSON response -- unifikovany
|
|
15
|
+
format chyb napric SYSNET sluzbami.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import jwt
|
|
23
|
+
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
|
24
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
25
|
+
from jwt import algorithms as jwt_algorithms
|
|
26
|
+
|
|
27
|
+
from auth_lib.config import AuthSettings, get_settings
|
|
28
|
+
from auth_lib.exceptions import (
|
|
29
|
+
AuthConfigurationError,
|
|
30
|
+
AuthError,
|
|
31
|
+
InvalidTokenError,
|
|
32
|
+
)
|
|
33
|
+
from auth_lib.jwks import JWKSClient, get_jwks_client
|
|
34
|
+
from auth_lib.models import AuthenticatedUser
|
|
35
|
+
from auth_lib.observability import _emit_rejected, _emit_validated
|
|
36
|
+
|
|
37
|
+
_bearer_scheme = HTTPBearer(auto_error=False, scheme_name="BearerAuth")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _jwk_to_public_key(jwk: dict[str, Any]) -> Any:
|
|
41
|
+
"""Prevod JWK dict -> PyJWT-kompatibilni verejny klic."""
|
|
42
|
+
kty = jwk.get("kty")
|
|
43
|
+
if kty == "RSA":
|
|
44
|
+
return jwt_algorithms.RSAAlgorithm.from_jwk(jwk)
|
|
45
|
+
if kty == "EC":
|
|
46
|
+
return jwt_algorithms.ECAlgorithm.from_jwk(jwk)
|
|
47
|
+
if kty == "oct":
|
|
48
|
+
return jwt_algorithms.HMACAlgorithm.from_jwk(jwk)
|
|
49
|
+
raise AuthConfigurationError(f"Unsupported JWK kty: {kty!r}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _decode_token(
|
|
53
|
+
token: str,
|
|
54
|
+
*,
|
|
55
|
+
settings: AuthSettings,
|
|
56
|
+
jwks_client: JWKSClient,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
try:
|
|
59
|
+
header = jwt.get_unverified_header(token)
|
|
60
|
+
except jwt.PyJWTError as exc:
|
|
61
|
+
raise InvalidTokenError(f"Malformed token header: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
kid = header.get("kid")
|
|
64
|
+
if not kid:
|
|
65
|
+
raise InvalidTokenError("Token header missing 'kid'")
|
|
66
|
+
|
|
67
|
+
jwk = await jwks_client.get_key(kid)
|
|
68
|
+
public_key = _jwk_to_public_key(jwk)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
claims = jwt.decode(
|
|
72
|
+
token,
|
|
73
|
+
key=public_key,
|
|
74
|
+
algorithms=settings.algorithms,
|
|
75
|
+
audience=settings.audience,
|
|
76
|
+
issuer=settings.issuer,
|
|
77
|
+
leeway=settings.leeway_seconds,
|
|
78
|
+
options={
|
|
79
|
+
"require": ["exp", "iat", "iss", "aud", "sub"],
|
|
80
|
+
"verify_signature": True,
|
|
81
|
+
"verify_exp": True,
|
|
82
|
+
"verify_iat": True,
|
|
83
|
+
"verify_iss": True,
|
|
84
|
+
"verify_aud": True,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
except jwt.ExpiredSignatureError as exc:
|
|
88
|
+
raise InvalidTokenError("Token has expired") from exc
|
|
89
|
+
except jwt.InvalidAudienceError as exc:
|
|
90
|
+
raise InvalidTokenError("Invalid token audience") from exc
|
|
91
|
+
except jwt.InvalidIssuerError as exc:
|
|
92
|
+
raise InvalidTokenError("Invalid token issuer") from exc
|
|
93
|
+
except jwt.InvalidSignatureError as exc:
|
|
94
|
+
raise InvalidTokenError("Invalid token signature") from exc
|
|
95
|
+
except jwt.MissingRequiredClaimError as exc:
|
|
96
|
+
raise InvalidTokenError(f"Missing required claim: {exc.claim}") from exc
|
|
97
|
+
except jwt.PyJWTError as exc:
|
|
98
|
+
raise InvalidTokenError(f"Invalid token: {exc}") from exc
|
|
99
|
+
|
|
100
|
+
return claims
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def get_current_user(
|
|
104
|
+
request: Request,
|
|
105
|
+
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
|
|
106
|
+
settings: AuthSettings = Depends(get_settings),
|
|
107
|
+
) -> AuthenticatedUser:
|
|
108
|
+
"""FastAPI dependency -- vrati overeneho uzivatele.
|
|
109
|
+
|
|
110
|
+
Chyby:
|
|
111
|
+
- 401 pri InvalidTokenError.
|
|
112
|
+
- 500 pri AuthConfigurationError.
|
|
113
|
+
|
|
114
|
+
Pokud settings.resource_client je nastaveny, role se mergujou z:
|
|
115
|
+
realm_access.roles + resource_access.<resource_client>.roles
|
|
116
|
+
"""
|
|
117
|
+
_ = request
|
|
118
|
+
|
|
119
|
+
if credentials is None or not credentials.credentials:
|
|
120
|
+
exc = InvalidTokenError("Missing Authorization bearer token")
|
|
121
|
+
_emit_rejected(exc)
|
|
122
|
+
raise HTTPException(
|
|
123
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
124
|
+
detail=exc.detail,
|
|
125
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
jwks_client = await get_jwks_client()
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
claims = await _decode_token(
|
|
132
|
+
credentials.credentials,
|
|
133
|
+
settings=settings,
|
|
134
|
+
jwks_client=jwks_client,
|
|
135
|
+
)
|
|
136
|
+
except AuthError as exc:
|
|
137
|
+
_emit_rejected(exc)
|
|
138
|
+
headers = {"WWW-Authenticate": "Bearer"} if exc.status_code == 401 else None
|
|
139
|
+
raise HTTPException(
|
|
140
|
+
status_code=exc.status_code,
|
|
141
|
+
detail=exc.detail,
|
|
142
|
+
headers=headers,
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
user = AuthenticatedUser.from_claims(
|
|
146
|
+
claims,
|
|
147
|
+
resource_client=settings.resource_client,
|
|
148
|
+
)
|
|
149
|
+
_emit_validated(user)
|
|
150
|
+
return user
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def install_exception_handlers(app: FastAPI) -> None:
|
|
154
|
+
"""Zaregistruje handlery custom vyjimek.
|
|
155
|
+
|
|
156
|
+
Telo JSON odpovedi ma tvar sdileneho ``sysnet_pyutils.ErrorModel``
|
|
157
|
+
({"code": int, "message": str}) -- konzistentni format chyb napric
|
|
158
|
+
SYSNET sluzbami.
|
|
159
|
+
"""
|
|
160
|
+
from fastapi.responses import JSONResponse
|
|
161
|
+
|
|
162
|
+
async def _auth_error_handler(request: Request, exc: AuthError) -> JSONResponse:
|
|
163
|
+
_ = request
|
|
164
|
+
_emit_rejected(exc)
|
|
165
|
+
headers = {"WWW-Authenticate": "Bearer"} if exc.status_code == 401 else None
|
|
166
|
+
return JSONResponse(
|
|
167
|
+
status_code=exc.status_code,
|
|
168
|
+
content=exc.to_error_model().model_dump(),
|
|
169
|
+
headers=headers,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
app.add_exception_handler(AuthError, _auth_error_handler) # type: ignore[arg-type]
|
auth_lib/exceptions.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vlastni vyjimky knihovny.
|
|
3
|
+
|
|
4
|
+
Integrace se sysnet-pyutils: ``AuthError.to_error_model()`` vraci
|
|
5
|
+
sdileny ``sysnet_pyutils.ErrorModel`` pro konzistentni format chyb
|
|
6
|
+
napric SYSNET sluzbami.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from sysnet_pyutils import ErrorModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthError(Exception):
|
|
18
|
+
"""Korenova vyjimka knihovny."""
|
|
19
|
+
|
|
20
|
+
status_code: int = 500
|
|
21
|
+
default_detail: str = "Authentication error"
|
|
22
|
+
|
|
23
|
+
def __init__(self, detail: str | None = None) -> None:
|
|
24
|
+
self.detail = detail or self.default_detail
|
|
25
|
+
super().__init__(self.detail)
|
|
26
|
+
|
|
27
|
+
def to_error_model(self) -> ErrorModel:
|
|
28
|
+
"""Konvertuje na sdileny SYSNET ``ErrorModel`` (code + message).
|
|
29
|
+
|
|
30
|
+
Lazy import -- pure unit testy bez sysnet-pyutils nepadnou jen
|
|
31
|
+
pri importu modulu.
|
|
32
|
+
"""
|
|
33
|
+
from sysnet_pyutils import ErrorModel as _ErrorModel
|
|
34
|
+
|
|
35
|
+
return _ErrorModel(code=self.status_code, message=self.detail)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InvalidTokenError(AuthError):
|
|
39
|
+
"""Token je neplatny -- spatny podpis, expirace, issuer, audience, format apod."""
|
|
40
|
+
|
|
41
|
+
status_code = 401
|
|
42
|
+
default_detail = "Invalid authentication token"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MissingRoleError(AuthError):
|
|
46
|
+
"""Uzivatel je overen, ale chybi mu pozadovana role."""
|
|
47
|
+
|
|
48
|
+
status_code = 403
|
|
49
|
+
default_detail = "Insufficient permissions"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AuthConfigurationError(AuthError):
|
|
53
|
+
"""Chybna konfigurace knihovny (chybi env, neplatny JWKS endpoint apod.)."""
|
|
54
|
+
|
|
55
|
+
status_code = 500
|
|
56
|
+
default_detail = "Authentication library misconfiguration"
|
auth_lib/jwks.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JWKS klient s async in-memory cache.
|
|
3
|
+
|
|
4
|
+
Klicova rozhodnuti:
|
|
5
|
+
- **Async I/O** (httpx.AsyncClient).
|
|
6
|
+
- **TTL cache** s konfigurovatelnou expiraci (jwks_cache_seconds).
|
|
7
|
+
- **Fresh cache je autoritativni** (bezpecne proti DoS vektoru s nahodnymi kidy).
|
|
8
|
+
- **Opt-in cooldown refresh pri kid miss** (AUTH_KID_MISS_REFRESH_COOLDOWN_SECONDS)
|
|
9
|
+
-- reakce na rotaci klicu v Keycloaku pred expiraci TTL; max 1 refresh/cooldown.
|
|
10
|
+
- **Single-flight lock** pod cold/stale cestou.
|
|
11
|
+
- **Lazy init asyncio.Lock** -- kompat s Python 3.14, nevyzadujeme beh loopu pri
|
|
12
|
+
importu nebo konstrukci.
|
|
13
|
+
- **Singleton na proces** (get_jwks_client) -- sdilena cache napric requesty.
|
|
14
|
+
- **Observability hook** on_jwks_refresh(key_count) po uspesnem fetchi.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from auth_lib.config import AuthSettings, get_settings
|
|
26
|
+
from auth_lib.exceptions import AuthConfigurationError, InvalidTokenError
|
|
27
|
+
from auth_lib.observability import _emit_jwks_refresh
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JWKSClient:
|
|
31
|
+
"""Async klient pro JWKS endpoint s in-memory TTL cache."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
settings: AuthSettings,
|
|
36
|
+
*,
|
|
37
|
+
http_client: httpx.AsyncClient | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._settings = settings
|
|
40
|
+
self._external_client = http_client is not None
|
|
41
|
+
self._http_client = http_client or httpx.AsyncClient(
|
|
42
|
+
timeout=settings.jwks_http_timeout_seconds
|
|
43
|
+
)
|
|
44
|
+
self._keys_by_kid: dict[str, dict[str, Any]] = {}
|
|
45
|
+
self._expires_at: float = 0.0
|
|
46
|
+
self._last_refresh_at: float = 0.0 # pro cooldown na kid-miss refresh
|
|
47
|
+
self._lock: asyncio.Lock | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def jwks_url(self) -> str:
|
|
51
|
+
return self._settings.jwks_url
|
|
52
|
+
|
|
53
|
+
def _is_fresh(self) -> bool:
|
|
54
|
+
return bool(self._keys_by_kid) and time.monotonic() < self._expires_at
|
|
55
|
+
|
|
56
|
+
def _ensure_lock(self) -> asyncio.Lock:
|
|
57
|
+
if self._lock is None:
|
|
58
|
+
self._lock = asyncio.Lock()
|
|
59
|
+
return self._lock
|
|
60
|
+
|
|
61
|
+
def _cooldown_allows_refresh(self) -> bool:
|
|
62
|
+
"""Kid miss ve fresh cache -- smime zkusit refresh?
|
|
63
|
+
|
|
64
|
+
Jen kdyz je cooldown > 0 a od posledniho refreshu ubehl.
|
|
65
|
+
"""
|
|
66
|
+
cooldown = self._settings.kid_miss_refresh_cooldown_seconds
|
|
67
|
+
if cooldown <= 0:
|
|
68
|
+
return False
|
|
69
|
+
return (time.monotonic() - self._last_refresh_at) >= cooldown
|
|
70
|
+
|
|
71
|
+
async def _fetch(self) -> None:
|
|
72
|
+
try:
|
|
73
|
+
resp = await self._http_client.get(self.jwks_url)
|
|
74
|
+
resp.raise_for_status()
|
|
75
|
+
except httpx.HTTPError as exc:
|
|
76
|
+
raise AuthConfigurationError(
|
|
77
|
+
f"Failed to fetch JWKS from {self.jwks_url}: {exc}"
|
|
78
|
+
) from exc
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
data = resp.json()
|
|
82
|
+
keys = data["keys"]
|
|
83
|
+
if not isinstance(keys, list):
|
|
84
|
+
raise TypeError("JWKS 'keys' is not a list")
|
|
85
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
86
|
+
raise AuthConfigurationError(
|
|
87
|
+
f"Malformed JWKS response from {self.jwks_url}: {exc}"
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
self._keys_by_kid = {k["kid"]: k for k in keys if "kid" in k}
|
|
91
|
+
self._expires_at = time.monotonic() + self._settings.jwks_cache_seconds
|
|
92
|
+
self._last_refresh_at = time.monotonic()
|
|
93
|
+
_emit_jwks_refresh(len(self._keys_by_kid))
|
|
94
|
+
|
|
95
|
+
async def get_key(self, kid: str) -> dict[str, Any]:
|
|
96
|
+
"""Vrati JWK pro dany kid.
|
|
97
|
+
|
|
98
|
+
Logika:
|
|
99
|
+
1) Fresh cache + kid v ni -> vratime primo.
|
|
100
|
+
2) Fresh cache + kid NENI v ni:
|
|
101
|
+
- pokud cooldown povoluje, zkusime refresh pod lockem,
|
|
102
|
+
- jinak InvalidTokenError.
|
|
103
|
+
3) Stale cache -> pod lockem (double-check), fetch, pak lookup.
|
|
104
|
+
"""
|
|
105
|
+
if self._is_fresh():
|
|
106
|
+
key = self._keys_by_kid.get(kid)
|
|
107
|
+
if key is not None:
|
|
108
|
+
return key
|
|
109
|
+
# Opt-in cooldown: zkusit refresh (rotace klicu pred TTL).
|
|
110
|
+
if not self._cooldown_allows_refresh():
|
|
111
|
+
raise InvalidTokenError(f"Signing key '{kid}' not found in JWKS")
|
|
112
|
+
async with self._ensure_lock():
|
|
113
|
+
# Double-check: jina korutina mohla uz refreshnout.
|
|
114
|
+
key = self._keys_by_kid.get(kid)
|
|
115
|
+
if key is not None:
|
|
116
|
+
return key
|
|
117
|
+
if self._cooldown_allows_refresh():
|
|
118
|
+
await self._fetch()
|
|
119
|
+
key = self._keys_by_kid.get(kid)
|
|
120
|
+
if key is None:
|
|
121
|
+
raise InvalidTokenError(f"Signing key '{kid}' not found in JWKS")
|
|
122
|
+
return key
|
|
123
|
+
|
|
124
|
+
async with self._ensure_lock():
|
|
125
|
+
# Double-check -- jina korutina uz mohla cache naplnit.
|
|
126
|
+
if self._is_fresh():
|
|
127
|
+
key = self._keys_by_kid.get(kid)
|
|
128
|
+
if key is not None:
|
|
129
|
+
return key
|
|
130
|
+
raise InvalidTokenError(f"Signing key '{kid}' not found in JWKS")
|
|
131
|
+
|
|
132
|
+
await self._fetch()
|
|
133
|
+
|
|
134
|
+
key = self._keys_by_kid.get(kid)
|
|
135
|
+
if key is None:
|
|
136
|
+
raise InvalidTokenError(f"Signing key '{kid}' not found in JWKS")
|
|
137
|
+
return key
|
|
138
|
+
|
|
139
|
+
async def refresh(self) -> None:
|
|
140
|
+
async with self._ensure_lock():
|
|
141
|
+
await self._fetch()
|
|
142
|
+
|
|
143
|
+
def invalidate(self) -> None:
|
|
144
|
+
self._keys_by_kid = {}
|
|
145
|
+
self._expires_at = 0.0
|
|
146
|
+
|
|
147
|
+
async def aclose(self) -> None:
|
|
148
|
+
if not self._external_client:
|
|
149
|
+
await self._http_client.aclose()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Modulovy singleton
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
_jwks_client: JWKSClient | None = None
|
|
157
|
+
_jwks_client_lock: asyncio.Lock | None = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def get_jwks_client() -> JWKSClient:
|
|
161
|
+
"""Vrati sdilenou instanci JWKSClient (singleton na proces)."""
|
|
162
|
+
global _jwks_client, _jwks_client_lock
|
|
163
|
+
if _jwks_client is not None:
|
|
164
|
+
return _jwks_client
|
|
165
|
+
|
|
166
|
+
if _jwks_client_lock is None:
|
|
167
|
+
_jwks_client_lock = asyncio.Lock()
|
|
168
|
+
|
|
169
|
+
async with _jwks_client_lock:
|
|
170
|
+
if _jwks_client is None:
|
|
171
|
+
_jwks_client = JWKSClient(get_settings())
|
|
172
|
+
return _jwks_client
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def reset_jwks_client() -> None:
|
|
176
|
+
"""Reset singletonu - pro testy."""
|
|
177
|
+
global _jwks_client
|
|
178
|
+
_jwks_client = None
|
auth_lib/models.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Datove modely knihovny.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from sysnet_pyutils import UserType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthenticatedUser(BaseModel):
|
|
16
|
+
"""Overeny uzivatel odvozeny z JWT.
|
|
17
|
+
|
|
18
|
+
Mapping claims -> atribut:
|
|
19
|
+
sub <- sub
|
|
20
|
+
username <- preferred_username (Keycloak standard)
|
|
21
|
+
email <- email
|
|
22
|
+
roles <- realm_access.roles + (volitelne) resource_access.<client>.roles
|
|
23
|
+
raw <- cely JWT payload
|
|
24
|
+
|
|
25
|
+
Konverze do sdileneho SYSNET modelu: ``to_user_type()``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(frozen=True)
|
|
29
|
+
|
|
30
|
+
sub: str
|
|
31
|
+
username: str | None = None
|
|
32
|
+
email: str | None = None
|
|
33
|
+
roles: list[str] = Field(default_factory=list)
|
|
34
|
+
raw: dict[str, Any] = Field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
def has_role(self, role: str) -> bool:
|
|
37
|
+
return role in self.roles
|
|
38
|
+
|
|
39
|
+
def has_any_role(self, roles: list[str]) -> bool:
|
|
40
|
+
return any(r in self.roles for r in roles)
|
|
41
|
+
|
|
42
|
+
def has_all_roles(self, roles: list[str]) -> bool:
|
|
43
|
+
return all(r in self.roles for r in roles)
|
|
44
|
+
|
|
45
|
+
def to_user_type(self) -> UserType:
|
|
46
|
+
"""Konvertuje na sdileny SYSNET model ``sysnet_pyutils.UserType``.
|
|
47
|
+
|
|
48
|
+
Mapping:
|
|
49
|
+
sub -> identifier
|
|
50
|
+
preferred_username -> name
|
|
51
|
+
email -> email
|
|
52
|
+
given_name -> name_first (pokud je v claims)
|
|
53
|
+
family_name -> name_last (pokud je v claims)
|
|
54
|
+
name -> name_full (pokud je v claims)
|
|
55
|
+
|
|
56
|
+
Import `sysnet_pyutils` je lazy, aby neselhalo pouze na to_user_type()
|
|
57
|
+
v prostrredi, kde sysnet-pyutils neni dostupne (napr. v pure unit testech
|
|
58
|
+
knihovny samotne).
|
|
59
|
+
"""
|
|
60
|
+
from sysnet_pyutils import UserType as _UserType
|
|
61
|
+
|
|
62
|
+
return _UserType(
|
|
63
|
+
identifier=self.sub,
|
|
64
|
+
name=self.username,
|
|
65
|
+
email=self.email,
|
|
66
|
+
name_first=self.raw.get("given_name"),
|
|
67
|
+
name_last=self.raw.get("family_name"),
|
|
68
|
+
name_full=self.raw.get("name"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_claims(
|
|
73
|
+
cls,
|
|
74
|
+
claims: dict[str, Any],
|
|
75
|
+
*,
|
|
76
|
+
resource_client: str | None = None,
|
|
77
|
+
) -> AuthenticatedUser:
|
|
78
|
+
"""Poskladaji uzivatele z validovanych claimu."""
|
|
79
|
+
realm_roles: list[str] = []
|
|
80
|
+
realm_access = claims.get("realm_access") or {}
|
|
81
|
+
if isinstance(realm_access, dict):
|
|
82
|
+
realm_roles = list(realm_access.get("roles") or [])
|
|
83
|
+
|
|
84
|
+
client_roles: list[str] = []
|
|
85
|
+
if resource_client:
|
|
86
|
+
resource_access = claims.get("resource_access") or {}
|
|
87
|
+
if isinstance(resource_access, dict):
|
|
88
|
+
client_block = resource_access.get(resource_client) or {}
|
|
89
|
+
if isinstance(client_block, dict):
|
|
90
|
+
client_roles = list(client_block.get("roles") or [])
|
|
91
|
+
|
|
92
|
+
seen: set[str] = set()
|
|
93
|
+
merged_roles: list[str] = []
|
|
94
|
+
for r in [*realm_roles, *client_roles]:
|
|
95
|
+
if r not in seen:
|
|
96
|
+
seen.add(r)
|
|
97
|
+
merged_roles.append(r)
|
|
98
|
+
|
|
99
|
+
return cls(
|
|
100
|
+
sub=str(claims["sub"]),
|
|
101
|
+
username=claims.get("preferred_username"),
|
|
102
|
+
email=claims.get("email"),
|
|
103
|
+
roles=merged_roles,
|
|
104
|
+
raw=claims,
|
|
105
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Observability hooks.
|
|
3
|
+
|
|
4
|
+
Knihovna sama nezna konkretni logging/metrics backend -- vystavuje
|
|
5
|
+
jednoduchy registr callbacku, ktery si konzument zaregistruje podle
|
|
6
|
+
svych potreb (Prometheus, OpenTelemetry, strukturovany log atd.).
|
|
7
|
+
|
|
8
|
+
Pripravena integrace se sysnet-pyutils: ``install_sysnet_logging()``
|
|
9
|
+
zaregistruje default hooky, ktere logji pres ``sysnet_pyutils.Log``
|
|
10
|
+
(singleton logger sdileny napric SYSNET sluzbami).
|
|
11
|
+
|
|
12
|
+
Garance:
|
|
13
|
+
- Hook volani je synchronni a best-effort -- vyjimka v hooku NIKDY
|
|
14
|
+
neprerusi validaci tokenu (knihovna ji poznamena do _HOOK_ERRORS).
|
|
15
|
+
- Poradi volani odpovida poradi registrace.
|
|
16
|
+
- Hook se da odregistrovat (remove_listener).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from auth_lib.models import AuthenticatedUser
|
|
26
|
+
|
|
27
|
+
ValidatedHook = Callable[["AuthenticatedUser"], None]
|
|
28
|
+
RejectedHook = Callable[[BaseException], None]
|
|
29
|
+
JWKSRefreshHook = Callable[[int], None]
|
|
30
|
+
|
|
31
|
+
_on_validated: list[ValidatedHook] = []
|
|
32
|
+
_on_rejected: list[RejectedHook] = []
|
|
33
|
+
_on_jwks_refresh: list[JWKSRefreshHook] = []
|
|
34
|
+
_HOOK_ERRORS: list[BaseException] = []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def on_token_validated(fn: ValidatedHook) -> ValidatedHook:
|
|
38
|
+
"""Zaregistruje callback po uspesne validaci tokenu."""
|
|
39
|
+
_on_validated.append(fn)
|
|
40
|
+
return fn
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def on_token_rejected(fn: RejectedHook) -> RejectedHook:
|
|
44
|
+
"""Zaregistruje callback pri odmitnuti tokenu."""
|
|
45
|
+
_on_rejected.append(fn)
|
|
46
|
+
return fn
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def on_jwks_refresh(fn: JWKSRefreshHook) -> JWKSRefreshHook:
|
|
50
|
+
"""Zaregistruje callback po uspesnem JWKS refreshi."""
|
|
51
|
+
_on_jwks_refresh.append(fn)
|
|
52
|
+
return fn
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def remove_listener(fn: Callable[..., None]) -> None:
|
|
56
|
+
"""Odebere callback ze vsech registru."""
|
|
57
|
+
for registry in (_on_validated, _on_rejected, _on_jwks_refresh):
|
|
58
|
+
if fn in registry:
|
|
59
|
+
registry.remove(fn) # type: ignore[arg-type]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def clear_listeners() -> None:
|
|
63
|
+
"""Smaze vsechny registry (pro testy)."""
|
|
64
|
+
_on_validated.clear()
|
|
65
|
+
_on_rejected.clear()
|
|
66
|
+
_on_jwks_refresh.clear()
|
|
67
|
+
_HOOK_ERRORS.clear()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def install_sysnet_logging() -> None:
|
|
71
|
+
"""Napoji default logovaci hooky na sdileny SYSNET ``Log`` singleton.
|
|
72
|
+
|
|
73
|
+
Po zavolani bude knihovna logovat:
|
|
74
|
+
- INFO pri uspesne validaci (sub + pocet roli),
|
|
75
|
+
- WARNING pri odmitnuti tokenu (typ vyjimky + detail),
|
|
76
|
+
- INFO po JWKS refreshi (pocet klicu).
|
|
77
|
+
|
|
78
|
+
Volat typicky pri startu aplikace. Hooky jsou idempotentne pridane --
|
|
79
|
+
druhe volani neregistrace stejne hooky znovu (chrani pres atribut).
|
|
80
|
+
"""
|
|
81
|
+
from sysnet_pyutils import Log
|
|
82
|
+
|
|
83
|
+
logger = Log().logger
|
|
84
|
+
|
|
85
|
+
def _on_ok(user: "AuthenticatedUser") -> None:
|
|
86
|
+
logger.info("auth_lib: token validated (sub=%s, roles=%d)", user.sub, len(user.roles))
|
|
87
|
+
|
|
88
|
+
def _on_err(exc: BaseException) -> None:
|
|
89
|
+
logger.warning("auth_lib: token rejected (%s): %s", type(exc).__name__, exc)
|
|
90
|
+
|
|
91
|
+
def _on_refresh(n: int) -> None:
|
|
92
|
+
logger.info("auth_lib: JWKS refreshed (keys=%d)", n)
|
|
93
|
+
|
|
94
|
+
# Idempotentni -- pokud uz jsou nase hooky zaregistrovane, nepridavat zas.
|
|
95
|
+
if not any(getattr(h, "_auth_lib_sysnet", False) for h in _on_validated):
|
|
96
|
+
_on_ok._auth_lib_sysnet = True # type: ignore[attr-defined]
|
|
97
|
+
_on_err._auth_lib_sysnet = True # type: ignore[attr-defined]
|
|
98
|
+
_on_refresh._auth_lib_sysnet = True # type: ignore[attr-defined]
|
|
99
|
+
_on_validated.append(_on_ok)
|
|
100
|
+
_on_rejected.append(_on_err)
|
|
101
|
+
_on_jwks_refresh.append(_on_refresh)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ----- interni emitery (vola knihovna) --------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _emit_validated(user: "AuthenticatedUser") -> None:
|
|
108
|
+
for h in list(_on_validated):
|
|
109
|
+
try:
|
|
110
|
+
h(user)
|
|
111
|
+
except BaseException as exc: # noqa: BLE001
|
|
112
|
+
_HOOK_ERRORS.append(exc)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _emit_rejected(exc: BaseException) -> None:
|
|
116
|
+
for h in list(_on_rejected):
|
|
117
|
+
try:
|
|
118
|
+
h(exc)
|
|
119
|
+
except BaseException as e: # noqa: BLE001
|
|
120
|
+
_HOOK_ERRORS.append(e)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _emit_jwks_refresh(key_count: int) -> None:
|
|
124
|
+
for h in list(_on_jwks_refresh):
|
|
125
|
+
try:
|
|
126
|
+
h(key_count)
|
|
127
|
+
except BaseException as exc: # noqa: BLE001
|
|
128
|
+
_HOOK_ERRORS.append(exc)
|
auth_lib/py.typed
ADDED
|
File without changes
|
auth_lib/roles.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Role guards jako factory funkce pro FastAPI Depends.
|
|
3
|
+
|
|
4
|
+
Design:
|
|
5
|
+
Knihovna vrací **dependency callable** (ne dekorátor na endpoint).
|
|
6
|
+
To je idiomatické pro FastAPI a umožňuje kompozici:
|
|
7
|
+
|
|
8
|
+
@app.get("/admin")
|
|
9
|
+
def only_admin(user = Depends(require_role("admin"))): ...
|
|
10
|
+
|
|
11
|
+
Výsledná dependency vrací ``AuthenticatedUser`` — můžeš tedy pokračovat
|
|
12
|
+
v handleru se jmenným přístupem (např. ``user.email``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
18
|
+
|
|
19
|
+
from fastapi import Depends, HTTPException
|
|
20
|
+
|
|
21
|
+
from auth_lib.dependencies import get_current_user
|
|
22
|
+
from auth_lib.exceptions import MissingRoleError
|
|
23
|
+
from auth_lib.models import AuthenticatedUser
|
|
24
|
+
|
|
25
|
+
RoleGuard = Callable[..., Awaitable[AuthenticatedUser]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def require_role(role: str) -> RoleGuard:
|
|
29
|
+
"""Vyžádá, aby uživatel měl danou roli.
|
|
30
|
+
|
|
31
|
+
Vrací FastAPI dependency, která:
|
|
32
|
+
- vyhodí 401, pokud token chybí/nevalidní (přes ``get_current_user``),
|
|
33
|
+
- vyhodí 403, pokud role chybí,
|
|
34
|
+
- jinak vrátí ``AuthenticatedUser``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
async def _guard(
|
|
38
|
+
user: AuthenticatedUser = Depends(get_current_user),
|
|
39
|
+
) -> AuthenticatedUser:
|
|
40
|
+
if not user.has_role(role):
|
|
41
|
+
# Konzistentní s dependencies.py: v dependency mapujeme sami.
|
|
42
|
+
raise HTTPException(
|
|
43
|
+
status_code=MissingRoleError.status_code,
|
|
44
|
+
detail=f"Missing required role: '{role}'",
|
|
45
|
+
)
|
|
46
|
+
return user
|
|
47
|
+
|
|
48
|
+
return _guard
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def require_any_role(roles: Sequence[str]) -> RoleGuard:
|
|
52
|
+
"""Vyžádá alespoň jednu z uvedených rolí (OR)."""
|
|
53
|
+
required = list(roles)
|
|
54
|
+
|
|
55
|
+
async def _guard(
|
|
56
|
+
user: AuthenticatedUser = Depends(get_current_user),
|
|
57
|
+
) -> AuthenticatedUser:
|
|
58
|
+
if not user.has_any_role(required):
|
|
59
|
+
raise HTTPException(
|
|
60
|
+
status_code=MissingRoleError.status_code,
|
|
61
|
+
detail=f"Missing any of required roles: {required}",
|
|
62
|
+
)
|
|
63
|
+
return user
|
|
64
|
+
|
|
65
|
+
return _guard
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def require_all_roles(roles: Sequence[str]) -> RoleGuard:
|
|
69
|
+
"""Vyžádá všechny uvedené role (AND)."""
|
|
70
|
+
required = list(roles)
|
|
71
|
+
|
|
72
|
+
async def _guard(
|
|
73
|
+
user: AuthenticatedUser = Depends(get_current_user),
|
|
74
|
+
) -> AuthenticatedUser:
|
|
75
|
+
if not user.has_all_roles(required):
|
|
76
|
+
missing = [r for r in required if r not in user.roles]
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
status_code=MissingRoleError.status_code,
|
|
79
|
+
detail=f"Missing required roles: {missing}",
|
|
80
|
+
)
|
|
81
|
+
return user
|
|
82
|
+
|
|
83
|
+
return _guard
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sysnet-auth
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Sdilena autentizacni knihovna pro FastAPI mikrosluzby s Keycloack (OIDC/JWT).
|
|
5
|
+
Author: SYSNET s.r.o.
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://sysnet.cz
|
|
8
|
+
Keywords: fastapi,keycloak,jwt,oidc,auth,sysnet
|
|
9
|
+
Classifier: Framework :: FastAPI
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: fastapi>=0.115
|
|
20
|
+
Requires-Dist: pydantic>=2.9
|
|
21
|
+
Requires-Dist: pydantic-settings>=2.3
|
|
22
|
+
Requires-Dist: pyjwt[crypto]>=2.9
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: sysnet-pyutils>=0.1
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
29
|
+
Requires-Dist: cryptography>=42.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
32
|
+
|
|
33
|
+
# sysnet-auth (import path: `auth_lib`)
|
|
34
|
+
|
|
35
|
+
Sdílená autentizační knihovna pro FastAPI mikroslužby, které ověřují identitu uživatelů přes **Keycloak** (OIDC / JWT). Součást SYSNET ekosystému.
|
|
36
|
+
|
|
37
|
+
Knihovna je záměrně úzká: neobsahuje business logiku, neukládá stav a nepřeposílá tokeny. Jediná zodpovědnost je **validace JWT** a vystavení objektu `AuthenticatedUser` dependency injection mechanismem FastAPI.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Proč existuje
|
|
42
|
+
|
|
43
|
+
V architektuře desítek mikroslužeb nechceme, aby každá z nich měla vlastní implementaci validace JWT. Rozkol v drobných detailech (kontrola audience, leeway, cachování JWKS) je snadný způsob, jak vyrobit bezpečnostní díru. `auth_lib` je **jediné místo, kde se validuje identita uživatele** — a všechny služby ji používají stejně.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Instalace
|
|
48
|
+
|
|
49
|
+
Balíček je publikovaný jako **`sysnet-auth`**, importuje se jako **`auth_lib`**
|
|
50
|
+
(běžný pattern: distribuční jméno ≠ import jméno).
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install sysnet-auth
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from auth_lib import get_current_user, require_role
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Python
|
|
61
|
+
|
|
62
|
+
Vyžaduje **Python ≥ 3.11**, plně testováno proti **3.11 – 3.14**.
|
|
63
|
+
|
|
64
|
+
### Runtime závislosti
|
|
65
|
+
|
|
66
|
+
| Balíček | Role |
|
|
67
|
+
|---------------------|------------------------------------------------|
|
|
68
|
+
| `fastapi` | DI / exception handlery |
|
|
69
|
+
| `pydantic` v2 | modely, validace |
|
|
70
|
+
| `pydantic-settings` | konfigurace přes env |
|
|
71
|
+
| `pyjwt[crypto]` | dekódování + validace JWT |
|
|
72
|
+
| `httpx` | async HTTP klient pro JWKS endpoint |
|
|
73
|
+
| `sysnet-pyutils` | sdílené SYSNET modely (`UserType`, `ErrorModel`, `Log`) |
|
|
74
|
+
|
|
75
|
+
> **Volba JWT knihovny.** `python-jose` je od 2021 neudržovaný. Používáme **PyJWT** — aktivně udržovaný de facto standard pro JWT v Pythonu.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Konfigurace
|
|
80
|
+
|
|
81
|
+
Všechno přes environment proměnné s prefixem `AUTH_`. Pydantic-settings načte settings při prvním volání `get_settings()` a cachuje je na proces.
|
|
82
|
+
|
|
83
|
+
| Proměnná | Default | Popis |
|
|
84
|
+
|-------------------------------------|--------------|-------|
|
|
85
|
+
| `AUTH_KEYCLOAK_URL` | — | Základní URL Keycloaku (`https://kc.example.cz`) |
|
|
86
|
+
| `AUTH_REALM` | — | Název Keycloak realm |
|
|
87
|
+
| `AUTH_AUDIENCE` | — | Očekávaný `aud` claim (typicky `client_id` API) |
|
|
88
|
+
| `AUTH_ALGORITHMS` | `["RS256"]` | Povolené podpisové algoritmy (CSV nebo JSON) |
|
|
89
|
+
| `AUTH_JWKS_CACHE_SECONDS` | `300` | TTL JWKS cache |
|
|
90
|
+
| `AUTH_JWKS_HTTP_TIMEOUT_SECONDS` | `5.0` | HTTP timeout pro fetch JWKS |
|
|
91
|
+
| `AUTH_LEEWAY_SECONDS` | `0` | Tolerance hodinového rozdílu (clock skew) |
|
|
92
|
+
| `AUTH_RESOURCE_CLIENT` | `None` | Pokud nastaveno, mergne i `resource_access.<klient>.roles` |
|
|
93
|
+
| `AUTH_KID_MISS_REFRESH_COOLDOWN_SECONDS` | `0` | Opt-in: refresh při neznámém `kid` 1× za N sekund |
|
|
94
|
+
|
|
95
|
+
### Odvozené hodnoty
|
|
96
|
+
|
|
97
|
+
- **Issuer:** `{AUTH_KEYCLOAK_URL}/realms/{AUTH_REALM}`
|
|
98
|
+
- **JWKS URL:** `{issuer}/protocol/openid-connect/certs`
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Použití ve FastAPI
|
|
103
|
+
|
|
104
|
+
### Ověřený uživatel
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from fastapi import Depends, FastAPI
|
|
108
|
+
from auth_lib import AuthenticatedUser, get_current_user, install_exception_handlers
|
|
109
|
+
|
|
110
|
+
app = FastAPI()
|
|
111
|
+
install_exception_handlers(app)
|
|
112
|
+
|
|
113
|
+
@app.get("/me")
|
|
114
|
+
async def me(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
|
|
115
|
+
return user
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Role guard
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from auth_lib import require_role, require_any_role, require_all_roles
|
|
122
|
+
|
|
123
|
+
@app.delete("/users/{id}")
|
|
124
|
+
async def delete_user(
|
|
125
|
+
id: str,
|
|
126
|
+
user: AuthenticatedUser = Depends(require_role("admin")),
|
|
127
|
+
):
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
@app.get("/content")
|
|
131
|
+
async def list_content(
|
|
132
|
+
user: AuthenticatedUser = Depends(require_any_role(["editor", "viewer"])),
|
|
133
|
+
):
|
|
134
|
+
...
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Konverze do SYSNET `UserType`
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from auth_lib import get_current_user
|
|
141
|
+
|
|
142
|
+
@app.get("/user-profile")
|
|
143
|
+
async def profile(user = Depends(get_current_user)):
|
|
144
|
+
sysnet_user = user.to_user_type() # sysnet_pyutils.UserType
|
|
145
|
+
return sysnet_user
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Mapping: `sub → identifier`, `preferred_username → name`, `email → email`, `given_name → name_first`, `family_name → name_last`, `name → name_full`.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Observability
|
|
153
|
+
|
|
154
|
+
Knihovna neví, co je Prometheus / OpenTelemetry / strukturovaný log. Místo toho exponuje **pub-sub hooky**, které si konzument zaregistruje:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from auth_lib import on_token_validated, on_token_rejected, on_jwks_refresh
|
|
158
|
+
|
|
159
|
+
@on_token_validated
|
|
160
|
+
def _ok(user):
|
|
161
|
+
metrics.incr("auth.ok", tags={"sub": user.sub})
|
|
162
|
+
|
|
163
|
+
@on_token_rejected
|
|
164
|
+
def _err(exc):
|
|
165
|
+
metrics.incr("auth.err", tags={"type": type(exc).__name__})
|
|
166
|
+
|
|
167
|
+
@on_jwks_refresh
|
|
168
|
+
def _jwks(n):
|
|
169
|
+
metrics.gauge("auth.jwks.keys", n)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Výjimka v hooku **nikdy neshodí validaci** (hooky jsou best-effort).
|
|
173
|
+
|
|
174
|
+
### Rychlé zapnutí přes SYSNET logger
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from auth_lib import install_sysnet_logging
|
|
178
|
+
install_sysnet_logging() # zapíše INFO/WARNING přes sysnet_pyutils.Log
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Idempotentní — druhé volání už hooky znovu neregistruje.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Výjimky
|
|
186
|
+
|
|
187
|
+
| Výjimka | HTTP | Kdy |
|
|
188
|
+
|--------------------------|------|---------------------------------------------------------|
|
|
189
|
+
| `InvalidTokenError` | 401 | špatný podpis, expirace, `iss`, `aud`, neznámý `kid`, … |
|
|
190
|
+
| `MissingRoleError` | 403 | uživatel je ověřen, ale chybí mu role |
|
|
191
|
+
| `AuthConfigurationError` | 500 | nedostupný JWKS, špatné URL, malformed response |
|
|
192
|
+
|
|
193
|
+
Všechny dědí z `AuthError`.
|
|
194
|
+
|
|
195
|
+
`install_exception_handlers(app)` registruje handler, který vrací **`sysnet_pyutils.ErrorModel`**:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{"code": 401, "message": "Token has expired"}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Jednotný formát chyb napříč SYSNET službami.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## JWKS caching
|
|
206
|
+
|
|
207
|
+
- In-memory TTL cache (default 300 s).
|
|
208
|
+
- Lazy fetch při prvním volání.
|
|
209
|
+
- **Single-flight:** souběh N requestů při cold/expired cache spustí právě jeden fetch.
|
|
210
|
+
- **Fresh cache je autoritativní** — kid miss = `InvalidTokenError`. Brání DoS přes náhodné kidy.
|
|
211
|
+
- **Opt-in cooldown refresh** (`AUTH_KID_MISS_REFRESH_COOLDOWN_SECONDS > 0`): při kid miss ve fresh cache povolí 1 refresh za N sekund — responzivnější reakce na rotaci klíčů.
|
|
212
|
+
- Lazy init `asyncio.Lock` — kompatibilní s Pythonem 3.14 (neváže lock na event loop z doby importu).
|
|
213
|
+
- Žádný background task, žádný disk.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Bezpečnostní poznámky
|
|
218
|
+
|
|
219
|
+
- Ověřujeme vždy **podpis, issuer i audience**.
|
|
220
|
+
- Výchozí `leeway=0`. Zapnout jen při doloženém clock skew.
|
|
221
|
+
- Pouze `RS256` výchozí, `none` ani HS256 nepovolujeme bez dobrého důvodu.
|
|
222
|
+
- Čteme **pouze `Authorization: Bearer`** (žádné cookies, žádné `X-*` hlavičky).
|
|
223
|
+
- Tokeny se nelogují. Diagnostika přes `sub` / `jti`.
|
|
224
|
+
- JWKS fetch má timeout → nedostupný Keycloak vrátí 500, ne čekání donekonečna.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Struktura projektu
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
auth_lib/
|
|
232
|
+
├── auth_lib/
|
|
233
|
+
│ ├── __init__.py # veřejné re-exporty (__all__)
|
|
234
|
+
│ ├── config.py # AuthSettings
|
|
235
|
+
│ ├── exceptions.py # AuthError + to_error_model()
|
|
236
|
+
│ ├── models.py # AuthenticatedUser + to_user_type()
|
|
237
|
+
│ ├── jwks.py # async JWKS cache + cooldown
|
|
238
|
+
│ ├── dependencies.py # get_current_user, install_exception_handlers
|
|
239
|
+
│ ├── roles.py # require_role / require_any_role / require_all_roles
|
|
240
|
+
│ ├── observability.py # hook registry + install_sysnet_logging()
|
|
241
|
+
│ └── py.typed # PEP 561 marker
|
|
242
|
+
├── tests/
|
|
243
|
+
├── pyproject.toml # sysnet-auth, Python 3.11-3.14
|
|
244
|
+
├── CHANGELOG.md
|
|
245
|
+
├── README.md
|
|
246
|
+
└── .gitignore
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Testování
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
pip install -e ".[dev]"
|
|
255
|
+
pytest --cov=auth_lib --cov-report=term-missing
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Testy nevolají reálný Keycloak — používají vlastní RSA keypair a JWKS cache předvyplněnou odpovídajícím JWK. **58 testů, 98 % pokrytí.**
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Architektonický princip
|
|
263
|
+
|
|
264
|
+
> **Tato knihovna je jediným místem, kde se řeší validace identity uživatele.**
|
|
265
|
+
|
|
266
|
+
Všechny FastAPI mikroslužby ji používají jednotným způsobem. Pokud narazíš na potřebu obejít `auth_lib` (vlastní dekódování, custom validace), je to signál k úpravě `auth_lib` — ne k duplikaci logiky.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
auth_lib/__init__.py,sha256=OstwolcHEFMatcb7xYepeZ0ivo1i1UmX3j4Tjn4GiJA,1999
|
|
2
|
+
auth_lib/config.py,sha256=hNxcb4Hd2QTe-eU0HOgLbwq3GKp_MBS6vP_X5FGnhU0,3453
|
|
3
|
+
auth_lib/dependencies.py,sha256=Q05mW9qXliynKJ5WdQRIMLtoEuZqpZux4Q3FLf8aqjo,5668
|
|
4
|
+
auth_lib/exceptions.py,sha256=r6kbuL0NLskp4WGz4mUkwSaumqegjAbLg5p298jDzO4,1558
|
|
5
|
+
auth_lib/jwks.py,sha256=eA4kF8gtUuObRfaEjpl3h6jBsrrMkymqEUZD01YSdgM,6295
|
|
6
|
+
auth_lib/models.py,sha256=57Pb1B5dEdNFc2qZIopSX5uDE1ZAGDDKi7wpSG9AeBw,3366
|
|
7
|
+
auth_lib/observability.py,sha256=s6Qp6V3iP3IIY3FTrGiHVB9aTf4PueMCKLxkTozEYec,4191
|
|
8
|
+
auth_lib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
auth_lib/roles.py,sha256=twsDLSC7LCeX7Qj7d5DomjHRkyxx97QagBCSoGdhpRA,2610
|
|
10
|
+
sysnet_auth-0.2.0.dist-info/METADATA,sha256=-91qqgwut_nJd-noCrEhKTBJWr2D95CCUZm48m1mAHM,9748
|
|
11
|
+
sysnet_auth-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
sysnet_auth-0.2.0.dist-info/top_level.txt,sha256=c4jUulZJz9nOnFobWPvTXkBhWqMB01IW8gecZnRI0Lg,9
|
|
13
|
+
sysnet_auth-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auth_lib
|