Metadata-Version: 2.4
Name: django-amzn-oidc-auth
Version: 1.0.0
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: Framework :: Django :: 6.0
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. Confirms the token was signed by *your* load balancer, by checking the header's `signer` field against `AMZN_OIDC_ALB_ARN`
3. Maps the decoded claims to a Django `User` (creating one on first login if configured)
4. 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

```python
# settings.py
AWS_REGION = "us-east-1"
AMZN_OIDC_ALB_ARN = (
    "arn:aws:elasticloadbalancing:us-east-1:123456789012:"
    "loadbalancer/app/my-alb/0123456789abcdef"
)
```

Find the ARN with `aws elbv2 describe-load-balancers --query 'LoadBalancers[].LoadBalancerArn'`. It is not a secret.

| Setting | Default | Description |
|---|---|---|
| `AWS_REGION` | required | Region used to fetch ALB public keys |
| `AMZN_OIDC_ALB_ARN` | **required** | ARN of the load balancer allowed to sign tokens, matched against the JWT header's `signer` field. Accepts a list of ARNs for blue/green or multi-region deployments. Startup fails if unset. |
| `AMZN_OIDC_ISSUER` | `None` | Expected value of the `iss` claim. Recommended: pins tokens to your identity provider as well as your load balancer. |
| `AMZN_OIDC_BYPASS_VALIDATION` | `False` | Accept unsigned tokens — dev only. Must be an actual `bool`, and is refused unless `DEBUG` is `True`. |
| `AMZN_OIDC_REVALIDATE_SECONDS` | `300` | How long an established session stands in for a fresh authentication before the backend is consulted again. Lower values propagate deactivation and authorization changes faster at the cost of a periodic DB write; `0` revalidates every request. |
| `AMZN_OIDC_LINK_EXISTING_USERS` | `False` | Allow OIDC login to adopt a pre-existing account that has a usable password. Off by default so an IdP-supplied claim cannot name a local account (e.g. a `createsuperuser` admin) and log in as it. |
| `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_REQUIRE_VERIFIED_EMAIL` | `True` | Only store `user.email` when the token says the address is verified (`email_verified` true, as a bool or the string `"true"`). An unverified address is left unstored and any address already on the account is kept. Set to `False` if your identity provider does not send `email_verified`. |
| `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)
DEBUG = True
AMZN_OIDC_BYPASS_VALIDATION = True
```

Two guardrails apply, because this flag disables authentication entirely:

- It must be a real `bool`. A string is rejected with `ImproperlyConfigured`, because settings read from the environment arrive as strings and *every* non-empty string is truthy in Python — `AMZN_OIDC_BYPASS_VALIDATION = "false"` would otherwise turn off signature verification. If you wire it to an env var, convert it explicitly: `os.environ.get("AMZN_OIDC_BYPASS_VALIDATION") == "true"`.
- It is refused unless `DEBUG` is `True`, so it cannot be left on in a production deployment.

Expiry is still enforced in bypass mode: an expired dev token reads as expired, and a token with no `exp` is rejected.

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