Metadata-Version: 2.4
Name: django-amzn-oidc-auth
Version: 1.0.0rc2
Summary: Django middleware and auth backend for OIDC authentication via AWS ALB
Keywords: alb,authentication,aws,django,oidc
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.12
Requires-Dist: cryptography>=43.0
Requires-Dist: django>=5.2
Requires-Dist: pyjwt>=2.9
Requires-Dist: requests>=2.32
Provides-Extra: dev
Requires-Dist: pytest-django>=4.9; extra == 'dev'
Requires-Dist: pytest>=8.3; extra == 'dev'
Requires-Dist: responses>=0.25; extra == 'dev'
Description-Content-Type: text/markdown

# django-amzn-oidc-auth

Django middleware and authentication backend for apps deployed behind an AWS Application Load Balancer (ALB) with OIDC authentication enabled.

## How it works

When an ALB is configured with OIDC, it handles the full OAuth2 flow and injects a signed JWT into every upstream request via the `x-amzn-oidc-data` header. This package:

1. Validates the JWT signature using the ALB's region-specific public key (fetched from AWS and cached)
2. Maps the decoded claims to a Django `User` (creating one on first login if configured)
3. Establishes a normal Django session — groups, permissions, `@login_required`, and `request.user` all work as usual

## Installation

```bash
pip install django-amzn-oidc-auth
```
or
```bash
uv add django-amzn-oidc-auth
```

## Setup

Add to `INSTALLED_APPS`, `MIDDLEWARE`, and `AUTHENTICATION_BACKENDS`:

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

MIDDLEWARE = [
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django_amzn_oidc_auth.middleware.AmznOidcMiddleware",
    ...
]

AUTHENTICATION_BACKENDS = [
    "django_amzn_oidc_auth.backends.AmznOidcAuthBackend",
    "django.contrib.auth.backends.ModelBackend",
]
```

## Settings

| Setting | Default | Description |
|---|---|---|
| `AWS_REGION` | required | Region used to fetch ALB public keys |
| `AMZN_OIDC_BYPASS_VALIDATION` | `False` | Skip JWT signature check — dev only |
| `AMZN_OIDC_AUTO_CREATE_USERS` | `True` | Create Django users on first login |
| `AMZN_OIDC_EXEMPT_PATHS` | `[]` | Paths that skip OIDC auth (e.g. health checks) |
| `AMZN_OIDC_USERNAME_CLAIM` | `sub` | OIDC claim to use as the Django username. Defaults to `sub`. Set this if your IdP uses a different stable identifier (e.g. `"preferred_username"`). Changing this on an existing deployment will break logins for users whose accounts were created under the old claim value. |
| `AMZN_OIDC_FIRST_NAME_CLAIM` | `None` | OIDC claim to use as `first_name`. When unset, falls back to `nickname` → `given_name` → first word of `name`. When set, only that claim is used — no fallback. |
| `AMZN_OIDC_LAST_NAME_CLAIM` | `None` | OIDC claim to use as `last_name`. When unset, falls back to `family_name` → second word of `name`. When set, only that claim is used — no fallback. |
| `AMZN_OIDC_AUDIENCE` | `None` | Expected value of the JWT `aud` claim. When set, tokens whose `aud` does not match are rejected — recommended when multiple applications share the same ALB to prevent cross-application token replay. When unset, audience validation is skipped. |

## Usage in views

Once the middleware is active, every authenticated request has a populated `request.user` (a standard Django `User` instance) and `request.oidc_claims` (the raw decoded JWT payload).

### Function-based views

```python
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse

@login_required
def profile(request):
    return JsonResponse({
        "username": request.user.username,
        "email": request.user.email,
    })

def debug_claims(request):
    # Raw OIDC payload — useful during development
    return JsonResponse(request.oidc_claims)
```

### Class-based views

```python
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views import View
from django.http import JsonResponse

class ProfileView(LoginRequiredMixin, View):
    def get(self, request):
        return JsonResponse({"username": request.user.username})
```

`LoginRequiredMixin` and `@login_required` both work because the middleware establishes a normal Django session — the auth decorators don't know or care that authentication came from an ALB header.

### Checking permissions and groups

Standard Django permission checks work unchanged:

```python
@login_required
def admin_only(request):
    if not request.user.has_perm("myapp.change_widget"):
        return HttpResponseForbidden()
    ...
```

### Health check / unauthenticated paths

Add paths that should bypass OIDC to `AMZN_OIDC_EXEMPT_PATHS`. The middleware passes these through without checking the `x-amzn-oidc-data` header, so load-balancer health checks and similar endpoints keep working even before a session exists:

```python
AMZN_OIDC_EXEMPT_PATHS = ["/healthcheck/", "/readyz/"]
```

### Accessing raw OIDC claims

`request.oidc_claims` contains the full decoded JWT payload from the ALB. This is useful when your OIDC provider includes custom claims (e.g. roles, tenant ID) beyond what Django's `User` model stores.

#### Authorising based on a custom claim

```python
from django.http import HttpResponseForbidden

def admin_dashboard(request):
    roles = request.oidc_claims.get("custom:roles", [])
    if "admin" not in roles:
        return HttpResponseForbidden()
    ...
```

#### Multi-tenant routing

```python
def my_view(request):
    tenant = request.oidc_claims.get("custom:tenant_id")
    queryset = Widget.objects.filter(tenant=tenant)
    ...
```

#### Using a claim as the Django username

If your IdP uses `preferred_username` (or another claim) as the stable account identifier instead of `sub`, configure it via `AMZN_OIDC_USERNAME_CLAIM`:

```python
# settings.py
AMZN_OIDC_USERNAME_CLAIM = "preferred_username"
```

> **Note:** Only set this on a fresh deployment, or when you are prepared to migrate existing `User` rows. Changing the claim on an existing deployment means Django will look up users by the new claim value and won't find accounts that were created under the old one.

#### Enriching the user model from claims

If you need to store extra claim data on first login (e.g. a department or employee ID), subclass the backend:

```python
from django_amzn_oidc_auth.backends import AmznOidcAuthBackend

class MyBackend(AmznOidcAuthBackend):
    def _sync_user_fields(self, user, claims):
        super()._sync_user_fields(user, claims)
        # profile is a OneToOneField added by your app
        user.profile.department = claims.get("custom:department", "")
        user.profile.save()
```

Register `MyBackend` in place of (or in addition to) the default in `AUTHENTICATION_BACKENDS`.

## Local development

In production the ALB injects the `x-amzn-oidc-data` header automatically. Locally there is no ALB, so set `AMZN_OIDC_BYPASS_VALIDATION = True` to accept unsigned tokens and inject the header yourself.

```python
# settings.py (local only)
AMZN_OIDC_BYPASS_VALIDATION = True
```

#### Generate a token

```bash
python -c "
import jwt, time
print(jwt.encode({'sub': 'dev-user', 'email': 'dev@example.com',
    'iss': 'https://example.com', 'exp': int(time.time()) + 3600},
    'not-a-real-secret-dev-only-ignored', algorithm='HS256'))
"
```

#### curl

Pass the token directly as a request header:

```bash
TOKEN=$(python -c "
import jwt, time
print(jwt.encode({'sub': 'dev-user', 'email': 'dev@example.com',
    'iss': 'https://example.com', 'exp': int(time.time()) + 3600},
    'not-a-real-secret-dev-only-ignored', algorithm='HS256'))
")
curl -H "x-amzn-oidc-data: $TOKEN" http://localhost:8000/
```

#### Browser

Browsers don't let pages set arbitrary request headers directly. The easiest workaround is a browser extension that injects custom headers, such as [ModHeader](https://modheader.com/) (Chrome/Firefox). Add a request header named `x-amzn-oidc-data` with the token as the value.

Alternatively, use a reverse proxy that injects the header for you. Both options below add the header to every request automatically, so you can browse normally without touching the extension on each page.

**ngrok** (if you already have it installed):

```bash
TOKEN=$(python -c "
import jwt, time
print(jwt.encode({'sub': 'dev-user', 'email': 'dev@example.com',
    'iss': 'https://example.com', 'exp': int(time.time()) + 3600},
    'not-a-real-secret-dev-only-ignored', algorithm='HS256'))
")
ngrok http 8000 --request-header-add "x-amzn-oidc-data:$TOKEN"
```

ngrok prints a public URL (e.g. `https://abc123.ngrok.io`) — open that in your browser.

**mitmproxy** (local only, no public URL):

```bash
TOKEN=$(python -c "
import jwt, time
print(jwt.encode({'sub': 'dev-user', 'email': 'dev@example.com',
    'iss': 'https://example.com', 'exp': int(time.time()) + 3600},
    'not-a-real-secret-dev-only-ignored', algorithm='HS256'))
")
mitmdump --mode reverse:http://localhost:8000 --listen-port 8080 \
  --modify-headers "/~q/x-amzn-oidc-data/$TOKEN"
```

Then browse to `http://localhost:8080`.
