Metadata-Version: 2.4
Name: sysnet-auth
Version: 0.3.0
Summary: Sdilena autentizacni knihovna pro FastAPI mikrosluzby s Keycloack (OIDC/JWT).
Author: SYSNET s.r.o.
License: GNU Affero General Public License v3
Project-URL: Homepage, https://sysnet.cz
Project-URL: Repository, https://github.com/SYSNET-CZ/auth-lib
Project-URL: Documentation, https://github.com/SYSNET-CZ/auth-lib#readme
Project-URL: Bug Tracker, https://github.com/SYSNET-CZ/auth-lib/issues
Keywords: fastapi,keycloak,jwt,oidc,auth,sysnet
Classifier: Framework :: FastAPI
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Security
Classifier: Typing :: Typed
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastapi<1,>=0.115
Requires-Dist: pydantic<3,>=2.9
Requires-Dist: pydantic-settings<3,>=2.3
Requires-Dist: pyjwt[crypto]<3,>=2.9
Requires-Dist: httpx<1,>=0.27
Requires-Dist: sysnet-pyutils>=0.1
Provides-Extra: dev
Requires-Dist: pytest<10,>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio<1,>=0.23; extra == "dev"
Requires-Dist: pytest-cov<8,>=5.0; extra == "dev"
Requires-Dist: cryptography<48,>=42.0; extra == "dev"
Requires-Dist: mypy<2,>=1.11; extra == "dev"
Requires-Dist: ruff<1,>=0.6; extra == "dev"
Dynamic: license-file

# sysnet-auth (import path: `auth_lib`)

[![GitHub](https://img.shields.io/badge/GitHub-SYSNET--CZ%2Fauth--lib-181717?style=flat&logo=github)](https://github.com/SYSNET-CZ/auth-lib)
[![PyPI - Version](https://img.shields.io/badge/pypi-0.3.0-blue)](https://pypi.org/project/sysnet-auth/)
[![Python Version](https://img.shields.io/pypi/pyversions/sysnet-auth)](https://pypi.org/project/sysnet-auth/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.115%2B-009688?style=flat&logo=fastapi)](https://fastapi.tiangolo.com)
[![Tests](https://img.shields.io/badge/tests-71%20passed-brightgreen)](https://github.com/SYSNET-CZ/auth-lib)
[![Coverage](https://img.shields.io/badge/coverage-98%25-brightgreen)](https://github.com/SYSNET-CZ/auth-lib)
[![License](https://img.shields.io/badge/license-AGPL--3-blue)](https://github.com/SYSNET-CZ/auth-lib/blob/main/LICENSE)

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.

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. Bez tokenu nebo při vypnuté autentizaci vrací anonymní identitu `ANONYMOUS`.

---

## Proč existuje

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ě.

---

## Instalace

Balíček je publikovaný jako **`sysnet-auth`**, importuje se jako **`auth_lib`**
(běžný pattern: distribuční jméno ≠ import jméno).

```bash
pip install sysnet-auth
```

```python
from auth_lib import get_current_user, require_role
```

### Python

Vyžaduje **Python ≥ 3.11**, plně testováno proti **3.11 – 3.14**.

### Runtime závislosti

| Balíček             | Role                                           |
|---------------------|------------------------------------------------|
| `fastapi`           | DI / exception handlery                        |
| `pydantic` v2       | modely, validace                               |
| `pydantic-settings` | konfigurace přes env                           |
| `pyjwt[crypto]`     | dekódování + validace JWT                      |
| `httpx`             | async HTTP klient pro JWKS endpoint            |
| `sysnet-pyutils`    | sdílené SYSNET modely (`UserType`, `ErrorModel`, `Log`) |

> **Volba JWT knihovny.** `python-jose` je od 2021 neudržovaný. Používáme **PyJWT** — aktivně udržovaný de facto standard pro JWT v Pythonu.

---

## Konfigurace

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.

### Povinná konfigurace při zapnuté autentizaci

**`AUTH_KEYCLOAK_URL`, `AUTH_REALM` a `AUTH_AUDIENCE` jsou povinné jen pokud `AUTH_ENABLED=True` (nebo `1`).** Při výchozím `AUTH_ENABLED=False` není potřeba konfigurace Keycloaku a všichni uživatelé jsou `ANONYMOUS`. Při zapnuté autentizaci chybějící hodnoty způsobí validační chybu konfigurace, nikoli tichý přechod na anonymní identitu. To platí i pro offline režim `AUTH_VERIFY_KEYCLOAK=False`.

| Proměnná                            | Default      | Popis |
|-------------------------------------|--------------|-------|
| `AUTH_KEYCLOAK_URL`                 | `""`         | Základní URL Keycloaku (`https://kc.example.cz`); povinná při `AUTH_ENABLED=True` |
| `AUTH_REALM`                        | `""`         | Název Keycloak realm; povinná při `AUTH_ENABLED=True` |
| `AUTH_AUDIENCE`                     | `""`         | Očekávaný `aud` claim (typicky `client_id` API); povinná při `AUTH_ENABLED=True` |

### Volitelné proměnné

| Proměnná                            | Default      | Popis |
|-------------------------------------|--------------|-------|
| `AUTH_ENABLED`                      | `False`      | Zapíná/vypíná autentizaci: `False` = všichni `ANONYMOUS`, `True` = validace dodaného JWT |
| `AUTH_VERIFY_KEYCLOAK`              | `True`       | `False` = offline vývojový režim bez načítání JWKS a ověření podpisu; **NIKDY v produkci** |
| `AUTH_ALGORITHMS`                   | `["RS256"]`  | Povolené podpisové algoritmy (CSV nebo JSON) |
| `AUTH_JWKS_CACHE_SECONDS`           | `300`        | TTL JWKS cache |
| `AUTH_JWKS_HTTP_TIMEOUT_SECONDS`    | `5.0`        | HTTP timeout pro fetch JWKS |
| `AUTH_LEEWAY_SECONDS`               | `0`          | Tolerance hodinového rozdílu (clock skew) |
| `AUTH_RESOURCE_CLIENT`              | `None`       | Pokud nastaveno, mergne i `resource_access.<klient>.roles` |
| `AUTH_KID_MISS_REFRESH_COOLDOWN_SECONDS` | `0`     | Opt-in: refresh při neznámém `kid` 1× za N sekund |

### Odvozené hodnoty

- **Issuer:** `{AUTH_KEYCLOAK_URL}/realms/{AUTH_REALM}`
- **JWKS URL:** `{issuer}/protocol/openid-connect/certs`

---

## Použití ve FastAPI

### Rychlý start — prostředí

Před spuštěním aplikace nastav proměnné prostředí pro svůj Keycloak. **Bez `AUTH_ENABLED=1` zůstává autentizace vypnutá**, i když pošleš Bearer token.

```bash
export AUTH_ENABLED=1
export AUTH_KEYCLOAK_URL=https://kc.example.cz
export AUTH_REALM=myrealm
export AUTH_AUDIENCE=myapi
export AUTH_VERIFY_KEYCLOAK=True
```

### Ověřený uživatel

```python
from fastapi import Depends, FastAPI
from auth_lib import AuthenticatedUser, get_current_user, install_exception_handlers

app = FastAPI()
install_exception_handlers(app)

@app.get("/me")
async def me(user: AuthenticatedUser = Depends(get_current_user)) -> AuthenticatedUser:
    return user
```

S platným Bearer tokenem a zapnutou autentizací vrací endpoint ověřeného uživatele. Samotné `Depends(get_current_user)` však přihlášení nevynucuje — bez tokenu vrací anonymní identitu.

### Anonymní uživatel a vypnutá autentizace

Pokud token chybí (nebo je prázdný) **NEBO `AUTH_ENABLED=False`**, `get_current_user` vrací singleton **`ANONYMOUS`**: `sub='anonymous'`, `is_anonymous=True`, `roles=[]`. Při vypnuté autentizaci se dodaný token ignoruje a JWT se nevaliduje.

```python
from fastapi import Depends, FastAPI
from auth_lib import ANONYMOUS, ANONYMOUS_SUB, AuthenticatedUser, get_current_user

app = FastAPI()

assert ANONYMOUS.sub == ANONYMOUS_SUB == "anonymous"
assert ANONYMOUS.is_anonymous is True

@app.get("/resource")
async def resource(user: AuthenticatedUser = Depends(get_current_user)):
    if user.is_anonymous:
        return {"access": "public only", "sub": ANONYMOUS_SUB}
    return {"access": "full", "sub": user.sub}
```

- Při `AUTH_ENABLED=True` neplatný nebo expirovaný token stále vrací **401** — není nahrazen anonymní identitou.
- Anonymní uživatel nemá role; role guard proto vrací **403**.
- Endpointy vyžadující přihlášení musí explicitně odmítnout `user.is_anonymous` nebo použít odpovídající role guard.

### Hotový /whoami endpoint

Není potřeba psát vlastní endpoint pro zjištění identity. Knihovna exportuje volitelný `auth_router`:

```python
from fastapi import FastAPI
from auth_lib import auth_router, install_exception_handlers

app = FastAPI()
install_exception_handlers(app)
app.include_router(auth_router, prefix='/auth', tags=['auth'])
# GET /auth/whoami → AuthenticatedUser nebo ANONYMOUS
```

`GET /auth/whoami` vrací JSON modelu `AuthenticatedUser`, včetně pole `is_anonymous`. Bez tokenu nebo při vypnuté autentizaci vrací `ANONYMOUS` (`sub="anonymous"`, `is_anonymous=true`). Prefix i tagy můžeš upravit podle své aplikace.

### Role guard

```python
from auth_lib import require_role, require_any_role, require_all_roles

@app.delete("/users/{id}")
async def delete_user(
    id: str,
    user: AuthenticatedUser = Depends(require_role("admin")),
):
    ...

@app.get("/content")
async def list_content(
    user: AuthenticatedUser = Depends(require_any_role(["editor", "viewer"])),
):
    ...
```

### Konverze do SYSNET `UserType`

```python
from auth_lib import get_current_user

@app.get("/user-profile")
async def profile(user = Depends(get_current_user)):
    sysnet_user = user.to_user_type()   # sysnet_pyutils.UserType
    return sysnet_user
```

Mapping: `sub → identifier`, `preferred_username → name`, `email → email`, `given_name → name_first`, `family_name → name_last`, `name → name_full`.

---

## Observability

Knihovna neví, co je Prometheus / OpenTelemetry / strukturovaný log. Místo toho exponuje **pub-sub hooky**, které si konzument zaregistruje:

```python
from auth_lib import on_token_validated, on_token_rejected, on_jwks_refresh

@on_token_validated
def _ok(user):
    metrics.incr("auth.ok", tags={"sub": user.sub})

@on_token_rejected
def _err(exc):
    metrics.incr("auth.err", tags={"type": type(exc).__name__})

@on_jwks_refresh
def _jwks(n):
    metrics.gauge("auth.jwks.keys", n)
```

Výjimka v hooku **nikdy neshodí validaci** (hooky jsou best-effort).

### Rychlé zapnutí přes SYSNET logger

```python
from auth_lib import install_sysnet_logging
install_sysnet_logging()  # zapíše INFO/WARNING přes sysnet_pyutils.Log
```

Idempotentní — druhé volání už hooky znovu neregistruje.

---

## Výjimky

| Výjimka                  | HTTP | Kdy                                                     |
|--------------------------|------|---------------------------------------------------------|
| `InvalidTokenError`      | 401  | špatný podpis, expirace, `iss`, `aud`, neznámý `kid`, … |
| `MissingRoleError`       | 403  | chybí požadovaná role, včetně anonymního uživatele      |
| `AuthConfigurationError` | 500  | nedostupný JWKS, špatné URL, malformed response         |

Všechny dědí z `AuthError`.

`install_exception_handlers(app)` registruje handler, který vrací **`sysnet_pyutils.ErrorModel`**:

```json
{"code": 401, "message": "Token has expired"}
```

Jednotný formát chyb napříč SYSNET službami.

---

## JWKS caching

- In-memory TTL cache (default 300 s).
- Lazy fetch při prvním volání vyžadujícím ověření podpisu (`AUTH_ENABLED=True`, dodaný token, `AUTH_VERIFY_KEYCLOAK=True`).
- **Single-flight:** souběh N requestů při cold/expired cache spustí právě jeden fetch.
- **Fresh cache je autoritativní** — kid miss = `InvalidTokenError`. Brání DoS přes náhodné kidy.
- **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íčů.
- Lazy init `asyncio.Lock` — kompatibilní s Pythonem 3.14 (neváže lock na event loop z doby importu).
- Žádný background task, žádný disk.

---

## Bezpečnostní poznámky

- Při `AUTH_ENABLED=True` a `AUTH_VERIFY_KEYCLOAK=True` ověřujeme **podpis, issuer i audience**. Audience může být řetězec nebo seznam obsahující očekávané `AUTH_AUDIENCE`.
- **Pro produkční autentizaci nastav `AUTH_ENABLED=1` a ponech `AUTH_VERIFY_KEYCLOAK=True`.** Offline režim vypíná ověření podpisu a není bezpečný pro produkci.
- Výchozí `AUTH_ENABLED=False` znamená anonymní identitu pro všechny požadavky, nikoli ochranu endpointů. Přístup řiď explicitní kontrolou identity nebo rolí.
- Výchozí `leeway=0`. Zapnout jen při doloženém clock skew.
- Pouze `RS256` výchozí, `none` ani HS256 nepovolujeme bez dobrého důvodu.
- Čteme **pouze `Authorization: Bearer <token>`** (žádné cookies, žádné `X-*` hlavičky).
- Tokeny se nelogují. Diagnostika přes `sub` / `jti`.
- JWKS fetch má timeout → nedostupný Keycloak vrátí 500, ne čekání donekonečna.

---

## Struktura projektu

```
auth_lib/
├── auth_lib/
│   ├── __init__.py          # veřejné re-exporty (__all__)
│   ├── config.py            # AuthSettings, AUTH_ENABLED, AUTH_VERIFY_KEYCLOAK
│   ├── exceptions.py        # AuthError + to_error_model()
│   ├── models.py            # AuthenticatedUser, is_anonymous, ANONYMOUS, ANONYMOUS_SUB
│   ├── jwks.py              # async JWKS cache + cooldown
│   ├── dependencies.py      # get_current_user, install_exception_handlers
│   ├── router.py            # auth_router s GET /whoami
│   ├── roles.py             # require_role / require_any_role / require_all_roles
│   ├── observability.py     # hook registry + install_sysnet_logging()
│   └── py.typed             # PEP 561 marker
├── tests/
├── pyproject.toml           # sysnet-auth, Python 3.11-3.14
├── CHANGELOG.md
├── README.md
└── .gitignore
```

---

## Testování

```bash
pip install -e ".[dev]"
pytest --cov=auth_lib --cov-report=term-missing
```

Testy nevolají reálný Keycloak — používají vlastní RSA keypair a JWKS cache předvyplněnou odpovídajícím JWK. **71 testů, 98 % pokrytí.** Pokrývají také anonymní identitu, přepínač autentizace, `/whoami`, offline režim a audience jako seznam.

---

## Architektonický princip

> **Tato knihovna je jediným místem, kde se řeší validace identity uživatele.**

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.

## Verze 0.3.0 — novinky oproti 0.2.0

### Anonymní identita a přepínač autentizace

`ANONYMOUS` singleton a pole `is_anonymous` sjednocují chování bez tokenu. `AUTH_ENABLED` má výchozí hodnotu `False` pro zpětně kompatibilní zapojení do služeb; JWT validaci aktivuje `AUTH_ENABLED=1`.

### Hotový router

`auth_router` poskytuje `GET /whoami` pro vrácení aktuální identity bez vlastní implementace endpointu.

### Offline režim (bez Keycloaku)

Nastav `AUTH_ENABLED=1` a `AUTH_VERIFY_KEYCLOAK=False` pro dekódování tokenu bez JWKS a ověřování podpisu. Konfigurace URL, realm a audience zůstává povinná; kontroly issueru, audience a časových claimů zůstávají aktivní. Vhodné pouze pro lokální vývoj, **NIKDY v produkci**.

### Podpora Keycloak audience listu

Keycloak standardně vrací `aud` jako seznam (`["client-id", "account"]`). Knihovna správně zpracuje jak řetězec, tak seznam; očekávané `AUTH_AUDIENCE` musí odpovídat řetězci nebo být jednou z položek seznamu.
