Metadata-Version: 2.4
Name: auth-guardian
Version: 0.1.36
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)
- [Skill para agentes de IA](#skill-para-agentes-de-ia)
- [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) + nonce por defecto** — como recomiendan Keycloak 26 y OAuth 2.1, incluso para clientes confidenciales.
- **Device flow** — login sin navegador (CLI, TV, IoT) con el Device Authorization Grant.
- **Backchannel logout** — recibe y valida el logout de Keycloak para cerrar sesiones desde el servidor.
- **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)
    use_nonce=True,                 # nonce OIDC (se verifica contra el id_token)
    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.

### Device flow (login sin navegador)

Para CLIs, TVs o IoT (Device Authorization Grant, RFC 8628). Habilita *OAuth 2.0
Device Authorization Grant* en el cliente de Keycloak.

```python
dev = await auth.oidc_client.request_device_authorization()
print(f"Ve a {dev['verification_uri']} e ingresa el código: {dev['user_code']}")

# Sondea hasta que el usuario apruebe (respeta interval y expiración)
tokens = await auth.oidc_client.poll_until_authorized(dev)
```

### Backchannel logout

Recibe el logout que Keycloak envía cuando termina una sesión (OIDC Back-Channel
Logout). Registra `POST /backchannel-logout` como *Backchannel logout URL* del
cliente en Keycloak.

```python
from auth_guardian import create_backchannel_logout_route

async def cerrar_sesion(claims):
    # invalida tu sesión local por claims["sub"] y/o claims["sid"]
    ...

app.include_router(create_backchannel_logout_route(auth, cerrar_sesion))
```

La ruta valida el `logout_token` (firma, emisor, audiencia, evento, sin nonce) y
solo entonces llama a tu callback.

---

## 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) + nonce** activados por defecto en el flujo de autorización, como recomiendan Keycloak 26 y OAuth 2.1 — protegen el `code` y previenen replay del id_token.
- **`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).

---

## Skill para agentes de IA

Si trabajas con asistentes de código (Claude, Cursor, etc.), la librería incluye una
**skill** en [`skills/auth-guardian/SKILL.md`](skills/auth-guardian/SKILL.md) con todo
lo que un agente necesita saber para integrarla bien (API, patrones, errores comunes).

- Con [`library-skills`](https://library-skills.io): `uvx library-skills` la detecta e instala.
- Manual: copia `skills/auth-guardian/` dentro de `.claude/skills/` (o `.agents/skills/`) de tu proyecto.

---

## 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

Ideas para más adelante (aportes bienvenidos): rotación automática de JWKS ante
`kid` desconocido y métricas/hooks de observabilidad.

## Licencia

MIT.
