Metadata-Version: 2.4
Name: auth-guardian
Version: 0.1.35
Summary: Libreria de autenticacion OIDC con Keycloak para FastAPI, Flask y Django
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: fastapi<1.0,>=0.118
Requires-Dist: httpx<0.29,>=0.28
Requires-Dist: python-jose[cryptography]<4,>=3.5
Provides-Extra: database
Requires-Dist: sqlalchemy<3,>=2.0; extra == "database"
Requires-Dist: aiosqlite<1,>=0.20; extra == "database"
Provides-Extra: fastapi
Requires-Dist: fastapi<1.0,>=0.118; extra == "fastapi"
Provides-Extra: flask
Requires-Dist: flask<4,>=3.0; extra == "flask"
Provides-Extra: django
Requires-Dist: django<6,>=4.2; extra == "django"
Provides-Extra: frameworks
Requires-Dist: flask<4,>=3.0; extra == "frameworks"
Requires-Dist: django<6,>=4.2; extra == "frameworks"
Provides-Extra: dev
Requires-Dist: build<2,>=1.3; extra == "dev"
Requires-Dist: django<6,>=4.2; extra == "dev"
Requires-Dist: fastapi<1.0,>=0.118; extra == "dev"
Requires-Dist: flask<4,>=3.0; extra == "dev"
Requires-Dist: mypy<2,>=1.10; extra == "dev"
Requires-Dist: pytest<9,>=8.3; extra == "dev"
Requires-Dist: pytest-asyncio<2,>=1.2; extra == "dev"
Requires-Dist: respx<0.23,>=0.22; extra == "dev"
Requires-Dist: ruff<0.15,>=0.14; extra == "dev"
Requires-Dist: twine<7,>=6.2; extra == "dev"

<div align="center">

# 🛡️ Auth Guardian

**Integra FastAPI, Flask o Django con Keycloak en minutos — sin pelearte con OIDC.**

<p align="center">
  <img src="https://img.shields.io/pypi/v/auth-guardian?color=1D4ED8&label=PyPI" alt="PyPI version"/>
  <img src="https://img.shields.io/pypi/pyversions/auth-guardian" alt="Python versions"/>
  <img src="https://img.shields.io/pypi/l/auth-guardian?color=green" alt="License"/>
  <img src="https://img.shields.io/badge/Keycloak-26-blue" alt="Keycloak 26"/>
</p>

</div>

---

`auth-guardian` te da **login OIDC, protección de endpoints y control por roles** con Keycloak, listos para usar. Tú te concentras en tu app; la librería se encarga del baile de tokens, el `state` anti-CSRF, la introspección y el logout con revocación.

```python
from fastapi import Depends, FastAPI
from auth_guardian import AuthGuardian, create_auth_router

app = FastAPI()
auth = AuthGuardian()                       # lee la config del entorno
app.include_router(create_auth_router(auth))  # /login, /oidc/callback, /logout

@app.get("/perfil")
async def perfil(user=Depends(auth.get_current_user)):
    return {"hola": user["preferred_username"]}
```

Eso es todo lo que necesitas para tener autenticación con Keycloak. 🎉

---

## 📑 Tabla de contenidos

- [¿Por qué auth-guardian?](#-por-qué-auth-guardian)
- [Características](#-características)
- [Compatibilidad](#-compatibilidad)
- [Instalación](#-instalación)
- [Configuración](#️-configuración)
- [Quickstart](#-quickstart)
  - [FastAPI](#fastapi)
  - [Flask](#flask)
  - [Django](#django)
- [Opciones del login (SSO y PKCE)](#️-opciones-del-login-sso-y-pkce)
- [Validación del token: `introspection` vs `local`](#-validación-del-token-introspection-vs-local)
- [Protección por roles](#-protección-por-roles)
- [Cómo funciona (diagramas)](#-cómo-funciona)
- [Manejo de errores](#-manejo-de-errores)
- [Referencia de la API pública](#-referencia-de-la-api-pública)
- [Avanzado (userinfo, multi-tenant, revocación, cookies)](#-avanzado)
- [Configurar el cliente en Keycloak](#-configurar-el-cliente-en-keycloak)
- [Seguridad](#-seguridad)
- [Troubleshooting](#-troubleshooting)
- [Desarrollo y contribución](#-desarrollo-y-contribución)
- [Roadmap](#️-roadmap)
- [Licencia](#-licencia)

---

## 🤔 ¿Por qué auth-guardian?

Integrar OIDC con Keycloak a mano significa: construir la URL de autorización, manejar el `state`, intercambiar el `code` por tokens, validar el `access_token` en cada request, refrescarlo, revocarlo al salir, extraer roles… y repetirlo en cada proyecto.

`auth-guardian` empaqueta todo eso detrás de una API pequeña y estable, **agnóstica de framework** (FastAPI, Flask, Django), para que integrar Keycloak sea cuestión de un par de líneas.

## ✨ Características

- 🔐 **Login OIDC completo** — rutas `/login`, `/oidc/callback` y `/logout` listas para montar.
- 🧩 **Multi-framework** — FastAPI, Flask y Django con la misma configuración.
- 🔏 **PKCE (S256) por defecto** — como recomiendan Keycloak 26 y OAuth 2.1, incluso para clientes confidenciales.
- 👤 **Protección de endpoints** — `get_current_user` como dependencia/decorador.
- 🎭 **Control por roles** — `require_role("admin")` con roles de realm y de client.
- ⚡ **Dos modos de validación** — introspección (revocación instantánea) o firma local (máximo rendimiento) con JWKS cacheado.
- 🚪 **Logout seguro** — revoca el refresh token en Keycloak *antes* de borrar cookies.
- 🛡️ **`state` anti-CSRF firmado** en el flujo OIDC.
- 🧾 **`userinfo`** — claims frescos del usuario directo desde Keycloak.
- 🧯 **Errores con contexto** — los fallos traen el `error`/`error_description` OAuth exacto de Keycloak (`invalid_grant`, etc.) para diagnosticar en segundos.
- 🚨 **Fallo temprano y claro** — si falta configuración, te lo dice al arrancar.
- 👥 **Admin API** — crear usuarios en Keycloak desde tu backend (opcional).
- 🏢 **Multi-tenant** — resuelve el realm por request con `tenant_resolver`.

## 🧬 Compatibilidad

| | Versiones soportadas |
|---|---|
| **Python** | 3.10+ |
| **FastAPI** | 0.118 – 0.139+ |
| **Flask** | 3.x |
| **Django** | 4.2 – 5.x |
| **Keycloak** | 26 (y compatibles con OIDC estándar) |

---

## 📦 Instalación

```bash
pip install auth-guardian            # FastAPI (por defecto)
pip install "auth-guardian[flask]"   # Flask
pip install "auth-guardian[django]"  # Django
```

---

## ⚙️ Configuración

`auth-guardian` lee estas variables de entorno. Las **obligatorias** hacen que la librería falle al arrancar con un mensaje claro si faltan:

| Variable | Obligatoria | Descripción |
|---|:---:|---|
| `KEYCLOAK_BASE_URL` | ✅ | URL pública de Keycloak (ej. `https://sso.midominio.com`). |
| `KEYCLOAK_REALM` | ✅ | Nombre del realm. |
| `KEYCLOAK_CLIENT_ID` | ✅ | Client ID (cliente confidencial). |
| `KEYCLOAK_CLIENT_SECRET` | ✅ | Client secret. También firma el `state` anti-CSRF. |
| `AUTH_TOKEN_VALIDATION` | — | `introspection` (por defecto) o `local`. Ver [más abajo](#-validación-del-token-introspection-vs-local). |
| `IS_PROD` | — | `true` marca las cookies como `Secure` (HTTPS). Por defecto `false`. |

> 💡 También se aceptan los alias `AUTH_BASE_URL`, `AUTH_REALM` y `AUTH_CLIENT_ID`.
>
> 💡 **Detrás de Docker/proxy** puedes separar la URL pública de la interna pasando `internal_url` a `AuthConfig` (el navegador usa la pública; el backend, la interna).

---

## 🚀 Quickstart

### FastAPI

```python
from typing import Any
from fastapi import Depends, FastAPI
from auth_guardian import AuthGuardian, create_auth_router

app = FastAPI()
auth = AuthGuardian()

# Monta /login, /oidc/callback y /logout
app.include_router(
    create_auth_router(auth, login_redirect_url="/perfil", logout_redirect_url="/login")
)

@app.get("/perfil")
async def perfil(user: dict[str, Any] = Depends(auth.get_current_user)):
    return {"usuario": user["preferred_username"], "email": user.get("email")}

@app.get("/admin")
async def admin(user: dict[str, Any] = Depends(auth.require_role("admin"))):
    return {"ok": True}
```

### Flask

```python
from flask import Flask, jsonify, g
from auth_guardian import AuthGuardian, create_flask_integration

app = Flask(__name__)
auth = AuthGuardian()
flask_auth = create_flask_integration(auth)

flask_auth.register_auth_routes(app, login_redirect_url="/perfil", logout_redirect_url="/login")

@app.get("/perfil")
@flask_auth.require_auth()
def perfil():
    return jsonify(g.auth_user)

@app.get("/admin")
@flask_auth.require_role("admin")
def admin():
    return jsonify({"ok": True})
```

### Django

```python
from django.http import JsonResponse
from django.urls import path
from auth_guardian import AuthGuardian, create_django_integration

auth = AuthGuardian()
django_auth = create_django_integration(auth)

@django_auth.require_auth()
def perfil(request):
    return JsonResponse(request.auth_user)

@django_auth.require_role("admin")
def admin(request):
    return JsonResponse({"ok": True})

urlpatterns = [
    *django_auth.build_auth_urlpatterns(login_redirect_url="/perfil/", logout_redirect_url="/login/"),
    path("perfil/", perfil),
    path("admin/", admin),
]
```

---

## 🎛️ Opciones del login (SSO y PKCE)

Los tres frameworks aceptan las mismas opciones al registrar las rutas:

```python
create_auth_router(
    auth,
    login_redirect_url="/perfil",   # a dónde va el usuario tras loguearse
    logout_redirect_url="/login",   # a dónde va tras salir (o si el login falla)
    prompt="login",                 # "login" fuerza credenciales SIEMPRE;
                                    # None respeta la sesión SSO activa de Keycloak
    use_pkce=True,                  # PKCE S256 (recomendado; actívalo salvo motivo concreto)
    on_login_success=mi_hook,       # callback con el payload del token tras login OK
)
```

> 💡 **SSO real:** con `prompt=None`, un usuario con sesión activa en Keycloak entra
> directo sin volver a teclear credenciales. El valor por defecto `"login"` fuerza
> re-autenticación en cada login (útil para apps sensibles).

---

## 🔎 Validación del token: `introspection` vs `local`

En cada request protegido, `auth-guardian` valida el `access_token`. Elige el modo con `AUTH_TOKEN_VALIDATION`:

| Modo | Cómo valida | Ventaja | Coste |
|---|---|---|---|
| **`introspection`** (por defecto) | Pregunta a Keycloak (`/token/introspect`) en cada request | Revocación **instantánea** | Una llamada de red por request |
| **`local`** | Verifica la firma del JWT contra el **JWKS** (cacheado, TTL 300s) | Muy **rápido**, sin red por request | La revocación no es instantánea (usa tokens de vida corta) |

> Regla práctica: **`introspection`** para máxima seguridad; **`local`** cuando el throughput importa.

---

## 🎭 Protección por roles

```python
# Un rol
Depends(auth.require_role("admin"))

# Cualquiera de varios roles
Depends(auth.require_role("admin", "auditor"))
```

Considera roles de **realm** (`realm_access.roles`) y de **client** (`resource_access`). Si el usuario no tiene el rol, responde **403**.

---

## 🔧 Cómo funciona

### 1. Login OIDC

```mermaid
sequenceDiagram
    participant U as Usuario
    participant A as Tu API
    participant K as Keycloak
    U->>A: GET /login
    A->>K: Redirect (authorization request + state firmado)
    K-->>U: Pantalla de login
    U->>K: Credenciales
    K-->>A: Redirect /oidc/callback?code=...
    A->>K: Intercambia code por tokens
    K-->>A: access_token + refresh_token
    A-->>U: Set cookies + redirect a la app
```

### 2. Request protegido (introspection)

```mermaid
sequenceDiagram
    participant C as Cliente
    participant A as Tu API
    participant K as Keycloak
    C->>A: Request con token
    A->>K: POST /token/introspect
    K-->>A: active: true  → 200 OK
    K-->>A: active: false → 401 Unauthorized
```

### 3. Logout

```mermaid
sequenceDiagram
    participant U as Usuario
    participant A as Tu API
    participant K as Keycloak
    U->>A: GET /logout
    A->>K: POST /revoke (refresh_token)
    K-->>A: 200 / 204
    A-->>U: Borra cookies + redirect
```

---

## 🧯 Manejo de errores

Todas las excepciones heredan de tipos claros y no filtran detalles internos al cliente:

| Excepción | Cuándo se lanza |
|---|---|
| `TokenValidationError` | Token inválido, expirado o emitido para otro cliente. |
| `KeycloakAPIError` | Fallo al hablar con Keycloak. Trae `status_code`, `detail` **y el error OAuth exacto**: `error` (p. ej. `invalid_grant`) y `error_description`. |
| `KeycloakAuthError` | Clase base de las anteriores. |

```python
try:
    await auth.oidc_client.refresh_access_token(refresh)
except KeycloakAPIError as exc:
    # exc.error == "invalid_grant" · exc.error_description == "Token is not active"
    log.warning("Refresh falló: %s (%s)", exc.error, exc.error_description)
```

| Situación | Respuesta al cliente |
|---|---|
| `active: false` / token inválido | `401 Unauthorized` |
| Rol insuficiente | `403 Forbidden` |
| Keycloak no disponible | `503 Service Unavailable` (sin exponer internos) |

```python
from auth_guardian import KeycloakAPIError

try:
    ...
except KeycloakAPIError as exc:
    raise HTTPException(status_code=exc.status_code, detail=exc.detail)
```

---

## 📚 Referencia de la API pública

El contrato público se mantiene **pequeño y estable** a propósito:

| Símbolo | Qué es |
|---|---|
| `AuthGuardian` | Punto de entrada. Métodos: `get_current_user`, `require_role(*roles)`, `authenticate_token`, `revoke_token`, `startup`/`shutdown`. |
| `AuthConfig` | Configuración avanzada (`internal_url`, `issuer`, `token_validation`, `jwks_cache_ttl_seconds`…). |
| `create_auth_router(auth, ...)` | Router de FastAPI con `/login`, `/oidc/callback`, `/logout`. |
| `create_flask_integration(auth)` | Integración Flask (`register_auth_routes`, `require_auth`, `require_role`). |
| `create_django_integration(auth)` | Integración Django (`build_auth_urlpatterns`, `require_auth`, `require_role`). |
| `AuthOIDCClient` | Cliente OIDC de bajo nivel (token, refresh, revoke, `fetch_userinfo`, admin API). |
| `extract_client_roles(payload, client_id)` | Extrae roles de client del token. |
| `generate_pkce_pair()` | Par PKCE `(code_verifier, code_challenge)` S256 para flujos propios. |
| Backends de revocación | `MemoryRevocationBackend`, `DatabaseRevocationBackend`, `AutoRevocationBackend`, `NullRevocationBackend`. |
| Excepciones | `KeycloakAuthError`, `TokenValidationError`, `KeycloakAPIError`. |

---

## 🧠 Avanzado

### `userinfo`: claims frescos del usuario

```python
info = await auth.oidc_client.fetch_userinfo(access_token)
# {"sub": "...", "email": "...", "preferred_username": "..."}
```

### Multi-tenant (un realm por request)

Resuelve el realm dinámicamente (por subdominio, cabecera, etc.). Los clientes por
realm se cachean automáticamente:

```python
def resolver_realm(request) -> str:
    return request.headers.get("X-Tenant", "realm-default")

auth = AuthGuardian(tenant_resolver=resolver_realm)
```

### Ciclo de vida (recursos HTTP)

```python
app = FastAPI(lifespan=auth.lifespan())   # startup/shutdown automáticos
```

### Backends de revocación (modo `local`)

En validación local, los access tokens revocados con `auth.revoke_token(token)` se
rechazan consultando un backend por JTI:

| Backend | Uso |
|---|---|
| `MemoryRevocationBackend` (defecto) | Proceso único. |
| `DatabaseRevocationBackend` | Compartido entre workers/instancias (SQLAlchemy). |
| `AutoRevocationBackend` | Elige según configuración. |
| `NullRevocationBackend` | Desactiva el chequeo. |

### Personalizar cookies

Los nombres de cookies (`access_token`, `id_token`, `refresh_token`) se pueden
cambiar con `cookie_name`, `cookie_id_name` y `cookie_refresh_name` al registrar
las rutas en cualquiera de los tres frameworks.

---

## 🔑 Configurar el cliente en Keycloak

1. Crea un cliente **OIDC** en tu realm.
2. Activa **Client authentication** (cliente confidencial).
3. Copia el **Client Secret** → `KEYCLOAK_CLIENT_SECRET`.
4. En **Valid Redirect URIs**, agrega la URL de tu callback (ej. `https://tuapp.com/oidc/callback`).
5. Define los **roles** (de realm o de client) según tu modelo de autorización.

> Detrás de un proxy inverso, asegúrate de propagar `Host` y `X-Forwarded-*` para que las Redirect URIs coincidan.

---

## 🔒 Seguridad

- **PKCE (S256)** activado por defecto en el flujo de autorización, como recomiendan Keycloak 26 y OAuth 2.1 — protege el `code` incluso si es interceptado.
- **`state` firmado** (HMAC-SHA256) en el flujo OIDC para prevenir CSRF.
- **Cookies** `HttpOnly` + `SameSite=Lax` (+ `Secure` con `IS_PROD=true`); el logout **revoca el refresh token** en Keycloak antes de borrarlas.
- **Sin fuga de detalles**: los errores de Keycloak se traducen a respuestas genéricas para el cliente final (el detalle queda en tus logs).
- Con `AUTH_TOKEN_VALIDATION=local`, usa **tokens de vida corta** para acotar la ventana de revocación (la librería te avisa al arrancar si el lifespan es alto).

---

## 🩺 Troubleshooting

<details>
<summary><b><code>Missing required configuration variables</code></b></summary>

Falta una variable obligatoria. Revisa la sección [Configuración](#️-configuración) y define `KEYCLOAK_BASE_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID` y `KEYCLOAK_CLIENT_SECRET`.
</details>

<details>
<summary><b><code>ModuleNotFoundError: No module named 'flask' / 'django'</code></b></summary>

Estás usando el adaptador sin instalar el extra: `pip install "auth-guardian[flask]"` o `"auth-guardian[django]"`.
</details>

<details>
<summary><b>Introspection devuelve 401 o 403</b></summary>

El cliente no es confidencial o el secret es incorrecto. Verifica `KEYCLOAK_CLIENT_SECRET` y que **Client authentication** esté activo en Keycloak.
</details>

<details>
<summary><b>Login falla en el callback (Redirect URI mismatch)</b></summary>

La Redirect URI de Keycloak no coincide con la de tu app. Revisa **Valid Redirect URIs** y, si estás tras un proxy, las cabeceras `X-Forwarded-*`.
</details>

---

## 🛠️ Desarrollo y contribución

```bash
git clone <repo> && cd auth-guardian
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

pytest          # tests
ruff check src  # lint
mypy src        # tipos
```

Los PRs son bienvenidos. Mantén el contrato público (`__all__`) pequeño y estable, y acompaña los cambios con tests.

## 🗺️ Roadmap

- `nonce` OIDC en el authorization request.
- Backchannel logout (propagación de logout SSO desde Keycloak).
- Device authorization flow.
- Reutilización del cliente HTTP en `AuthOIDCClient` (hoy solo el validador lo reutiliza).

## 📄 Licencia

MIT.
