Metadata-Version: 2.4
Name: jwtguard
Version: 0.1.1
Summary: Simple, modern JWT authentication for Django REST Framework with roles support
Project-URL: Homepage, https://github.com/fabianfalon/jwtguard
Project-URL: Repository, https://github.com/fabianfalon/jwtguard
Project-URL: Documentation, https://github.com/fabianfalon/jwtguard#readme
Project-URL: Changelog, https://github.com/fabianfalon/jwtguard/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/fabianfalon/jwtguard/issues
License: MIT
License-File: LICENSE
Keywords: authentication,django,drf,jwt,rest-framework
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Requires-Dist: django>=4.2
Requires-Dist: djangorestframework>=3.14
Requires-Dist: pyjwt>=2.8
Provides-Extra: crypto
Requires-Dist: cryptography>=41.0; extra == 'crypto'
Provides-Extra: dev
Requires-Dist: factory-boy>=3.3; extra == 'dev'
Requires-Dist: model-bakery>=1.18; extra == 'dev'
Requires-Dist: pip-audit>=2.7; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest-django>=4.8; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# jwtguard

[![PyPI version](https://img.shields.io/pypi/v/jwtguard.svg)](https://pypi.org/project/jwtguard/)
[![Python versions](https://img.shields.io/pypi/pyversions/jwtguard.svg)](https://pypi.org/project/jwtguard/)
[![Tests](https://github.com/fabianfalon/jwtguard/actions/workflows/ci.yml/badge.svg)](https://github.com/fabianfalon/jwtguard/actions)
[![Coverage](https://img.shields.io/codecov/c/github/fabianfalon/jwtguard)](https://codecov.io/gh/fabianfalon/jwtguard)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Simple, modern JWT authentication for Django REST Framework — with roles, refresh token rotation, and zero magic.

Built on top of [PyJWT](https://pyjwt.readthedocs.io/), fully compliant with [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519).

---

## Why jwtguard?

Most JWT libraries for Django are either abandoned, over-engineered, or hide too much behind configuration. This library gives you a working JWT auth workflow in minutes, with code you can actually read and understand.

- **Explicit over magic** — no hidden middleware or monkey-patching
- **Role-based permissions** baked in, sourced from Django Groups
- **Refresh token rotation** with database-backed revocation
- **RFC 7519 compliant** — `sub`, `jti`, `iat`, `exp` claims out of the box
- **RS256 and HS256** support
- **Django 4.2+ and 5.x** compatible

---

## Installation

```bash
pip install jwtguard
```

For RS256 support (asymmetric keys):

```bash
pip install "jwtguard[crypto]"
```

---

## Quick start

### 1. Add to `INSTALLED_APPS`

```python
INSTALLED_APPS = [
    ...
    "jwtguard",
]
```

### 2. Run migrations

```bash
python manage.py migrate
```

### 3. Configure DRF

```python
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "jwtguard.authentication.JWTAuthentication",
    ],
}
```

### 4. Add URLs

```python
from django.urls import include, path

urlpatterns = [
    path("api/auth/", include("jwtguard.urls")),
]
```

That's it. You now have a working JWT auth workflow.

---

## Usage

### Authenticate

```bash
curl -X POST http://localhost:8000/api/auth/token/ \
  -H "Content-Type: application/json" \
  -d '{"username": "john", "password": "secret"}'
```

```json
{
  "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```

### Call a protected endpoint

```bash
curl http://localhost:8000/api/me/ \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```

### Refresh the access token

```bash
curl -X POST http://localhost:8000/api/auth/token/refresh/ \
  -H "Content-Type: application/json" \
  -d '{"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}'
```

```json
{
  "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```

### Logout (revoke refresh token)

```bash
curl -X POST http://localhost:8000/api/auth/token/logout/ \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"refresh": "<refresh_token>"}'
```

### Logout from all devices

```bash
curl -X POST http://localhost:8000/api/auth/token/logout/all/ \
  -H "Authorization: Bearer <access_token>"
```

---

## Endpoints

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/auth/token/` | Obtain access + refresh tokens |
| `POST` | `/auth/token/refresh/` | Rotate refresh token, get new access token |
| `POST` | `/auth/token/logout/` | Revoke a specific refresh token |
| `POST` | `/auth/token/logout/all/` | Revoke all sessions for the current user |

---

## Role-based permissions

Roles are sourced from Django's built-in `Group` model and embedded in the access token at login time.

```python
from jwtguard.permissions import HasRole, HasAllRoles

class ReportView(APIView):
    permission_classes = [HasRole.require("analyst", "admin")]

    def get(self, request):
        ...

class BillingView(APIView):
    # user must have BOTH roles
    permission_classes = [HasAllRoles.require("finance", "admin")]

    def post(self, request):
        ...
```

You can also read roles directly from the token payload in your view:

```python
def get(self, request):
    payload = request.auth  # dict with all JWT claims
    roles = payload.get("roles", [])
```

---

## Configuration

All settings go under `DRF_JWT` in your `settings.py`. Every key is optional — the defaults are production-ready.

```python
from datetime import timedelta

DRF_JWT = {
    # Token lifetimes
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),

    # Signing
    "ALGORITHM": "HS256",           # or "RS256"
    "SIGNING_KEY": SECRET_KEY,      # for RS256: your private key
    "VERIFYING_KEY": None,          # for RS256: your public key

    # Refresh token behavior
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,

    # Roles
    "ROLES_CLAIM": "roles",         # claim name in the token
    "ROLES_FROM": "groups",         # attribute on the User model

    # Header
    "AUTH_HEADER_PREFIX": "Bearer",

    # Multi-service / microservices (optional)
    "AUDIENCE": None,               # e.g. "my-api"
    "ISSUER": None,                 # e.g. "https://auth.example.com"

    # Custom claims (optional)
    "EXTRA_CLAIMS_CALLABLE": None,  # dotted path to a callable(user) -> dict
}
```

### RS256 example

```python
from pathlib import Path

DRF_JWT = {
    "ALGORITHM": "RS256",
    "SIGNING_KEY": Path("private.pem").read_text(),
    "VERIFYING_KEY": Path("public.pem").read_text(),
}
```

### Custom claims

Add any extra claims to the access token without touching core claims (`sub`, `jti`, `type`, `exp`):

```python
# myapp/tokens.py
def add_claims(user):
    return {"email": user.email, "tenant": user.org.slug}

# settings.py
DRF_JWT = {
    "EXTRA_CLAIMS_CALLABLE": "myapp.tokens.add_claims",
}
```

### Audience and issuer (microservices)

When multiple services share a signing key, configure `AUDIENCE` and `ISSUER` to prevent a token issued for one service from being accepted by another:

```python
DRF_JWT = {
    "AUDIENCE": "payments-service",
    "ISSUER": "https://auth.example.com",
}
```

### Custom roles source

If your roles live somewhere other than Django Groups, point `ROLES_FROM` at any attribute or property on your User model:

```python
# models.py
class User(AbstractUser):
    @property
    def role_names(self):
        return [self.role]  # your custom logic

# settings.py
DRF_JWT = {
    "ROLES_FROM": "role_names",
}
```

---

## Token payload structure

```json
{
  "sub": "42",
  "jti": "a3f8c2...",
  "roles": ["admin", "editor"],
  "type": "access",
  "iat": 1714300800,
  "exp": 1714301700
}
```

No PII is included by default. Use `EXTRA_CLAIMS_CALLABLE` to embed custom data.

---

## Requirements

- Python 3.10+
- Django 4.2+
- djangorestframework 3.14+
- PyJWT 2.8+

---

## Contributing

Contributions are welcome. Please open an issue before submitting a pull request for significant changes.

```bash
git clone https://github.com/fabianfalon/jwtguard.git
cd jwtguard
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
```

---

## License

MIT — see [LICENSE](LICENSE) for details.
