Metadata-Version: 2.5
Name: django-universal-auth
Version: 0.2.0
Summary: Generic, reusable authentication (JWT + server-side sessions) for Django and Django REST Framework.
Author: OpsTree
License-Expression: MIT
License-File: LICENSE
Keywords: authentication,django,djangorestframework,jwt,sessions
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
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: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Requires-Python: >=3.10
Requires-Dist: django<7,>=5.2
Requires-Dist: djangorestframework<4,>=3.16
Requires-Dist: pyjwt<3,>=2.10
Provides-Extra: crypto
Requires-Dist: pyjwt[crypto]<3,>=2.10; extra == 'crypto'
Description-Content-Type: text/markdown

# django-universal-auth

Generic, reusable authentication for Django and Django REST Framework: short-lived JWT access
tokens, rotating refresh tokens bound to server-side sessions, device/session management,
password change and reset, brute-force protection and security events.

It works with **your** user model (`AUTH_USER_MODEL`) and contains no business logic.

> **Status: 0.1.0 (alpha).** Authentication is implemented. Authorization (roles and permissions)
> lives in the separate [django-universal-rbac](../django-universal-rbac) package.

- [Compatibility](#compatibility)
- [Installation](#installation)
- [How it works](#how-it-works)
- [API endpoints](#api-endpoints)
- [Using it from your own code](#using-it-from-your-own-code)
- [Configuration reference](#configuration-reference)
- [Security notes](#security-notes)
- [Database and migrations](#database-and-migrations)
- [Project layout](#project-layout)
- [Development and testing](#development-and-testing)
- [Versioning](#versioning)

## Compatibility

| | Supported | Tested |
|---|---|---|
| Python | 3.10 – 3.14 (Django 6.x itself needs ≥ 3.12) | 3.10, 3.12, 3.14 |
| Django | 5.2 LTS, 6.0, 6.1 | 5.2.17, 6.0.8, 6.1.1 |
| Django REST Framework | ≥ 3.16, < 4 | 3.18.1 |
| MySQL | 8.0.11+ with Django 5.2/6.0; **8.4+ with Django 6.1** | 8.0.34, 8.4 |

MySQL 8.0 reached end of life in April 2026. Prefer MySQL 8.4 LTS. The package contains no
MySQL-specific SQL, but MySQL is the database it is tested on.

Runtime dependencies: `Django`, `djangorestframework`, `PyJWT`. Install the `crypto` extra
(`django-universal-auth[crypto]`) for RS256/ES256 signing.

Dependencies are declared as version ranges in `pyproject.toml`, and pip installs them
automatically. There is no `requirements.txt`, because a reusable library should not pin exact
versions. Pin the package itself to a release tag (for example `auth-v0.1.0`) in your project's
`requirements.txt` or lock file.

## Installation

The package is not on PyPI. Install it from the repository, pinned to a release tag (see the
[repository README](../README.md#using-the-packages-in-a-project) for uv and editable installs):

```bash
pip install mysqlclient "django-universal-auth @ git+ssh://git@github.com/OT-RAPPLE/GoldenStackBackend.git@auth-v0.1.0#subdirectory=django-universal-auth"
```

For RS256/ES256 or single sign-on, use `django-universal-auth[crypto] @ ...` instead.

```python
# settings.py
import os

INSTALLED_APPS = [
    # ... django.contrib apps ...
    "rest_framework",
    "universal_auth",
    "accounts",  # your app that defines the user model
]

AUTH_USER_MODEL = "accounts.User"  # set this before the first migrate (a Django rule)

CACHES = {  # lockout counters and throttles need a cache shared by all workers
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/1",
    }
}

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": ["universal_auth.authentication.JWTAuthentication"],
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    # "NUM_PROXIES": 1,  # set when behind a reverse proxy, so the real client IP is used
}
# With drf-spectacular installed, the schema describes JWTAuthentication as a bearer JWT
# automatically.

UNIVERSAL_AUTH = {
    "SIGNING_KEY": os.environ["UNIVERSAL_AUTH_SIGNING_KEY"],  # ≥ 32 random bytes for HS256
    "ISSUER": "https://api.example.com",
    "PASSWORD_RESET_URL": "https://app.example.com/reset-password?uid={uid}&token={token}",
}
```

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

urlpatterns = [
    path("auth/", include("universal_auth.urls")),
    # your API ...
]
```

```bash
python manage.py migrate
python manage.py check --deploy     # includes universal_auth's configuration checks
```

Generate a signing key with `python -c "import secrets; print(secrets.token_urlsafe(48))"`.

Protect your own views as usual. `request.user` is your user model instance and
`request.auth` holds the verified JWT claims:

```python
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet


class ArticleViewSet(ModelViewSet):  # any view in your project
    permission_classes = [IsAuthenticated]
```

## How it works

```text
POST /auth/login ──► Django authenticate() ──► AuthSession + RefreshToken(hash) ──► access + refresh
                                                                    │
GET /api/... with "Authorization: Bearer <access>" ──► verify JWT ──► load session+user (1 query)
                                                                    │
POST /auth/token/refresh ──► lock token + session rows ──► mark used, issue new pair (rotation)
                           └─ token already used? ──► revoke the whole session (theft detected)
```

* **Access token:** a JWT signed with PyJWT (HS256 by default), valid for 5 minutes and never
  longer than its session. It carries `sub` (user pk), `sid` (session id), `jti`, `iat`, `exp`,
  `typ` and optionally `iss` and `aud`. It contains no permissions or personal data.
* **Refresh token:** 256 random bits, not a JWT. Only its SHA-256 hash is stored. Every refresh
  replaces it. Presenting an already-used token revokes the session and records
  `AUTH_REFRESH_REUSE_DETECTED`.
* **Session:** one row per login and device (IP, user agent, optional `device_label`). It expires
  after `SESSION_MAX_LIFETIME` regardless of activity, and can be revoked at any time.
* **Revocation is immediate.** Every request checks the session and `user.is_active` with one
  indexed query. Set `ACCESS_TOKEN_CHECK_SESSION = False` for fully stateless verification;
  revocation then takes up to `ACCESS_TOKEN_LIFETIME` to take effect.
* **Login uses `django.contrib.auth.authenticate()`**, so your `AUTHENTICATION_BACKENDS`, custom
  user models (email login, UUID keys), password hashers and `user_logged_in`/`last_login`
  behave exactly as in Django.

## API endpoints

All paths are relative to where you include `universal_auth.urls`. Every endpoint is optional:
you can disable a group with settings, or skip the include and route the views
(`universal_auth.views.*`) yourself.

| Method & path | Auth | Body | Success |
|---|---|---|---|
| `POST login` | – | `{"<USERNAME_FIELD>": "...", "password": "...", "device_label"?: "..."}` | `200` tokens |
| `POST token/refresh` | – | `{"refresh": "..."}` (or the cookie in cookie mode) | `200` tokens |
| `POST logout` | Bearer | – | `204` |
| `GET me` | Bearer | – | `200 {"user": {...}}` |
| `POST password/change` | Bearer | `{"current_password", "new_password"}` | `204` |
| `POST password/reset` | – | `{"email"}` | `202`, always (no account enumeration) |
| `POST password/reset/confirm` | – | `{"uid", "token", "new_password"}` | `204` |
| `GET sessions` | Bearer | – | `200 [{id, created_at, last_activity_at, expires_at, ip_address, user_agent, device_label, current}]` |
| `DELETE sessions/<id>` | Bearer | – | `204` (`404` if not found **or not yours**) |
| `POST sessions/revoke-all` | Bearer | `{"include_current"?: false}` | `204` |
| `GET sso/<provider>/login` | – | query `code_challenge` | `302` to the provider (web apps) |
| `GET sso/<provider>/callback` | – | – (the provider calls it) | `302` to `SSO_FRONTEND_URL?code=...` or `?error=...` |
| `POST sso/exchange` | – | `{"code", "code_verifier", "device_label"?}` | `200` tokens |
| `POST sso/<provider>/token` | – | `{"id_token", "nonce"?, "device_label"?}` | `200` tokens (mobile apps, provider SDKs) |
| `POST invite` | Bearer + admin | `{"email", "first_name"?, "last_name"?}` (+ your `INVITE_SERIALIZER` fields) | `201 {"user": {...}}` |
| `POST invite/accept` | – | `{"uid", "token", "new_password", "device_label"?}` | `200` tokens (the user is logged in) |
| `POST users/<id>/resend-invite` | Bearer + admin | – | `202` (`409` if no invite is pending) |
| `POST users/<id>/unlock` | Bearer + admin | – | `204` |

The last four are off by default; see [Invites and admin unlock](#invites-and-admin-unlock).

Token response:

```json
{
  "token_type": "Bearer",
  "access": "eyJhbGciOi...",
  "expires_in": 300,
  "refresh": "q3v2...",
  "refresh_expires_in": 86400,
  "session": {"id": "7b1e6d2c-...", "expires_at": "2026-10-30T09:55:00Z"},
  "user": {"id": 42, "username": "alice", "email": "alice@example.com", "first_name": "", "last_name": ""},
  "mfa_required": false
}
```

`mfa_required` is always `false` in this release. It is reserved so that MFA can be added later
without breaking clients.

**Errors** use DRF's format. The package's views add a stable, machine-readable `code`:

| Status | `code` | When |
|---|---|---|
| 400 | – (field errors) | Validation. `{"new_password": ["This password is too common."]}`; an invalid reset link gives `{"non_field_errors": ["The reset link is invalid or has expired."]}` |
| 401 | `invalid_credentials` | Wrong password, unknown user or inactive user (one message for all three) |
| 401 | `not_authenticated` | No `Authorization` header on a protected endpoint |
| 401 | `token_invalid`, `token_expired` | Bad or expired access token; unknown or expired refresh token |
| 401 | `token_reused` | A rotated refresh token was replayed; the session is now revoked |
| 401 | `session_revoked` | Session revoked or expired, or user deactivated |
| 401 | `sso_failed`, `sso_account_conflict`, `sso_signup_disabled` | SSO login rejected (see [Single sign-on](#single-sign-on-openid-connect)) |
| 403 | `permission_denied` | Not allowed by `USER_ADMIN_PERMISSION_CLASSES`, or `INVITE_CREATE_USER` raised `PermissionDenied` |
| 404 | `not_found` | `DELETE sessions/<id>` for an unknown session or another user's session; unknown user id on the user admin endpoints |
| 409 | `invite_not_pending` | Resending an invite to a user who set a password, signed in with SSO or is inactive |
| 429 | `account_locked`, `throttled` | Lockout or rate limit; see the `Retry-After` header |

Your own views keep your project's exception handler. For authentication failures there, DRF's
`exc.get_codes()` returns the same codes.

### Refresh token in an HttpOnly cookie (browser SPAs)

```python
UNIVERSAL_AUTH = {..., "REFRESH_TOKEN_TRANSPORT": "cookie"}
```

`login` and `token/refresh` then set an `HttpOnly; Secure; SameSite=Strict` cookie on
`REFRESH_COOKIE_PATH` (default `/auth/token/`) and omit `refresh` from the JSON. `token/refresh`
reads only the cookie, and `logout` deletes it. Keep the access token in memory, not
`localStorage`. If you mount the URLs somewhere other than `auth/`, adjust
`REFRESH_COOKIE_PATH`; system check `universal_auth.W004` reports a path that doesn't cover
`token/refresh`.

`token/refresh` relies on `SameSite` for CSRF protection. Keep `Strict` (or `Lax`). With
`REFRESH_COOKIE_SAMESITE = "None"`, another site could trigger a refresh; it cannot read the
new tokens, but add your own CSRF check if you need that setting.

### Single sign-on (OpenID Connect)

Log in with any OpenID Connect provider: Google, Microsoft Entra ID, Okta, Keycloak, Auth0,
AWS Cognito, and so on. No extra libraries are needed beyond the `crypto` extra
(see [Installation](#installation)). SSO logins create normal sessions, so refresh,
logout, session management and security events work exactly as for password logins.

```python
UNIVERSAL_AUTH = {
    ...,
    "SSO_FRONTEND_URL": "https://app.example.com/sso/complete",  # needed for the redirect flow
    "SSO_PROVIDERS": {
        "google": {
            "ISSUER": "https://accounts.google.com",
            "CLIENT_ID": os.environ["GOOGLE_CLIENT_ID"],
            "CLIENT_SECRET": os.environ["GOOGLE_CLIENT_SECRET"],
            "AUDIENCES": [os.environ["GOOGLE_IOS_CLIENT_ID"]],  # extra client ids (mobile apps)
        },
        "okta": {
            "ISSUER": "https://example.okta.com",
            "CLIENT_ID": os.environ["OKTA_CLIENT_ID"],
            "CLIENT_SECRET": os.environ["OKTA_CLIENT_SECRET"],
            "ALLOW_SIGNUP": False,  # only existing users (matched by verified email)
        },
        "keycloak": {
            "ISSUER": "https://sso.example.com/realms/main",
            "CLIENT_ID": "my-app",
            "CLIENT_SECRET": os.environ["KEYCLOAK_CLIENT_SECRET"],
            "ALLOWED_EMAIL_DOMAINS": ["example.com"],
        },
    },
}
```

Register `https://<your-api>/auth/sso/<provider>/callback` as the redirect URI at the provider.

**Web apps (redirect flow):**

1. The frontend creates a random `code_verifier` (43–128 characters from `A-Z a-z 0-9 - . _ ~`),
   keeps it in `sessionStorage`, and sends the browser to
   `GET /auth/sso/google/login?code_challenge=<BASE64URL(SHA-256(code_verifier)), no padding>`.
2. The user signs in at Google. Google redirects back to `/auth/sso/google/callback`, and the
   backend checks the answer (state, PKCE, nonce, ID-token signature, issuer and audience).
3. The browser lands on `SSO_FRONTEND_URL?code=<one-time code>`, or `?error=<code>` on failure.
4. The frontend posts `{"code", "code_verifier"}` to `POST /auth/sso/exchange` and gets the normal
   token response. The code is valid for 60 seconds and works once.

The verifier ties the code to the browser that started the login: a code someone else slips into
a link (login CSRF) can't be exchanged.

```javascript
// Start
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
sessionStorage.setItem("sso_verifier", verifier);
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
location.href = `${API}/auth/sso/google/login?code_challenge=${base64url(new Uint8Array(digest))}`;

// On SSO_FRONTEND_URL
const code = new URLSearchParams(location.search).get("code");
const response = await fetch(`${API}/auth/sso/exchange`, {
  method: "POST",
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify({code, code_verifier: sessionStorage.getItem("sso_verifier")}),
});

function base64url(bytes) {
  return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
```

Behind a TLS-terminating proxy, set Django's `SECURE_PROXY_SSL_HEADER` (or the provider's
`REDIRECT_URI`) so the callback URL sent to the provider is `https://`.

**Mobile apps and provider SDKs (ID token flow):** sign in with the provider's SDK, then post its
ID token to `POST /auth/sso/<provider>/token`. Add the app's client ID to `AUDIENCES`. Send the
`nonce` the app passed to the SDK too; without it, a leaked ID token can be replayed until it
expires.

**Which local user is logged in:**

* A provider account seen before (same provider and `sub`) logs in its linked user.
* Otherwise, if exactly one user has the same email **and the provider marked it verified**, the
  account is linked (`LINK_BY_VERIFIED_EMAIL`, default on). If that account had a password, the
  password is cleared and its sessions are revoked: whoever set it never proved they own the
  email, so a pre-registered account can't be taken over. The account is SSO-only from then on.
  Password reset skips users without a usable password, as Django's does, so removing someone at
  the provider also ends their access. To give the account a password again, set one from your
  own admin tooling (`user.set_password()`).
* An existing user with that email but an unverified email gives `sso_account_conflict`; it is
  never linked, because anyone can type someone else's email at some providers.
* So does an existing user already linked to the same provider under another `sub`: the
  provider reassigned the email to someone else, who must not get the old owner's account.
* Inactive users are never linked or logged in.
* Otherwise a new user is created (`ALLOW_SIGNUP`, default on) with an unusable password, through
  `SSO_CREATE_USER`. The default sets the username to `<provider>-<sub>`, the email (only if
  verified) and the first/last name. Point `SSO_CREATE_USER` at your own function
  `(*, provider, claims, email)` if your user model needs more.

Provider keys: `ISSUER` and `CLIENT_ID` (required), `CLIENT_SECRET`, `AUDIENCES`, `SCOPES`
(default `["openid", "email", "profile"]`), `AUTH_PARAMS` (extra authorization parameters, such
as `{"prompt": "select_account"}`), `REDIRECT_URI` (override the callback URL, for example behind
a proxy), `ALLOW_SIGNUP`, `LINK_BY_VERIFIED_EMAIL`, `ALLOWED_EMAIL_DOMAINS` (only verified emails
in these domains may log in).

**Microsoft Entra ID:** use the tenant-specific issuer
(`https://login.microsoftonline.com/<tenant-id>/v2.0`), not `common`. Microsoft does not send
`email_verified`, so its logins never link to existing users by email: a matching email gives
`sso_account_conflict`, and new users are created without an email. To connect existing users,
create their `SSOIdentity` rows yourself (`provider="microsoft"`, `subject=<the token's sub>`).

### Invites and admin unlock

Admins create accounts; users choose their own password. Turn it on with:

```python
UNIVERSAL_AUTH = {
    "ENABLE_USER_ADMIN_API": True,
    "INVITE_URL": "https://app.example.com/accept-invite?uid={uid}&token={token}",
    "USER_ADMIN_PERMISSION_CLASSES": ["rest_framework.permissions.IsAdminUser"],  # default
}
```

1. `POST invite` creates the user with an unusable password, through `INVITE_CREATE_USER`, and
   calls `INVITE_SENDER` with a `uid` and `token`. Both run in one transaction: if either raises,
   no user is created.
2. The frontend page at `INVITE_URL` posts `{"uid", "token", "new_password"}` to `invite/accept`.
   The password is checked against `AUTH_PASSWORD_VALIDATORS`, then the user is logged in and gets
   tokens.
3. Invite links have their own salt and lifetime (`INVITE_TIMEOUT`, default 3 days, independent
   of `PASSWORD_RESET_TIMEOUT`). They stop working once the password is set, and
   `users/<id>/resend-invite` cancels earlier links. A password reset link is never accepted as an
   invite, and the reverse.

An invite is *pending* while the user is active, has no usable password and has no SSO identity.
`password/reset` ignores such users, so an admin resends the invite instead.

`users/<id>/unlock` lifts the login lockout for the user's username and email (both, when they
differ). IP locks stay, because they protect every account.

**Extending.** Add fields with `INVITE_SERIALIZER` (subclass `universal_auth.serializers.InviteSerializer`)
and store them with `INVITE_CREATE_USER`, a callable `(validated_data, invited_by, request)` that
returns the user. Raise `django.core.exceptions.PermissionDenied` inside it to refuse an invite
(`403`, nothing saved). For example, giving roles from a permission system:

```python
from django.core.exceptions import PermissionDenied
from universal_auth.services import create_invited_user


def create_user_with_roles(*, validated_data, invited_by, request):
    roles = validated_data.pop("roles", [])
    user = create_invited_user(
        validated_data=validated_data, invited_by=invited_by, request=request
    )
    for role in roles:
        if not can_assign(invited_by, role):
            raise PermissionDenied("You can't give this role.")
        assign(user, role, actor=invited_by)
    return user
```

`USER_ADMIN_PERMISSION_CLASSES` takes dotted paths to DRF permission classes, so another
permission system can guard these endpoints (`["myproject.permissions.CanManageUsers"]`).

The services work without DRF: `invite_user(validated_data, invited_by=..., request=...)`,
`resend_invite(user, ...)`, `accept_invite(user, password, ...)`, `unlock_user(user, ...)`,
`is_invite_pending(user)`.

## Using it from your own code

The services in `universal_auth.services` do not depend on DRF. You can call them
from plain Django views, Celery tasks or management commands.

```python
from universal_auth import services
from universal_auth.models import AuthSession, RevokeReason

tokens = services.complete_login(user, request)  # after another mechanism authenticated the user
tokens.access, tokens.refresh, tokens.session  # IssuedTokens (also *_expires_at, issued_at)
services.deactivate_user(user, actor=admin)  # is_active=False + revoke all sessions
services.activate_user(user, actor=admin)
services.revoke_sessions(AuthSession.objects.filter(user=user), reason=RevokeReason.ADMIN)
```

`complete_login()` is the integration point for other login mechanisms; the built-in SSO uses it
too. Once another library (for example for SAML or GitHub) or an MFA step has identified the
user, call it to get the same sessions, rotation, revocation and login events as password login.

**Security events** are stored in the `SecurityEvent` model and
sent as a signal after the transaction commits, so you can forward them to a SIEM:

```python
from django.dispatch import receiver
from universal_auth.services import security_event


@receiver(security_event)
def forward(sender, event, **kwargs):
    siem.send(event.event_type, user_id=event.user_id, ip=event.ip_address, **event.metadata)
```

Event types: `AUTH_LOGIN_SUCCESS`, `AUTH_LOGIN_FAILED`, `AUTH_ACCOUNT_LOCKED`, `AUTH_LOGOUT`,
`AUTH_TOKEN_REFRESHED`, `AUTH_REFRESH_REUSE_DETECTED`, `AUTH_SESSION_REVOKED`,
`AUTH_PASSWORD_CHANGED`, `AUTH_PASSWORD_RESET_REQUESTED`, `AUTH_PASSWORD_RESET`,
`AUTH_ACCOUNT_ACTIVATED`, `AUTH_ACCOUNT_DEACTIVATED`, `AUTH_ACCOUNT_UNLOCKED` (these three with
metadata `actor_id`), `AUTH_INVITE_SENT` (metadata `actor_id`, `resend`), `AUTH_INVITE_ACCEPTED`,
`AUTH_SSO_IDENTITY_LINKED` (metadata
`provider`, `user_created`, `password_cleared`). SSO logins are `AUTH_LOGIN_SUCCESS` with
`{"method": "sso", "provider": ...}`. These are security events only. Keep
business audit logs in your application.

**Password reset email.** The default sender emails a link built from `PASSWORD_RESET_URL`. To
use templates or a task queue, point `PASSWORD_RESET_SENDER` at a callable
`(user, uid, token, request)`. Sending asynchronously also gives uniform response timing.

**Housekeeping.** Schedule `cleanup_expired()` (cron, Celery beat), for example daily. It deletes
expired or revoked sessions, expired refresh tokens and security events older than the retention
period, and returns the counts:

```python
from universal_auth.services import cleanup_expired

cleanup_expired()  # {"sessions": 12, "refresh_tokens": 40, "security_events": 310}
```

## Configuration reference

All keys go in `settings.UNIVERSAL_AUTH`. Unknown keys raise a system-check warning.

| Key | Default | Description |
|---|---|---|
| `SIGNING_KEY` | `None` (falls back to `SECRET_KEY` with a warning) | HMAC secret, or private key PEM for RS256/ES256 |
| `VERIFYING_KEY` | `None` | Public key PEM for RS256/ES256 |
| `ALGORITHM` | `"HS256"` | `HS256`, `HS384`, `HS512`, `RS256` or `ES256` |
| `ISSUER` / `AUDIENCE` | `None` | Added to tokens and verified when set |
| `LEEWAY` | `timedelta(0)` | Clock-skew tolerance for `exp`/`iat` |
| `ACCESS_TOKEN_LIFETIME` | `timedelta(minutes=5)` | Never longer than the session's remaining lifetime |
| `ACCESS_TOKEN_CHECK_SESSION` | `True` | Check session and user state on every request (immediate revocation) |
| `REFRESH_TOKEN_LIFETIME` | `timedelta(days=1)` | Idle lifetime; each refresh issues a new token |
| `REFRESH_TOKEN_REUSE_GRACE` | `timedelta(0)` | If a rotated token is replayed within this window, reject it without revoking the session. Tolerates clients that refresh in parallel. |
| `SESSION_MAX_LIFETIME` | `timedelta(days=30)` | Absolute session lifetime; the user must log in again afterwards |
| `MAX_SESSIONS_PER_USER` | `None` | When set, the oldest active sessions are revoked when a new one is created |
| `REFRESH_TOKEN_TRANSPORT` | `"body"` | `"body"` or `"cookie"` |
| `REFRESH_COOKIE_NAME` | `"ua_refresh"` | Cookie mode |
| `REFRESH_COOKIE_PATH` | `"/auth/token/"` | Cookie mode |
| `REFRESH_COOKIE_DOMAIN` | `None` | Cookie mode |
| `REFRESH_COOKIE_SAMESITE` | `"Strict"` | Cookie mode |
| `REFRESH_COOKIE_SECURE` | `True` | Cookie mode |
| `LOCKOUT_ENABLED` | `True` | Credential-failure lockout |
| `LOCKOUT_THRESHOLD` | `5` | Failures per login identifier within the window |
| `LOCKOUT_IP_THRESHOLD` | `50` | Failures per client IP within the window |
| `LOCKOUT_WINDOW` | `timedelta(minutes=15)` | Counting window |
| `LOCKOUT_DURATION` | `timedelta(minutes=15)` | How long a lock lasts |
| `LOCKOUT_CACHE` | `"default"` | Cache alias; must be shared between workers in production |
| `THROTTLE_RATES` | `{"login": "10/min", "refresh": "30/min", "password_reset": "5/hour", "sso": "20/min", "invite_accept": "10/hour"}` | Per-IP DRF rates, merged with these defaults (overriding one keeps the others); `None` disables one. `password_reset` applies to reset requests and confirmations, counted separately; `sso` applies to starting SSO (login, ID token) and completing it (callback, exchange), counted separately |
| `REVOKE_SESSIONS_ON_PASSWORD_CHANGE` | `True` | Revoke all *other* sessions on password change (reset always revokes all) |
| `PASSWORD_RESET_SENDER` | `"universal_auth.services.send_password_reset_email"` | Callable `(user, uid, token, request)` |
| `PASSWORD_RESET_URL` | `None` | Link template with `{uid}` and `{token}`; required by the default sender |
| `USER_SERIALIZER` | `"universal_auth.serializers.UserSerializer"` | Renders `user` in responses |
| `ENABLE_SESSION_API` | `True` | Mount the `sessions*` endpoints |
| `ENABLE_PASSWORD_RESET_API` | `True` | Mount the `password/reset*` endpoints |
| `ENABLE_USER_ADMIN_API` | `False` | Mount `invite`, `invite/accept`, `users/<id>/resend-invite` and `users/<id>/unlock` |
| `USER_ADMIN_PERMISSION_CLASSES` | `["rest_framework.permissions.IsAdminUser"]` | Dotted paths of DRF permission classes for the admin endpoints (on top of being logged in) |
| `INVITE_URL` | `None` | Link template with `{uid}` and `{token}`; required by the default invite sender |
| `INVITE_TIMEOUT` | `timedelta(days=3)` | Invite link lifetime |
| `INVITE_SENDER` | `"universal_auth.services.send_invite_email"` | Callable `(user, uid, token, request)`; runs inside the invite transaction |
| `INVITE_SERIALIZER` | `"universal_auth.serializers.InviteSerializer"` | Validates the `invite` body |
| `INVITE_CREATE_USER` | `"universal_auth.services.create_invited_user"` | Callable `(validated_data, invited_by, request)` returning the new user |
| `RECORD_SECURITY_EVENTS` | `True` | Persist events (the signal is sent either way) |
| `SECURITY_EVENT_RETENTION` | `timedelta(days=90)` | Used by `cleanup_expired()` |
| `SSO_PROVIDERS` | `{}` | OpenID Connect providers by name (see [Single sign-on](#single-sign-on-openid-connect)) |
| `SSO_FRONTEND_URL` | `None` | Frontend page for the redirect flow; without it only the ID token flow is available |
| `SSO_CREATE_USER` | `"universal_auth.sso.create_sso_user"` | Creates users on first SSO login |

System checks (`manage.py check`):

| ID | Meaning |
|---|---|
| `universal_auth.W001` | No dedicated `SIGNING_KEY`; `SECRET_KEY` is used |
| `universal_auth.W002` | Unknown `UNIVERSAL_AUTH` key (likely a typo) |
| `universal_auth.W003` | Lockout cache is per-process (LocMem/Dummy) with `DEBUG=False` |
| `universal_auth.W004` | Cookie transport with a `REFRESH_COOKIE_PATH` that doesn't cover the mounted `token/refresh` URL |
| `universal_auth.E001` | Unsupported `ALGORITHM` |
| `universal_auth.E002` | HMAC key too short (32/48/64 bytes for HS256/384/512) |
| `universal_auth.E003` | RS256/ES256 without both `SIGNING_KEY` and `VERIFYING_KEY` |
| `universal_auth.E004` | Invalid `REFRESH_TOKEN_TRANSPORT` |
| `universal_auth.E005` | Password reset API enabled without `PASSWORD_RESET_URL` (default sender) |
| `universal_auth.E006` | `LOCKOUT_CACHE` alias missing from `CACHES` |
| `universal_auth.E007` | RS256/ES256 without the `cryptography` package (install the `crypto` extra) |
| `universal_auth.E008` | Invalid `SSO_PROVIDERS` entry (bad name, missing `ISSUER`/`CLIENT_ID`, unknown key) |
| `universal_auth.E009` | `SSO_PROVIDERS` set without the `cryptography` package |
| `universal_auth.E010` | `SSO_PROVIDERS` set while `LOCKOUT_CACHE` is a `DummyCache` (one-time codes would be lost) |
| `universal_auth.E011` | User admin API enabled without `INVITE_URL` (default sender) |

## Security notes

* Passwords use Django's hashers and `AUTH_PASSWORD_VALIDATORS`. Nothing is custom.
* No custom cryptography: JWTs come from PyJWT with a pinned algorithm list (`alg=none` and
  algorithm confusion are rejected), and reset tokens come from Django's
  `PasswordResetTokenGenerator`.
* Tokens, passwords and reset tokens are never logged or stored in events.
* **Enumeration resistance:** login gives one error for unknown user, wrong password and inactive
  user (Django hashes a dummy password for unknown users); password reset always returns 202;
  lockout counters exist for non-existent identifiers too.
* Token responses (login, refresh) are sent with `Cache-Control: no-store`.
* **Lockout** is per identifier *and* per IP. Identifiers are compared ignoring case and accents,
  like MySQL's default collations, so `Alice` and `älice` share one counter. A successful login
  clears only the identifier counter; a password reset also lifts an identifier lock. A wrong `current_password` on `password/change` counts as
  a failed login too, so a stolen access token cannot be used to guess the password.
* **Password reset** never reveals whether an account exists: a failing email sender is logged,
  not returned as an error, and look-alike addresses that the database collation would match
  (for example `ä` and `a`) are ignored.
* **Password changes outside this package** (Django admin, `manage.py changepassword`, your own
  `user.set_password()` calls) do **not** revoke sessions. Use `services.change_password()` or
  `services.reset_password()`, or call `services.revoke_sessions(user.universal_auth_sessions.all(),
  reason=RevokeReason.PASSWORD_CHANGE, user=user)` afterwards, especially when securing a
  compromised account.
* **Security events** are written after the transaction commits. If writing one fails, the error
  is logged and the request still succeeds.
* **Throttles** use DRF's cache (the `default` alias), not `LOCKOUT_CACHE`. In production both
  must be shared between workers.
* **Client IP:** `X-Forwarded-For` is only trusted when DRF's `NUM_PROXIES` is set. Set it to
  the number of proxies in front of Django.
* **Transactions:** the package's views opt out of `ATOMIC_REQUESTS`, because a rollback would
  otherwise erase failed-login events and undo a theft-triggered session revocation. Services
  open their own transactions.
* **Key rotation:** changing `SIGNING_KEY` invalidates access tokens only (5 minutes at most).
  Refresh tokens are opaque, so clients recover with a single refresh.
* **SSO:** ID tokens are checked against the provider's published keys (asymmetric algorithms
  only), exact issuer, audience, `azp`, expiry and nonce. The redirect flow uses `state`, `nonce`
  and PKCE towards the provider, and a second PKCE pair from the frontend, so the one-time code
  can't be injected into another browser. Accounts are matched on provider + `sub`; emails link
  only when verified, and linking clears a password set on the account. Provider calls are
  HTTPS-only and never follow redirects.

## Database and migrations

Tables (all prefixed `universal_auth_`): `authsession`, `refreshtoken`, `securityevent`,
`ssoidentity` (links a provider account to a user; unique per provider and `sub`).
Foreign keys use `settings.AUTH_USER_MODEL`, so any user model and primary-key type works.
Deleting a user cascades to their sessions and tokens. Security events keep their row with
`user = NULL`.

* Migrations ship with the package. Your project only runs `python manage.py migrate`; never
  run `makemigrations` for `universal_auth`. Upgrade by changing the release tag in your
  requirements and reinstalling, then `migrate` again.
* Released migrations are never edited. Backward-compatible schema changes arrive in minor
  releases; anything that needs action from you is a major release and is described in
  `CHANGELOG.md`.
* To uninstall: `python manage.py migrate universal_auth zero`, then remove it from `INSTALLED_APPS`.

## Project layout

```text
django-universal-auth/
├── universal_auth/
│   ├── apps.py            app config, UNIVERSAL_AUTH settings + defaults, system checks
│   ├── models.py          AuthSession, RefreshToken, SecurityEvent, SSOIdentity
│   ├── services.py        all logic: tokens, login, refresh, sessions, passwords, lockout, events
│   ├── sso.py             OpenID Connect single sign-on
│   ├── authentication.py  DRF JWTAuthentication
│   ├── serializers.py     request/response serializers
│   ├── views.py           optional endpoints + their throttles
│   ├── urls.py            include("universal_auth.urls")
│   ├── exceptions.py      errors with stable `code` values
│   └── migrations/
├── tests/                 test_authentication.py, test_services.py, test_api.py, test_sso.py
│                          + settings.py, conftest.py, urls.py (test infrastructure)
├── pyproject.toml  README.md  CHANGELOG.md  LICENSE
```

Only `universal_auth/` is included in the installed package. `tests/` stays in the source
repository.

`services.py` doesn't import DRF, so the logic is usable from any Django code; a test enforces this.

## Development and testing

The test suite runs against real MySQL, because the refresh-rotation race test needs InnoDB row
locks:

```bash
docker run -d --name ua-test-mysql84 -p 3307:3306 -e MYSQL_ROOT_PASSWORD=ua_test_root mysql:8.4
uv venv --python 3.14 && uv pip install -e . --group dev   # or: pip install -e . --group dev (pip ≥ 25.1)
UA_DB_PORT=3307 UA_DB_PASSWORD=ua_test_root .venv/bin/pytest
.venv/bin/ruff check universal_auth tests && .venv/bin/ruff format --check universal_auth tests
```

The development tools (pytest, pytest-django, mysqlclient, coverage, ruff) are listed in the
`dev` dependency group in `pyproject.toml`. The test database is reused between runs
(`--reuse-db`); add `--create-db` after changing a migration.

Django code needs a settings module to run, and a package has no project around it. So
`tests/settings.py` is a minimal stand-in project used only by pytest (selected with
`DJANGO_SETTINGS_MODULE = "tests.settings"` in `pyproject.toml`). It is not included in the
installed package; projects configure the package in their own settings as shown under
[Installation](#installation). Its database connection comes from `UA_DB_HOST`, `UA_DB_PORT`,
`UA_DB_USER` (default `root`), `UA_DB_PASSWORD` and `UA_DB_NAME`.

## Versioning

[Semantic Versioning](https://semver.org/). The public API consists of: documented import paths,
`UNIVERSAL_AUTH` keys, URL names, request/response shapes, error `code` values, event type names,
and table and column names. The package stays at `0.x` until authentication is complete and
reviewed, and `1.0.0` then freezes that API. See [CHANGELOG.md](CHANGELOG.md).
