Libreria de autenticacion OIDC con Keycloak para FastAPI, Flask y Django
Project description
🛡️ Auth Guardian
Add Keycloak authentication to FastAPI, Flask or Django in minutes — without wrestling with OIDC.
English · Español
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.
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.
Table of contents
- Why auth-guardian?
- Features
- Compatibility
- Installation
- Configuration
- Quickstart
- Login options (SSO and PKCE)
- Token validation:
introspectionvslocal - Role-based protection
- How it works (diagrams)
- Error handling
- Public API reference
- Advanced (userinfo, multi-tenant, revocation, cookies)
- Configuring the client in Keycloak
- Security
- Skill for AI agents
- Troubleshooting
- Development and contributing
- 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/callbackand/logoutroutes 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_useras 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
introspectionandlocalmodes). - Secure logout — revokes the refresh token in Keycloak before clearing cookies.
- Signed anti-CSRF
statein 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
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. |
IS_PROD |
— | true marks cookies as Secure (HTTPS). Defaults to false. |
💡 The aliases
AUTH_BASE_URL,AUTH_REALMandAUTH_CLIENT_IDare also accepted.💡 Behind Docker/proxy you can separate the public URL from the internal one by passing
internal_urltoAuthConfig(the browser uses the public one; the backend, the internal one).
Quickstart
FastAPI
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
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
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:
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:
introspectionfor maximum security;localwhen throughput matters.
Role-based protection
# 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
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)
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
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. |
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) |
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
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.
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:
- User Federation → LDAP maps the AD attribute to a Keycloak user attribute.
- Add a protocol mapper (User Attribute → token claim) on the client/scope.
- 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 |
@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.
⚠️ Lightweight access tokens (Keycloak 26): if you enable them on the client, the access token is trimmed and may not carry roles/attributes. Use
introspectionmode (default) orfetch_userinfoso 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:
def resolve_realm(request) -> str:
return request.headers.get("X-Tenant", "default-realm")
auth = AuthGuardian(tenant_resolver=resolve_realm)
Lifecycle (HTTP resources)
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.
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.
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
- Create an OIDC client in your realm.
- Enable Client authentication (confidential client).
- Copy the Client Secret →
KEYCLOAK_CLIENT_SECRET. - In Valid Redirect URIs, add your callback URL (e.g.
https://yourapp.com/oidc/callback). - Define the roles (realm or client) according to your authorization model.
Behind a reverse proxy, make sure to forward
HostandX-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
codeand prevent id_token replay. - Signed
state(HMAC-SHA256) in the OIDC flow to prevent CSRF. - Cookies
HttpOnly+SameSite=Lax(+SecurewithIS_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 with
everything an agent needs to integrate it well (API, patterns, common pitfalls).
- With
library-skills:uvx library-skillsdetects and installs it. - Manual: copy
skills/auth-guardian/into your project's.claude/skills/(or.agents/skills/).
Troubleshooting
Missing required configuration variables
A required variable is missing. Check the Configuration section and set KEYCLOAK_BASE_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_SECRET.
ModuleNotFoundError: No module named 'flask' / 'django'
You're using the adapter without installing the extra: pip install "auth-guardian[flask]" or "auth-guardian[django]".
Introspection returns 401 or 403
The client isn't confidential or the secret is wrong. Check KEYCLOAK_CLIENT_SECRET and that Client authentication is enabled in Keycloak.
Login fails at the callback (Redirect URI mismatch)
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.
Development and contributing
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file auth_guardian-0.1.40.tar.gz.
File metadata
- Download URL: auth_guardian-0.1.40.tar.gz
- Upload date:
- Size: 53.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8af85febab678df29e95d9d7ffde3d7bf094bb8788a99500c2b471db2d2777d9
|
|
| MD5 |
1fcb76a5908866e2a5c676b141555e0a
|
|
| BLAKE2b-256 |
925efe4793b029faec5fbf15bf58e26501bd66b8576db8b2905f77e641bd5e25
|
File details
Details for the file auth_guardian-0.1.40-py3-none-any.whl.
File metadata
- Download URL: auth_guardian-0.1.40-py3-none-any.whl
- Upload date:
- Size: 42.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e76e49a551ada9dc1d7ed31d3851c5637195664f9913943439dc63c516ac3a92
|
|
| MD5 |
b0bee2ecf24c3d7b857ee46dce9a6b93
|
|
| BLAKE2b-256 |
8771f65a80dbe967fd3e63a86e87de596e8c1df909b3d5f185020a3da9ac6e95
|