Metadata-Version: 2.4
Name: auth-guardian
Version: 0.1.40
Summary: Libreria de autenticacion OIDC con Keycloak para FastAPI, Flask y Django
Author: JcperezDev
License-Expression: MIT
Keywords: keycloak,oidc,oauth2,authentication,fastapi,flask,django
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Framework :: FastAPI
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
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"
Dynamic: license-file

<div align="center">

# 🛡️ Auth Guardian

**Add Keycloak authentication to FastAPI, Flask or Django in minutes — without wrestling with OIDC.**

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

**English** · [Español](https://github.com/JcperezDev/auth-guardian/blob/main/README.es.md)

</div>

---

`auth-guardian` gives you ready-to-use **OIDC login, endpoint protection and role-based access** with Keycloak. You focus on your app; the library handles the token dance, the anti-CSRF `state`, introspection and logout with revocation.

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

app = FastAPI()
auth = AuthGuardian()                       # reads config from the environment
app.include_router(create_auth_router(auth))  # /login, /oidc/callback, /logout

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

That's all you need for Keycloak authentication.

> ▶️ **Want to try it right now?** A complete, self-contained demo (Keycloak + FastAPI, one command) lives in [`examples/fastapi-keycloak`](examples/fastapi-keycloak).

---

## Table of contents

- [Why auth-guardian?](#-why-auth-guardian)
- [Features](#features)
- [Compatibility](#compatibility)
- [Installation](#installation)
- [Configuration](#configuration)
- [Quickstart](#quickstart)
  - [FastAPI](#fastapi)
  - [Flask](#flask)
  - [Django](#django)
- [Login options (SSO and PKCE)](#login-options-sso-and-pkce)
- [Token validation: `introspection` vs `local`](#token-validation-introspection-vs-local)
- [Role-based protection](#role-based-protection)
- [How it works (diagrams)](#how-it-works)
- [Error handling](#error-handling)
- [Public API reference](#public-api-reference)
- [Advanced (userinfo, multi-tenant, revocation, cookies)](#advanced)
- [Configuring the client in Keycloak](#configuring-the-client-in-keycloak)
- [Security](#security)
- [Skill for AI agents](#skill-for-ai-agents)
- [Troubleshooting](#troubleshooting)
- [Development and contributing](#development-and-contributing)
- [License](#license)

---

## 🤔 Why auth-guardian?

Wiring OIDC with Keycloak by hand means: building the authorization URL, handling the `state`, exchanging the `code` for tokens, validating the `access_token` on every request, refreshing it, revoking it on logout, extracting roles… and repeating it in every project.

`auth-guardian` packages all of that behind a small, stable, **framework-agnostic** API (FastAPI, Flask, Django), so integrating Keycloak takes a couple of lines.

## Features

- **Complete OIDC login** — `/login`, `/oidc/callback` and `/logout` routes ready to mount.
- **Multi-framework** — FastAPI, Flask and Django with the same configuration.
- **PKCE (S256) + nonce by default** — as recommended by Keycloak 26 and OAuth 2.1, even for confidential clients.
- **Device flow** — browserless login (CLI, TV, IoT) with the Device Authorization Grant.
- **Backchannel logout** — receives and validates Keycloak's logout to end sessions server-side.
- **Endpoint protection** — `get_current_user` as a dependency/decorator.
- **Role-based access** — `require_role("admin")` with realm and client roles.
- **Two validation modes** — introspection (instant revocation) or local signature (max performance) with cached JWKS.
- **Automatic silent token refresh** — when the access token expires, the FastAPI dependency renews it transparently with the refresh token (works in both `introspection` and `local` modes).
- **Secure logout** — revokes the refresh token in Keycloak *before* clearing cookies.
- **Signed anti-CSRF `state`** in the OIDC flow.
- **`userinfo`** — fresh user claims straight from Keycloak.
- **Errors with context** — failures carry Keycloak's exact OAuth `error`/`error_description` (`invalid_grant`, etc.) so you can diagnose in seconds.
- **Fail fast and clear** — if configuration is missing, it tells you at startup.
- **Admin API** — create users in Keycloak from your backend (optional).
- **Multi-tenant** — resolve the realm per request with `tenant_resolver`.

## Compatibility

| | Supported versions |
|---|---|
| **Python** | 3.10+ |
| **FastAPI** | 0.118 – 0.139+ |
| **Flask** | 3.x |
| **Django** | 4.2 – 5.x |
| **Keycloak** | 26 (and any standard OIDC-compliant provider) |

---

## Installation

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

---

## Configuration

`auth-guardian` reads these environment variables. The **required** ones make the library fail at startup with a clear message if they are missing:

| Variable | Required | Description |
|---|:---:|---|
| `KEYCLOAK_BASE_URL` | ✅ | Public Keycloak URL (e.g. `https://sso.mydomain.com`). |
| `KEYCLOAK_REALM` | ✅ | Realm name. |
| `KEYCLOAK_CLIENT_ID` | ✅ | Client ID (confidential client). |
| `KEYCLOAK_CLIENT_SECRET` | ✅ | Client secret. Also signs the anti-CSRF `state`. |
| `AUTH_TOKEN_VALIDATION` | — | `introspection` (default) or `local`. See [below](#token-validation-introspection-vs-local). |
| `IS_PROD` | — | `true` marks cookies as `Secure` (HTTPS). Defaults to `false`. |

> 💡 The aliases `AUTH_BASE_URL`, `AUTH_REALM` and `AUTH_CLIENT_ID` are also accepted.
>
> 💡 **Behind Docker/proxy** you can separate the public URL from the internal one by passing `internal_url` to `AuthConfig` (the browser uses the public one; the backend, the internal one).

---

## Quickstart

### FastAPI

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

app = FastAPI()
auth = AuthGuardian()

# Mounts /login, /oidc/callback and /logout
app.include_router(
    create_auth_router(auth, login_redirect_url="/profile", logout_redirect_url="/login")
)

@app.get("/profile")
async def profile(user: dict[str, Any] = Depends(auth.get_current_user)):
    return {"user": 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="/profile", logout_redirect_url="/login")

@app.get("/profile")
@flask_auth.require_auth()
def profile():
    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 profile(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="/profile/", logout_redirect_url="/login/"),
    path("profile/", profile),
    path("admin/", admin),
]
```

---

## Login options (SSO and PKCE)

All three frameworks accept the same options when registering the routes:

```python
create_auth_router(
    auth,
    login_redirect_url="/profile",  # where the user goes after logging in
    logout_redirect_url="/login",   # where they go after logout (or if login fails)
    prompt="login",                 # "login" ALWAYS forces credentials;
                                    # None honors the active Keycloak SSO session
    use_pkce=True,                  # PKCE S256 (recommended; keep it on unless you have a reason)
    use_nonce=True,                 # OIDC nonce (verified against the id_token)
    on_login_success=my_hook,       # callback with the token payload after a successful login
)
```

> 💡 **Real SSO:** with `prompt=None`, a user with an active Keycloak session gets
> in directly without re-entering credentials. The default `"login"` forces
> re-authentication on every login (useful for sensitive apps).

---

## Token validation: `introspection` vs `local`

On every protected request, `auth-guardian` validates the `access_token`. Choose the mode with `AUTH_TOKEN_VALIDATION`:

| Mode | How it validates | Advantage | Cost |
|---|---|---|---|
| **`introspection`** (default) | Asks Keycloak (`/token/introspect`) on every request | **Instant** revocation | One network call per request |
| **`local`** | Verifies the JWT signature against the **JWKS** (cached, TTL 300s) | Very **fast**, no network per request | Revocation isn't instant (use short-lived tokens) |

> Rule of thumb: **`introspection`** for maximum security; **`local`** when throughput matters.

---

## Role-based protection

```python
# A single role
Depends(auth.require_role("admin"))

# Any of several roles
Depends(auth.require_role("admin", "auditor"))
```

Considers both **realm** roles (`realm_access.roles`) and **client** roles (`resource_access`). If the user lacks the role, it responds **403**.

---

## How it works

### 1. OIDC login

```mermaid
sequenceDiagram
    participant U as User
    participant A as Your API
    participant K as Keycloak
    U->>A: GET /login
    A->>K: Redirect (authorization request + signed state)
    K-->>U: Login screen
    U->>K: Credentials
    K-->>A: Redirect /oidc/callback?code=...
    A->>K: Exchange code for tokens
    K-->>A: access_token + refresh_token
    A-->>U: Set cookies + redirect to the app
```

### 2. Protected request (introspection)

```mermaid
sequenceDiagram
    participant C as Client
    participant A as Your API
    participant K as Keycloak
    C->>A: Request with 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 User
    participant A as Your API
    participant K as Keycloak
    U->>A: GET /logout
    A->>K: POST /revoke (refresh_token)
    K-->>A: 200 / 204
    A-->>U: Clear cookies + redirect
```

---

## Error handling

Every exception inherits from clear types and never leaks internal details to the client:

| Exception | When it's raised |
|---|---|
| `TokenValidationError` | Token invalid, expired or issued for another client. |
| `KeycloakAPIError` | Failure talking to Keycloak. Carries `status_code`, `detail` **and the exact OAuth error**: `error` (e.g. `invalid_grant`) and `error_description`. |
| `KeycloakAuthError` | Base class of the above. |

```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 failed: %s (%s)", exc.error, exc.error_description)
```

| Situation | Response to the client |
|---|---|
| `active: false` / invalid token | `401 Unauthorized` |
| Insufficient role | `403 Forbidden` |
| Keycloak unavailable | `503 Service Unavailable` (without exposing internals) |

```python
from auth_guardian import KeycloakAPIError

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

---

## Public API reference

The public contract is kept **small and stable** on purpose:

| Symbol | What it is |
|---|---|
| `AuthGuardian` | Entry point. Methods: `get_current_user`, `require_role(*roles)`, `authenticate_token`, `revoke_token`, `startup`/`shutdown`. |
| `AuthConfig` | Advanced configuration (`internal_url`, `issuer`, `token_validation`, `jwks_cache_ttl_seconds`…). |
| `create_auth_router(auth, ...)` | FastAPI router with `/login`, `/oidc/callback`, `/logout`. |
| `create_flask_integration(auth)` | Flask integration (`register_auth_routes`, `require_auth`, `require_role`). |
| `create_django_integration(auth)` | Django integration (`build_auth_urlpatterns`, `require_auth`, `require_role`). |
| `AuthOIDCClient` | Low-level OIDC client (token, refresh, revoke, `fetch_userinfo`, `get_service_account_token`, admin API). |
| `extract_client_roles(payload, client_id)` | Extracts client roles from the token. |
| `generate_pkce_pair()` | PKCE `(code_verifier, code_challenge)` S256 pair for custom flows. |
| Revocation backends | `MemoryRevocationBackend`, `DatabaseRevocationBackend`, `AutoRevocationBackend`, `NullRevocationBackend`. |
| Exceptions | `KeycloakAuthError`, `TokenValidationError`, `KeycloakAPIError`. |

---

## Advanced

### `userinfo`: fresh user claims

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

### Machine-to-machine (service account tokens)

For service-to-service calls with no user, use the `client_credentials` grant with
this client's own service account. Enable **Service accounts** on the Keycloak client.

```python
token = await auth.get_service_account_token()          # optionally scope="my-api"
access_token = token["access_token"]                    # cache until ~expires_in
# call another API: headers={"Authorization": f"Bearer {access_token}"}
```

### Active Directory / LDAP attributes and custom claims

`get_current_user` returns the **full token claims** (a plain `dict`), so **any claim
Keycloak puts in the token is available to your app** — including attributes federated
from Active Directory / LDAP. Nothing to change in the library; you wire the attribute
in Keycloak and just read it:

1. **User Federation → LDAP** maps the AD attribute to a Keycloak user attribute.
2. Add a **protocol mapper** (User Attribute → token claim) on the client/scope.
3. Read it in your app: `user.get("department")`.

Common AD attributes and the claim they usually land in:

| AD attribute | Typical claim | Example |
|---|---|---|
| `displayName` | `name` | John Smith |
| `sAMAccountName` | `preferred_username` | JSmith |
| `userPrincipalName` | `upn` / `preferred_username` | JSmith@domain.com |
| `mail` | `email` | jsmith@domain.com |
| `givenName` | `given_name` | John |
| `sn` | `family_name` | Smith |
| `department` | `department` (custom) | Sales |
| `title` | `title` (custom) | Manager |
| `memberOf` | `groups` (via Group mapper) | Managers |

```python
@app.get("/me")
async def me(user: dict = Depends(auth.get_current_user)):
    return {
        "name": user.get("name"),            # displayName
        "email": user.get("email"),          # mail
        "department": user.get("department"),  # custom AD mapper
    }
```

> 💡 Prefer `auth.oidc_client.fetch_userinfo(access_token)` when you want the freshest
> attribute values without re-issuing the token.

### Organizations (Keycloak 26)

Keycloak 26 can add an `organization` claim (id + attributes) to tokens. Since the full
payload is returned, read it directly: `user.get("organization")`. For per-realm
multi-tenancy see [Multi-tenant](#multi-tenant-one-realm-per-request).

> ⚠️ **Lightweight access tokens (Keycloak 26):** if you enable them on the client, the
> access token is trimmed and may not carry roles/attributes. Use `introspection` mode
> (default) or `fetch_userinfo` so role checks and claims keep working.

### Multi-tenant (one realm per request)

Resolve the realm dynamically (by subdomain, header, etc.). Per-realm clients are
cached automatically:

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

auth = AuthGuardian(tenant_resolver=resolve_realm)
```

### Lifecycle (HTTP resources)

```python
app = FastAPI(lifespan=auth.lifespan())   # automatic startup/shutdown
```

### Revocation backends (`local` mode)

In local validation, access tokens revoked with `auth.revoke_token(token)` are
rejected by checking a backend by JTI:

| Backend | Use |
|---|---|
| `MemoryRevocationBackend` (default) | Single process. |
| `DatabaseRevocationBackend` | Shared across workers/instances (SQLAlchemy). |
| `AutoRevocationBackend` | Picks based on configuration. |
| `NullRevocationBackend` | Disables the check. |

### Customizing cookies

The cookie names (`access_token`, `id_token`, `refresh_token`) can be changed with
`cookie_name`, `cookie_id_name` and `cookie_refresh_name` when registering the
routes in any of the three frameworks.

### Device flow (browserless login)

For CLIs, TVs or IoT (Device Authorization Grant, RFC 8628). Enable *OAuth 2.0
Device Authorization Grant* on the Keycloak client.

```python
dev = await auth.oidc_client.request_device_authorization()
print(f"Go to {dev['verification_uri']} and enter the code: {dev['user_code']}")

# Poll until the user approves (honors interval and expiration)
tokens = await auth.oidc_client.poll_until_authorized(dev)
```

### Backchannel logout

Receives the logout Keycloak sends when a session ends (OIDC Back-Channel Logout).
Register `POST /backchannel-logout` as the client's *Backchannel logout URL* in
Keycloak.

```python
from auth_guardian import create_backchannel_logout_route

async def end_session(claims):
    # invalidate your local session by claims["sub"] and/or claims["sid"]
    ...

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

The route validates the `logout_token` (signature, issuer, audience, event, no nonce)
and only then calls your callback.

---

## Configuring the client in Keycloak

1. Create an **OIDC** client in your realm.
2. Enable **Client authentication** (confidential client).
3. Copy the **Client Secret** → `KEYCLOAK_CLIENT_SECRET`.
4. In **Valid Redirect URIs**, add your callback URL (e.g. `https://yourapp.com/oidc/callback`).
5. Define the **roles** (realm or client) according to your authorization model.

> Behind a reverse proxy, make sure to forward `Host` and `X-Forwarded-*` so the Redirect URIs match.

---

## Security

- **PKCE (S256) + nonce** enabled by default in the authorization flow, as recommended by Keycloak 26 and OAuth 2.1 — they protect the `code` and prevent id_token replay.
- **Signed `state`** (HMAC-SHA256) in the OIDC flow to prevent CSRF.
- **Cookies** `HttpOnly` + `SameSite=Lax` (+ `Secure` with `IS_PROD=true`); logout **revokes the refresh token** in Keycloak before clearing them.
- **No detail leakage**: Keycloak errors are translated into generic responses for the end client (the detail stays in your logs).
- With `AUTH_TOKEN_VALIDATION=local`, use **short-lived tokens** to bound the revocation window (the library warns you at startup if the lifespan is high).

---

## Skill for AI agents

If you work with coding assistants (Claude, Cursor, etc.), the library ships a
**skill** in [`skills/auth-guardian/SKILL.md`](skills/auth-guardian/SKILL.md) with
everything an agent needs to integrate it well (API, patterns, common pitfalls).

- With [`library-skills`](https://library-skills.io): `uvx library-skills` detects and installs it.
- Manual: copy `skills/auth-guardian/` into your project's `.claude/skills/` (or `.agents/skills/`).

---

## Troubleshooting

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

A required variable is missing. Check the [Configuration](#configuration) section and set `KEYCLOAK_BASE_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID` and `KEYCLOAK_CLIENT_SECRET`.
</details>

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

You're using the adapter without installing the extra: `pip install "auth-guardian[flask]"` or `"auth-guardian[django]"`.
</details>

<details>
<summary><b>Introspection returns 401 or 403</b></summary>

The client isn't confidential or the secret is wrong. Check `KEYCLOAK_CLIENT_SECRET` and that **Client authentication** is enabled in Keycloak.
</details>

<details>
<summary><b>Login fails at the callback (Redirect URI mismatch)</b></summary>

Keycloak's Redirect URI doesn't match your app's. Review **Valid Redirect URIs** and, if you're behind a proxy, the `X-Forwarded-*` headers.
</details>

---

## Development and contributing

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

PRs are welcome. Keep the public contract (`__all__`) small and stable, and back your changes with tests.

## License

MIT.
