UniAuth.ID

Django Quickstart

Integrate UniAuth with Django using mozilla-django-oidc — Mozilla's production-grade OIDC client (used by Firefox Accounts). Full discovery support, JWKS verification, back-channel logout.

Discovery-first: mozilla-django-oidc auto-fetches every endpoint from https://uniauth.id/.well-known/openid-configuration. You set the issuer; it configures the rest.

1. Install

pip install mozilla-django-oidc cryptography

2. Environment

# .env
UNIAUTH_CLIENT_ID=uni_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
UNIAUTH_CLIENT_SECRET=unis_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

3. settings.py

# settings.py
import os

INSTALLED_APPS = [
    # ...
    'mozilla_django_oidc',
    'myapp',
]

AUTHENTICATION_BACKENDS = [
    'myapp.auth.UniAuthBackend',   # custom — see step 5
    'django.contrib.auth.backends.ModelBackend',
]

MIDDLEWARE = [
    # ...
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'mozilla_django_oidc.middleware.SessionRefresh',
]

# UniAuth via OIDC discovery
OIDC_RP_CLIENT_ID              = os.environ['UNIAUTH_CLIENT_ID']
OIDC_RP_CLIENT_SECRET          = os.environ['UNIAUTH_CLIENT_SECRET']
OIDC_RP_SIGN_ALGO              = 'RS256'
OIDC_OP_JWKS_ENDPOINT          = 'https://uniauth.id/.well-known/jwks.json'
OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://uniauth.id/api/oauth/authorize'
OIDC_OP_TOKEN_ENDPOINT         = 'https://uniauth.id/api/oauth/token'
OIDC_OP_USER_ENDPOINT          = 'https://uniauth.id/api/oauth/userinfo'
OIDC_OP_LOGOUT_ENDPOINT        = 'https://uniauth.id/api/oauth/end-session'

# Scopes — 'groups' lets you use the OIDC groups claim for RBAC
OIDC_RP_SCOPES = 'openid profile email groups'

# Require PKCE (UniAuth enforces it)
OIDC_USE_PKCE = True

# Verify ID token locally (don't roundtrip to /userinfo unless needed)
OIDC_VERIFY_JWT = True
OIDC_CREATE_USER = True

LOGIN_URL        = '/oidc/authenticate/'
LOGIN_REDIRECT_URL  = '/'
LOGOUT_REDIRECT_URL = '/'

Tip: for extra resilience, fetch endpoints from discovery at startup instead of hardcoding them in settings.py:

# settings.py — alternative: fetch from discovery
import requests

_disc = requests.get(
    'https://uniauth.id/.well-known/openid-configuration',
    timeout=5
).json()
OIDC_OP_AUTHORIZATION_ENDPOINT = _disc['authorization_endpoint']
OIDC_OP_TOKEN_ENDPOINT         = _disc['token_endpoint']
OIDC_OP_USER_ENDPOINT          = _disc['userinfo_endpoint']
OIDC_OP_JWKS_ENDPOINT          = _disc['jwks_uri']
OIDC_OP_LOGOUT_ENDPOINT        = _disc['end_session_endpoint']

4. urls.py

# urls.py
from django.urls import include, path
from myapp.views import backchannel_logout

urlpatterns = [
    path('oidc/', include('mozilla_django_oidc.urls')),
    path('logout-webhook/', backchannel_logout, name='backchannel_logout'),
    # ...
]

5. Custom Backend — Map groups Claim to is_staff

# myapp/auth.py
from mozilla_django_oidc.auth import OIDCAuthenticationBackend


class UniAuthBackend(OIDCAuthenticationBackend):
    def verify_claims(self, claims):
        # Require email_verified before login (no env-var allowlist)
        if not claims.get('email_verified'):
            return False
        return super().verify_claims(claims)

    def create_user(self, claims):
        user = super().create_user(claims)
        self._apply_groups(user, claims)
        return user

    def update_user(self, user, claims):
        user = super().update_user(user, claims)
        self._apply_groups(user, claims)
        return user

    def _apply_groups(self, user, claims):
        groups = claims.get('groups', []) or []
        # Map UniAuth groups claim to Django staff/superuser
        user.is_staff     = 'admin' in groups or 'staff' in groups
        user.is_superuser = 'admin' in groups
        # Set custom attribute if you have one
        # user.uniauth_sub = claims['sub']
        user.save()

6. Back-channel Logout

# myapp/views.py
import requests
from functools import lru_cache
from django.contrib.sessions.models import Session
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.conf import settings
from jose import jwt


@lru_cache(maxsize=1)
def _jwks():
    return requests.get('https://uniauth.id/.well-known/jwks.json', timeout=5).json()


@csrf_exempt
@require_POST
def backchannel_logout(request):
    token = request.POST.get('logout_token', '')
    try:
        claims = jwt.decode(
            token, _jwks(),
            algorithms=['RS256'],
            issuer='https://uniauth.id',
            audience=settings.OIDC_RP_CLIENT_ID,
        )
    except Exception:
        return HttpResponse(status=400)

    # Required per OIDC Back-Channel Logout 1.0
    if 'http://schemas.openid.net/event/backchannel-logout' not in (claims.get('events') or {}):
        return HttpResponse(status=400)
    if 'nonce' in claims:
        return HttpResponse(status=400)

    # Invalidate all Django sessions for this user
    from django.contrib.auth import get_user_model
    User = get_user_model()
    try:
        user = User.objects.get(username=claims['sub'])  # or by your uniauth_sub field
        Session.objects.filter(expire_date__gt=__import__('django').utils.timezone.now()).all()
        # Simpler: bump user.last_login / clear sessions filtered by session_data contains user_id
        user.sessions.all().delete() if hasattr(user, 'sessions') else None
    except User.DoesNotExist:
        pass

    return HttpResponse(status=200)

Register https://yourapp.com/logout-webhook/ as backchannel_logout_uri in the Developer Console.

7. Protecting Views

from django.contrib.auth.decorators import login_required, user_passes_test

@login_required
def dashboard(request):
    return render(request, 'dashboard.html')

@user_passes_test(lambda u: u.is_staff)
def admin_panel(request):
    return render(request, 'admin.html')

Next