sysnet-auth 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sysnet_auth-0.2.0/PKG-INFO +266 -0
- sysnet_auth-0.2.0/README.md +234 -0
- sysnet_auth-0.2.0/auth_lib/__init__.py +74 -0
- sysnet_auth-0.2.0/auth_lib/config.py +114 -0
- sysnet_auth-0.2.0/auth_lib/dependencies.py +172 -0
- sysnet_auth-0.2.0/auth_lib/exceptions.py +56 -0
- sysnet_auth-0.2.0/auth_lib/jwks.py +178 -0
- sysnet_auth-0.2.0/auth_lib/models.py +105 -0
- sysnet_auth-0.2.0/auth_lib/observability.py +128 -0
- sysnet_auth-0.2.0/auth_lib/py.typed +0 -0
- sysnet_auth-0.2.0/auth_lib/roles.py +83 -0
- sysnet_auth-0.2.0/pyproject.toml +89 -0
- sysnet_auth-0.2.0/setup.cfg +4 -0
- sysnet_auth-0.2.0/sysnet_auth.egg-info/PKG-INFO +266 -0
- sysnet_auth-0.2.0/sysnet_auth.egg-info/SOURCES.txt +20 -0
- sysnet_auth-0.2.0/sysnet_auth.egg-info/dependency_links.txt +1 -0
- sysnet_auth-0.2.0/sysnet_auth.egg-info/requires.txt +14 -0
- sysnet_auth-0.2.0/sysnet_auth.egg-info/top_level.txt +1 -0
- sysnet_auth-0.2.0/tests/test_edges.py +236 -0
- sysnet_auth-0.2.0/tests/test_features.py +398 -0
- sysnet_auth-0.2.0/tests/test_jwt.py +172 -0
- sysnet_auth-0.2.0/tests/test_roles.py +124 -0
|
@@ -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,234 @@
|
|
|
1
|
+
# sysnet-auth (import path: `auth_lib`)
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Proč existuje
|
|
10
|
+
|
|
11
|
+
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ě.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Instalace
|
|
16
|
+
|
|
17
|
+
Balíček je publikovaný jako **`sysnet-auth`**, importuje se jako **`auth_lib`**
|
|
18
|
+
(běžný pattern: distribuční jméno ≠ import jméno).
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install sysnet-auth
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from auth_lib import get_current_user, require_role
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Python
|
|
29
|
+
|
|
30
|
+
Vyžaduje **Python ≥ 3.11**, plně testováno proti **3.11 – 3.14**.
|
|
31
|
+
|
|
32
|
+
### Runtime závislosti
|
|
33
|
+
|
|
34
|
+
| Balíček | Role |
|
|
35
|
+
|---------------------|------------------------------------------------|
|
|
36
|
+
| `fastapi` | DI / exception handlery |
|
|
37
|
+
| `pydantic` v2 | modely, validace |
|
|
38
|
+
| `pydantic-settings` | konfigurace přes env |
|
|
39
|
+
| `pyjwt[crypto]` | dekódování + validace JWT |
|
|
40
|
+
| `httpx` | async HTTP klient pro JWKS endpoint |
|
|
41
|
+
| `sysnet-pyutils` | sdílené SYSNET modely (`UserType`, `ErrorModel`, `Log`) |
|
|
42
|
+
|
|
43
|
+
> **Volba JWT knihovny.** `python-jose` je od 2021 neudržovaný. Používáme **PyJWT** — aktivně udržovaný de facto standard pro JWT v Pythonu.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Konfigurace
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
| Proměnná | Default | Popis |
|
|
52
|
+
|-------------------------------------|--------------|-------|
|
|
53
|
+
| `AUTH_KEYCLOAK_URL` | — | Základní URL Keycloaku (`https://kc.example.cz`) |
|
|
54
|
+
| `AUTH_REALM` | — | Název Keycloak realm |
|
|
55
|
+
| `AUTH_AUDIENCE` | — | Očekávaný `aud` claim (typicky `client_id` API) |
|
|
56
|
+
| `AUTH_ALGORITHMS` | `["RS256"]` | Povolené podpisové algoritmy (CSV nebo JSON) |
|
|
57
|
+
| `AUTH_JWKS_CACHE_SECONDS` | `300` | TTL JWKS cache |
|
|
58
|
+
| `AUTH_JWKS_HTTP_TIMEOUT_SECONDS` | `5.0` | HTTP timeout pro fetch JWKS |
|
|
59
|
+
| `AUTH_LEEWAY_SECONDS` | `0` | Tolerance hodinového rozdílu (clock skew) |
|
|
60
|
+
| `AUTH_RESOURCE_CLIENT` | `None` | Pokud nastaveno, mergne i `resource_access.<klient>.roles` |
|
|
61
|
+
| `AUTH_KID_MISS_REFRESH_COOLDOWN_SECONDS` | `0` | Opt-in: refresh při neznámém `kid` 1× za N sekund |
|
|
62
|
+
|
|
63
|
+
### Odvozené hodnoty
|
|
64
|
+
|
|
65
|
+
- **Issuer:** `{AUTH_KEYCLOAK_URL}/realms/{AUTH_REALM}`
|
|
66
|
+
- **JWKS URL:** `{issuer}/protocol/openid-connect/certs`
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Použití ve FastAPI
|
|
71
|
+
|
|
72
|
+
### Ověřený uživatel
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from fastapi import Depends, FastAPI
|
|
76
|
+
from auth_lib import AuthenticatedUser, get_current_user, install_exception_handlers
|
|
77
|
+
|
|
78
|
+
app = FastAPI()
|
|
79
|
+
install_exception_handlers(app)
|
|
80
|
+
|
|
81
|
+
@app.get("/me")
|
|
82
|
+
async def me(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
|
|
83
|
+
return user
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Role guard
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from auth_lib import require_role, require_any_role, require_all_roles
|
|
90
|
+
|
|
91
|
+
@app.delete("/users/{id}")
|
|
92
|
+
async def delete_user(
|
|
93
|
+
id: str,
|
|
94
|
+
user: AuthenticatedUser = Depends(require_role("admin")),
|
|
95
|
+
):
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
@app.get("/content")
|
|
99
|
+
async def list_content(
|
|
100
|
+
user: AuthenticatedUser = Depends(require_any_role(["editor", "viewer"])),
|
|
101
|
+
):
|
|
102
|
+
...
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Konverze do SYSNET `UserType`
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from auth_lib import get_current_user
|
|
109
|
+
|
|
110
|
+
@app.get("/user-profile")
|
|
111
|
+
async def profile(user = Depends(get_current_user)):
|
|
112
|
+
sysnet_user = user.to_user_type() # sysnet_pyutils.UserType
|
|
113
|
+
return sysnet_user
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Mapping: `sub → identifier`, `preferred_username → name`, `email → email`, `given_name → name_first`, `family_name → name_last`, `name → name_full`.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Observability
|
|
121
|
+
|
|
122
|
+
Knihovna neví, co je Prometheus / OpenTelemetry / strukturovaný log. Místo toho exponuje **pub-sub hooky**, které si konzument zaregistruje:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from auth_lib import on_token_validated, on_token_rejected, on_jwks_refresh
|
|
126
|
+
|
|
127
|
+
@on_token_validated
|
|
128
|
+
def _ok(user):
|
|
129
|
+
metrics.incr("auth.ok", tags={"sub": user.sub})
|
|
130
|
+
|
|
131
|
+
@on_token_rejected
|
|
132
|
+
def _err(exc):
|
|
133
|
+
metrics.incr("auth.err", tags={"type": type(exc).__name__})
|
|
134
|
+
|
|
135
|
+
@on_jwks_refresh
|
|
136
|
+
def _jwks(n):
|
|
137
|
+
metrics.gauge("auth.jwks.keys", n)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Výjimka v hooku **nikdy neshodí validaci** (hooky jsou best-effort).
|
|
141
|
+
|
|
142
|
+
### Rychlé zapnutí přes SYSNET logger
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from auth_lib import install_sysnet_logging
|
|
146
|
+
install_sysnet_logging() # zapíše INFO/WARNING přes sysnet_pyutils.Log
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Idempotentní — druhé volání už hooky znovu neregistruje.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Výjimky
|
|
154
|
+
|
|
155
|
+
| Výjimka | HTTP | Kdy |
|
|
156
|
+
|--------------------------|------|---------------------------------------------------------|
|
|
157
|
+
| `InvalidTokenError` | 401 | špatný podpis, expirace, `iss`, `aud`, neznámý `kid`, … |
|
|
158
|
+
| `MissingRoleError` | 403 | uživatel je ověřen, ale chybí mu role |
|
|
159
|
+
| `AuthConfigurationError` | 500 | nedostupný JWKS, špatné URL, malformed response |
|
|
160
|
+
|
|
161
|
+
Všechny dědí z `AuthError`.
|
|
162
|
+
|
|
163
|
+
`install_exception_handlers(app)` registruje handler, který vrací **`sysnet_pyutils.ErrorModel`**:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{"code": 401, "message": "Token has expired"}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Jednotný formát chyb napříč SYSNET službami.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## JWKS caching
|
|
174
|
+
|
|
175
|
+
- In-memory TTL cache (default 300 s).
|
|
176
|
+
- Lazy fetch při prvním volání.
|
|
177
|
+
- **Single-flight:** souběh N requestů při cold/expired cache spustí právě jeden fetch.
|
|
178
|
+
- **Fresh cache je autoritativní** — kid miss = `InvalidTokenError`. Brání DoS přes náhodné kidy.
|
|
179
|
+
- **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íčů.
|
|
180
|
+
- Lazy init `asyncio.Lock` — kompatibilní s Pythonem 3.14 (neváže lock na event loop z doby importu).
|
|
181
|
+
- Žádný background task, žádný disk.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Bezpečnostní poznámky
|
|
186
|
+
|
|
187
|
+
- Ověřujeme vždy **podpis, issuer i audience**.
|
|
188
|
+
- Výchozí `leeway=0`. Zapnout jen při doloženém clock skew.
|
|
189
|
+
- Pouze `RS256` výchozí, `none` ani HS256 nepovolujeme bez dobrého důvodu.
|
|
190
|
+
- Čteme **pouze `Authorization: Bearer`** (žádné cookies, žádné `X-*` hlavičky).
|
|
191
|
+
- Tokeny se nelogují. Diagnostika přes `sub` / `jti`.
|
|
192
|
+
- JWKS fetch má timeout → nedostupný Keycloak vrátí 500, ne čekání donekonečna.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Struktura projektu
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
auth_lib/
|
|
200
|
+
├── auth_lib/
|
|
201
|
+
│ ├── __init__.py # veřejné re-exporty (__all__)
|
|
202
|
+
│ ├── config.py # AuthSettings
|
|
203
|
+
│ ├── exceptions.py # AuthError + to_error_model()
|
|
204
|
+
│ ├── models.py # AuthenticatedUser + to_user_type()
|
|
205
|
+
│ ├── jwks.py # async JWKS cache + cooldown
|
|
206
|
+
│ ├── dependencies.py # get_current_user, install_exception_handlers
|
|
207
|
+
│ ├── roles.py # require_role / require_any_role / require_all_roles
|
|
208
|
+
│ ├── observability.py # hook registry + install_sysnet_logging()
|
|
209
|
+
│ └── py.typed # PEP 561 marker
|
|
210
|
+
├── tests/
|
|
211
|
+
├── pyproject.toml # sysnet-auth, Python 3.11-3.14
|
|
212
|
+
├── CHANGELOG.md
|
|
213
|
+
├── README.md
|
|
214
|
+
└── .gitignore
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Testování
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
pip install -e ".[dev]"
|
|
223
|
+
pytest --cov=auth_lib --cov-report=term-missing
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
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í.**
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Architektonický princip
|
|
231
|
+
|
|
232
|
+
> **Tato knihovna je jediným místem, kde se řeší validace identity uživatele.**
|
|
233
|
+
|
|
234
|
+
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,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
|
+
]
|
|
@@ -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]
|