Skip to main content

Keycloak access-token validation with a framework-agnostic Principal and adapters for Django REST Framework, FastAPI, and Flask.

Project description

keycloak-guard

CI PyPI Python License: MIT

Validate Keycloak access tokens once, the same way, in every Python service.

One dependency-light core does the "validate-then-classify" work; thin adapters wrap it for Django REST Framework, FastAPI, and Flask, so every service in your fleet authenticates identically and returns the same machine-readable error body.

What it does

  1. Validate — resolves the signing key by kid from the realm JWKS, verifies the signature with a pinned algorithm (RS256 by default — never derived from the token, the defense against algorithm-confusion attacks, RFC 8725), and checks iss, aud, exp, and required claims.
  2. Classify — decides whether the caller is a human USER (interactive authorization-code/PKCE login) or a SERVICE (client-credentials grant), preferring an explicit role you assign and falling back to Keycloak's service-account-<clientId> username convention.
  3. Authorize — exposes realm + client roles and scopes on a framework-agnostic Principal.

The core depends only on pyjwt[crypto]. Framework adapters import their framework lazily, so installing the core never drags in Django, FastAPI, or Flask.

Install

pip install keycloak-guard             # core only
pip install "keycloak-guard[drf]"      # + Django REST Framework adapter
pip install "keycloak-guard[fastapi]"  # + FastAPI adapter
pip install "keycloak-guard[flask]"    # + Flask adapter
pip install "keycloak-guard[all]"      # everything

Requires Python 3.10+.

Quick start

from keycloak_guard import KeycloakAuthConfig, TokenValidator

config = KeycloakAuthConfig(
    issuer="https://kc.example.com/realms/myrealm",  # must equal the token's iss
    audience="orders-api",                           # this service
    # jwks_url defaults to {issuer}/protocol/openid-connect/certs
    user_role="end-user",        # optional, strengthens classification
    service_role="api-client",   # optional
)
validator = TokenValidator(config)
principal = validator.validate(bearer_token)  # -> Principal, or raises AuthError

principal.subject        # "sub" claim
principal.is_service     # client-credentials caller?
principal.has_role("admin")
principal.has_scope("orders.read")

Audience gotcha: Keycloak's default access-token aud is often just account, not your API. Add an audience mapper / client scope in Keycloak so your service appears in aud, or validation will (correctly) reject the token. For a transitional period you can set verify_aud=False — this also stops requiring the aud claim — but treat that as temporary.

Use per framework

Django REST Framework

# myapp/auth.py
from keycloak_guard import KeycloakAuthConfig, TokenValidator
from keycloak_guard.contrib.drf import KeycloakAuthentication

class MyKeycloakAuthentication(KeycloakAuthentication):
    validator = TokenValidator(KeycloakAuthConfig(issuer=..., audience="orders-api"))

    def get_user(self, principal):
        # optional: JIT-provision a Django user keyed on principal.subject
        return principal
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "myapp.auth.MyKeycloakAuthentication",
        "rest_framework.authentication.TokenAuthentication",  # legacy, dual-auth
    ]
}

Non-Bearer requests return None, so DRF falls through to the next authenticator — that's the dual-auth mechanism for gradual migrations.

FastAPI

from fastapi import Depends, FastAPI
from keycloak_guard import KeycloakAuthConfig, TokenValidator
from keycloak_guard.contrib.fastapi import KeycloakAuth, require_roles

app = FastAPI()
auth = KeycloakAuth(TokenValidator(KeycloakAuthConfig(issuer=..., audience="orders-api")))

@app.get("/me")
async def me(principal=Depends(auth)):
    return {"sub": principal.subject, "type": principal.type}

@app.get("/admin", dependencies=[Depends(require_roles(auth, "admin"))])
async def admin(): ...

Flask

from flask import Flask, g
from keycloak_guard import KeycloakAuthConfig, TokenValidator
from keycloak_guard.contrib.flask import keycloak_protected

app = Flask(__name__)
validator = TokenValidator(KeycloakAuthConfig(issuer=..., audience="billing-api"))

@app.get("/me")
@keycloak_protected(validator)
def me():
    return {"sub": g.principal.subject}

@app.get("/admin")
@keycloak_protected(validator, roles=["admin"])
def admin(): ...

Error shape (consistent across frameworks)

On a token rejection every adapter returns the same canonical body, so any client can react uniformly — key on error to tell "the backend refused this token" apart from an unrelated 401 (a proxy, a sub-resource, a view's own error):

{ "error": "keycloak_auth_failed", "code": "invalid_audience", "detail": "Invalid audience" }
  • error — constant marker; the same for every auth failure.
  • code — the specific reason: invalid_audience, token_expired, token_not_active, invalid_issuer, invalid_signature, missing_claim, invalid_algorithm, missing_token, invalid_header, key_error, invalid_token.
  • detail — human-readable message.

Role failures return 403 with error: "keycloak_access_denied" and code: "missing_role" — distinct from a rejected token, same shape.

All 401 responses also carry WWW-Authenticate: Bearer. DRF and Flask return the dict at the top level; FastAPI nests it under its usual detail key (body["detail"]["error"] == "keycloak_auth_failed"). The source of truth is AuthError.to_dict(), so custom adapters stay consistent for free.

Configuration reference

Field Default Purpose
issuer — (required) Realm issuer URL; must equal the token's iss. Trailing slash stripped.
audience — (required) Accepted audience(s); token passes if its aud contains any one.
jwks_url {issuer}/protocol/openid-connect/certs Override the JWKS endpoint.
algorithms ("RS256",) Pinned signature algorithms. Never widened from the token.
leeway_seconds 30 Clock-skew tolerance for exp/iat.
required_claims exp, iat, iss, aud, sub Claims that must be present.
verify_aud True Disable to skip audience verification (and the aud requirement). Temporary use only.
user_role / service_role None Explicit roles for USER/SERVICE classification (strongest signal).
service_account_username_prefix "service-account-" Fallback classification for client-credentials callers.
bare_client_roles True Also expose client roles by bare name. Bare names collide across clients — set False to require client:role.
jwks_cache_lifespan_seconds 300 PyJWT JWKS cache lifetime.
jwks_timeout_seconds 30 JWKS fetch timeout.
verify_ssl True TLS verification for the JWKS fetch. Never False in production.

Custom key resolution

TokenValidator(config, key_resolver=...) accepts any KeyResolver. StaticKeyResolver(public_pem) pins a single key (great for tests); JWKSKeyResolver is the production default.

Security notes

  • Algorithms are pinned in config and never read from the token header — alg=none and HS256/RS256 confusion attacks are rejected at decode.
  • The aud check is on by default; a token minted for another service is rejected even if the signature is valid.
  • With bare_client_roles=True (the default), has_role("admin") matches an admin role from any client in resource_access. If different clients in your realm define same-named roles with different meanings, set it to False and check namespaced roles ("orders-api:admin").

Tests

pip install -e ".[dev]"
pytest

The suite mints tokens with a local RSA key and asserts the validator accepts a good token and rejects expired, wrong-audience, wrong-issuer, tampered-signature, alg=none, HS256/algorithm-confusion, and missing-claim tokens, plus both classification paths — no network or live Keycloak needed.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

keycloak_guard-0.1.0.tar.gz (19.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

keycloak_guard-0.1.0-py3-none-any.whl (17.3 kB view details)

Uploaded Python 3

File details

Details for the file keycloak_guard-0.1.0.tar.gz.

File metadata

  • Download URL: keycloak_guard-0.1.0.tar.gz
  • Upload date:
  • Size: 19.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for keycloak_guard-0.1.0.tar.gz
Algorithm Hash digest
SHA256 b9f37a7056d4a0a8fe1716687ae242ffae8cd44c3db9c6f36acea1fa56ee6e94
MD5 2c948eda15dbefeecac3011bb06ab92d
BLAKE2b-256 51124bcff872875cfd6912708f3ffb90d312df8ac04a8b46f94aa37bd5c725e8

See more details on using hashes here.

Provenance

The following attestation bundles were made for keycloak_guard-0.1.0.tar.gz:

Publisher: publish.yml on Dinakar2329/keycloak-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file keycloak_guard-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: keycloak_guard-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 17.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for keycloak_guard-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 07142f5479bd94584b924e3380cf635da34e6f3c4a0e64f0a88f8063437923db
MD5 fd03f54a8e476fab22ea9451b91e9c17
BLAKE2b-256 a185d29202d1b93c8d06ad5fe3aa4f35f93ecb9b201c2e709086d12fc67314a8

See more details on using hashes here.

Provenance

The following attestation bundles were made for keycloak_guard-0.1.0-py3-none-any.whl:

Publisher: publish.yml on Dinakar2329/keycloak-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page