Skip to main content

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 package.

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 for uv and editable installs):

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.

# 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
}

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}",
}
# urls.py
from django.urls import include, path

urlpatterns = [
    path("auth/", include("universal_auth.urls")),
    # your API ...
]
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:

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

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)

Token response:

{
  "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)
404 not_found DELETE sessions/<id> for an unknown session or another user's session
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.

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). SSO logins create normal sessions, so refresh, logout, session management and security events work exactly as for password logins.

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.

// 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>).

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.

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)  # is_active=False + revoke all sessions
services.activate_user(user)
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:

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_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:

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"} 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
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)
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)

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

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:

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

Metadata

Release files for django-universal-auth 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-universal-auth 0.1.0
File Size Uploaded Provenance
django_universal_auth-0.1.0.tar.gz 64.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for django-universal-auth 0.1.0
File Interpreter ABI Platform
django_universal_auth-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 107.2 kB

Release files / django_universal_auth-0.1.0.tar.gz

Download URL django_universal_auth-0.1.0.tar.gz
Size 64.4 kB
Tags Source
SHA-256 checksum
How to use checksums
55fd100db40289682b78189cdc85b34f7e70392658fe101a5dfaa23d579a9d1b
BLAKE2b-256 checksum
How to use checksums
c8152322e789696c819ad81e7d0a153db1c86ed9f8fdec75b44d98df6f52fb23
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.12

Release files / django_universal_auth-0.1.0-py3-none-any.whl

Download URL django_universal_auth-0.1.0-py3-none-any.whl
Size 42.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
159bc75593458a08ee500fd3fb74a2a25ddcb8a28516fa81a516ce639babff97
BLAKE2b-256 checksum
How to use checksums
9080d785164202e54b51c4ced3a6971465f67796f91271403a03bd6fd8dfac6d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.12

Security for django-universal-auth 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page