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
- Installation
- How it works
- API endpoints
- Using it from your own code
- Configuration reference
- Security notes
- Database and migrations
- Project layout
- Development and testing
- 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 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,typand optionallyissandaud. 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 afterSESSION_MAX_LIFETIMEregardless of activity, and can be revoked at any time. - Revocation is immediate. Every request checks the session and
user.is_activewith one indexed query. SetACCESS_TOKEN_CHECK_SESSION = Falsefor fully stateless verification; revocation then takes up toACCESS_TOKEN_LIFETIMEto take effect. - Login uses
django.contrib.auth.authenticate(), so yourAUTHENTICATION_BACKENDS, custom user models (email login, UUID keys), password hashers anduser_logged_in/last_loginbehave 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.
Refresh token in an HttpOnly cookie (browser SPAs)
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):
- The frontend creates a random
code_verifier(43–128 characters fromA-Z a-z 0-9 - . _ ~), keeps it insessionStorage, and sends the browser toGET /auth/sso/google/login?code_challenge=<BASE64URL(SHA-256(code_verifier)), no padding>. - 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). - The browser lands on
SSO_FRONTEND_URL?code=<one-time code>, or?error=<code>on failure. - The frontend posts
{"code", "code_verifier"}toPOST /auth/sso/exchangeand 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, throughSSO_CREATE_USER. The default sets the username to<provider>-<sub>, the email (only if verified) and the first/last name. PointSSO_CREATE_USERat 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=noneand algorithm confusion are rejected), and reset tokens come from Django'sPasswordResetTokenGenerator. - 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
Aliceandäliceshare one counter. A successful login clears only the identifier counter; a password reset also lifts an identifier lock. A wrongcurrent_passwordonpassword/changecounts 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
äanda) are ignored. - Password changes outside this package (Django admin,
manage.py changepassword, your ownuser.set_password()calls) do not revoke sessions. Useservices.change_password()orservices.reset_password(), or callservices.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
defaultalias), notLOCKOUT_CACHE. In production both must be shared between workers. - Client IP:
X-Forwarded-Foris only trusted when DRF'sNUM_PROXIESis 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_KEYinvalidates 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 usesstate,nonceand 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 runmakemigrationsforuniversal_auth. Upgrade by changing the release tag in your requirements and reinstalling, thenmigrateagain. - 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 fromINSTALLED_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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| django_universal_auth-0.1.0.tar.gz | 64.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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.