Metadata-Version: 2.4
Name: keycloak-guard
Version: 0.1.0
Summary: Keycloak access-token validation with a framework-agnostic Principal and adapters for Django REST Framework, FastAPI, and Flask.
Author: Dinakar JC
License-Expression: MIT
Project-URL: Homepage, https://github.com/Dinakar2329/keycloak-guard
Project-URL: Repository, https://github.com/Dinakar2329/keycloak-guard
Project-URL: Issues, https://github.com/Dinakar2329/keycloak-guard/issues
Project-URL: Changelog, https://github.com/Dinakar2329/keycloak-guard/blob/main/CHANGELOG.md
Keywords: keycloak,jwt,oidc,oauth2,authentication,authorization,django-rest-framework,fastapi,flask
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
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 :: Django
Classifier: Framework :: FastAPI
Classifier: Framework :: Flask
Classifier: Topic :: Internet :: WWW/HTTP :: Session
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyjwt[crypto]>=2.9
Provides-Extra: drf
Requires-Dist: djangorestframework>=3.14; extra == "drf"
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110; extra == "fastapi"
Provides-Extra: flask
Requires-Dist: flask>=3.0; extra == "flask"
Provides-Extra: all
Requires-Dist: djangorestframework>=3.14; extra == "all"
Requires-Dist: fastapi>=0.110; extra == "all"
Requires-Dist: flask>=3.0; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: cryptography>=42.0; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Dynamic: license-file

# keycloak-guard

[![CI](https://github.com/Dinakar2329/keycloak-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/Dinakar2329/keycloak-guard/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/keycloak-guard)](https://pypi.org/project/keycloak-guard/)
[![Python](https://img.shields.io/pypi/pyversions/keycloak-guard)](https://pypi.org/project/keycloak-guard/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

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

```bash
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

```python
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

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

```python
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

```python
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

```python
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):

```json
{ "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

```bash
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](LICENSE)
