Skip to main content

A Django authentication middleware package for integrating with OUA SSO server

Project description

Organization Unified Access Authentication

A plug-and-play Django authentication package for integrating with the Organisation SSO backend. Supports JWKS-based JWT verification, tenant hierarchy (Country → Company → Branch), OAuth2 authorization code flow with PKCE, and hierarchical DRF permission classes.

Installation

pip install oua-auth

Quick Start

# settings.py
INSTALLED_APPS = [..., "oua_auth"]

OUA_SSO_URL = "https://sso.org.com"
OUA_CLIENT_ID = "your-client-id"
OUA_CLIENT_SECRET = "your-client-secret"
OUA_REDIRECT_URI = "https://yourapp.com/sso/callback/"
OUA_AUDIENCE = "organisation-services"
OUA_APPLICATION_CODE = "APPLICATION_CODE"

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "oua_auth.authentication.OUAJWTAuthentication",
    ],
}

MIDDLEWARE = [
    ...,
    "oua_auth.middleware.OUAAuthMiddleware",
    "oua_auth.middleware.TenantScopeMiddleware",
    "oua_auth.security_middleware.SecurityHeadersMiddleware",
]

# urls.py
urlpatterns = [
    path("sso/", include("oua_auth.urls")),
    ...
]

Run migrations:

python manage.py migrate oua_auth

Configuration

Required Settings

Setting Description
OUA_SSO_URL Organisation SSO server base URL
OUA_CLIENT_ID OAuth client ID

Auto-Discovered (OIDC)

These are fetched automatically from /.well-known/openid-configuration but can be overridden:

Setting Default
OUA_ISSUER From OIDC discovery
OUA_JWKS_URI {OUA_SSO_URL}/api/v1/oauth/keys
OUA_AUDIENCE organisation-services

OAuth Flow Settings

Setting Description
OUA_CLIENT_SECRET OAuth client secret (for confidential clients)
OUA_REDIRECT_URI Callback URL in your Django app
OUA_SCOPES OAuth scopes (default: openid profile email)
OUA_APPLICATION_CODE App code for role filtering (e.g. APPLICATION_CODE)
OUA_LOGIN_REDIRECT_URL Where to redirect after login (default: /)

Breaking changes in 0.8.0

  • Identity and authorisation claims are no longer read from the JWT body. Starting in 0.8.0, oua_auth resolves identity (email, preferred_username, name) from GET /userinfo and role + tenant (sso_role, country_id, company_id, branch_ids) from POST /introspect. The JWT body carries only sub, jti, typ, scp, azp, exp, iat, iss, aud, email_verified (v2 slim-token contract). Deployments must configure introspection credentials (OUA_INTROSPECTION_CLIENT_ID / OUA_INTROSPECTION_CLIENT_SECRET) or authentication fails closed with HTTP 503.
  • SSO unreachable → 503, not 401. When the SSO authority is unreachable and the local cache is cold, the SDK returns 503 + Retry-After — never a forced logout or 401. Stale cached claims are served within the grace window (OUA_REMOTE_CLAIMS_GRACE, default 300 s beyond TTL).
  • Seven new OUA_* settings: OUA_INTROSPECT_ENDPOINT, OUA_INTROSPECTION_CLIENT_ID, OUA_INTROSPECTION_CLIENT_SECRET, OUA_REMOTE_CLAIMS_CACHE_TTL, OUA_REMOTE_CLAIMS_GRACE, OUA_REMOTE_CLAIMS_TIMEOUT, OUA_FETCH_USERINFO. See docs/configuration.md for defaults and details.

Breaking changes in 0.6.0

  • Admin promotion fails closed (M-9). If both OUA_TRUSTED_ADMIN_DOMAINS and OUA_TRUSTED_ADMIN_EMAILS are unset, no SSO SUPER_ADMIN is auto-granted Django is_staff/is_superuser (previous versions promoted everyone in that case). Set at least one to retain auto-promotion; otherwise provision admins manually. A startup WARNING is logged when both are unset in production-mode config.
  • Session-restore verifies the token (C-1). Browser/session requests now re-verify the session access token via JWKS; an invalid token is cleared and the request proceeds unauthenticated.
  • Static-PEM fallback is disabled by default (H-4). When OUA_ALLOW_STATIC_KEY_FALLBACK is unset or False, a JWKS resolution failure results in AuthenticationFailed (401) instead of silently retrying against OUA_PUBLIC_KEY. Clients that relied on the implicit fallback must set OUA_ALLOW_STATIC_KEY_FALLBACK = True explicitly — but the recommended fix is to ensure JWKS reachability.

Security Settings

Setting Default Description
OUA_PUBLIC_KEY None Static PEM fallback key (PEM string). Only consulted when OUA_ALLOW_STATIC_KEY_FALLBACK is True.
OUA_ALLOW_STATIC_KEY_FALLBACK False Security gate (H-4). When False (default), a JWKS resolution failure raises AuthenticationFailed immediately (fail-closed). Set to True only in environments where JWKS is genuinely unreachable and OUA_PUBLIC_KEY is a current, trusted key. A WARNING is logged every time the fallback fires. The correct long-term fix is ensuring JWKS reachability.
OUA_TOKEN_LEEWAY 0 Clock skew tolerance in seconds
OUA_JWKS_REFETCH_COOLDOWN 60 Minimum seconds between forced JWKS refetches on signature failure (H-3). A refetch is skipped — and InvalidSignatureError is raised immediately — if one already fired within this window.
OUA_TRUSTED_ADMIN_DOMAINS [] Domains auto-granted Django admin (case-insensitive). Empty = no auto-promotion (fail-closed).
OUA_TRUSTED_ADMIN_EMAILS [] Emails auto-granted Django admin. Empty = no auto-promotion (fail-closed).
OUA_MAX_AUTH_FAILURES 5 Failures before rate limiting
OUA_AUTH_FAILURE_WINDOW 300 Rate limit window in seconds
OUA_ALLOWED_DOMAINS [] Only these email domains can authenticate
OUA_RESTRICTED_DOMAINS [] These email domains are blocked
OUA_MAX_SUSPICIOUS_ACTIVITIES 3 Threshold for account locking

Requiring MFA-backed tokens (OUA_REQUIRED_ACR)

Require that access tokens were issued with a sufficient Authentication Context Class Reference (OIDC acr, e.g. multi-factor). Off by default.

# settings.py — app-wide: every authenticated request must carry an MFA acr
OUA_REQUIRED_ACR = "mfa"          # or a list: ["mfa", "phrh"]

A token whose acr is not one of the configured values is rejected with HTTP 401 and a step-up challenge so the client can re-authenticate:

WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="mfa"

Values are matched exactly, including case (acr values are case-sensitive per OIDC). There is no ordering/hierarchy — list every acceptable value.

For per-endpoint enforcement, use the RequiresACR permission. It can only strengthen the global requirement (it never loosens OUA_REQUIRED_ACR):

from rest_framework.views import APIView

from oua_auth.permissions import RequiresACR

class PayoutView(APIView):
    permission_classes = [RequiresACR("mfa")]   # or RequiresACR("mfa", "phrh")

For server-rendered (session/MVT) views, use the require_acr decorator instead — it redirects the browser to step up rather than returning a 401:

from oua_auth.decorators import require_acr

@require_acr("mfa")
def sensitive_settings(request):
    ...

An insufficient acr redirects to oua-login?next=<path>&acr_values=mfa, which forwards acr_values to the SSO /authorize endpoint so the user can step up and return to the page they were on. Build the same URL directly with build_step_up_url(request, "mfa").

SCIM consumer (oua_auth.scim)

Enable by adding oua_auth.scim.apps.OUAScimConfig to INSTALLED_APPS, mounting oua_auth.scim.urls, and setting:

Setting Required Default Description
OUA_SCIM_ADAPTER yes Dotted path to your SCIMAdapter implementation
OUA_SCIM_ROLE_EXTENSION_URN yes SCIM extension URN carrying the per-app role list
OUA_SCIM_PROVISION_SCOPE no scim:provision OAuth scope a machine token must carry
OUA_SCIM_IDENTITY_MODEL no oua_scim.SCIMIdentity Swappable identity model (app_label.ModelName)
OUA_PROVISION_CLIENT_ID / OUA_PROVISION_CLIENT_SECRET for dual-write Machine client creds for outbound provisioning

Migrating an existing consumer (db_table trap)

If your app already has a SCIM-identity table (e.g. you're extracting this consumer out of an app that owned the table before), do not leave OUA_SCIM_IDENTITY_MODEL at its default. The shipped default model binds a fresh oua_scim_identity table, so a default install creates an empty table and orphans your existing rows. To re-base onto your existing data:

  1. Subclass AbstractSCIMIdentity in your own app.
  2. Set the subclass's Meta.db_table to your existing identity table name.
  3. Point OUA_SCIM_IDENTITY_MODEL at that subclass. Like AUTH_USER_MODEL, the swap is resolved at app-registry load, so set this before startup (and before running migrations).
  4. Generate the re-base migration and verify it is state-only — no CREATE TABLE / ALTER TABLE against the live table (use a SeparateDatabaseAndState / --state-only migration). If the migration would rewrite the table, stop: the model and the existing schema are out of sync.

Authentication Flow

API Authentication (Bearer Token)

For API clients that already have a JWT access token from the SSO:

Authorization: Bearer <access-token>

The middleware validates the JWT via JWKS, provisions/updates the local Django user, and sets:

  • request.user — authenticated Django user
  • request.oua_claims — decoded JWT payload
  • request.oua_tenant — dict with country_id, company_id, branch_id, sso_role, role_level
  • request.tenant_scopeTenantScope dataclass (set by TenantScopeMiddleware)

Browser Login (OAuth2 + PKCE)

For web apps that need to redirect users to the SSO login page:

  1. User visits a protected page
  2. @sso_login_required redirects to /sso/login/
  3. OUA generates PKCE challenge and redirects to SSO authorize endpoint
  4. User authenticates on SSO (with optional 2FA)
  5. SSO redirects back to /sso/callback/ with an authorization code
  6. OUA exchanges the code for tokens, creates a Django session
  7. User is redirected to the original page

SSO Role Hierarchy

The SSO uses a hierarchical role model:

Role Level Scope
SUPER_ADMIN 5 Platform-wide
COUNTRY_ADMIN 4 Country-wide
COMPANY_ADMIN 3 Company-wide
BRANCH_ADMIN 2 Branch-level
USER 1 Basic access

DRF Permission Classes

from oua_auth.permissions import HasSSORole, HasTenantAccess, IsSSOAuthenticated

class OrderViewSet(viewsets.ModelViewSet):
    # Require at least COMPANY_ADMIN role
    permission_classes = [HasSSORole("COMPANY_ADMIN")]

    def get_queryset(self):
        # Auto-filter by tenant scope
        return Order.objects.for_tenant(self.request.tenant_scope)

class BranchDetailView(APIView):
    # Check tenant access on object level
    permission_classes = [IsSSOAuthenticated, HasTenantAccess]

View Decorators

from oua_auth.decorators import sso_login_required, require_sso_role

@sso_login_required
def dashboard(request):
    return render(request, "dashboard.html")

@require_sso_role("BRANCH_ADMIN")
def admin_panel(request):
    return render(request, "admin.html")

Tenant-Scoped Models

from oua_auth.models import TenantMixin

class Order(TenantMixin):
    description = models.TextField()
    amount = models.DecimalField(max_digits=10, decimal_places=2)

# In a view — automatic tenant filtering:
orders = Order.objects.for_tenant(request.tenant_scope)

# Manual tenant assignment on create:
Order.objects.create(
    description="New order",
    amount=100.00,
    country_id=request.tenant_scope.country_id,
    company_id=request.tenant_scope.company_id,
    branch_id=request.tenant_scope.branch_id,
)

Token Blacklisting

from oua_auth.authentication import OUAJWTAuthentication

# Revoke a token
OUAJWTAuthentication.revoke_token(
    token=request.oua_token,
    blacklisted_by=request.user.email,
    reason="User logout",
)

Clean up expired tokens:

python manage.py clean_expired_tokens

Security Features

  • JWKS-based JWT verification with automatic key rotation retry
  • Token type validation — rejects refresh tokens used as access tokens
  • Tenant hierarchy — Country → Company → Branch scoping
  • Hierarchical role modelSUPER_ADMIN > COUNTRY_ADMIN > COMPANY_ADMIN > BRANCH_ADMIN > USER
  • Rate limiting — distributed via Django cache framework
  • Token blacklisting — persistent DB + in-memory fallback
  • Account locking — after configurable suspicious activity threshold
  • Input sanitization — XSS prevention via lxml
  • Security headers middleware — CSP, HSTS, X-Frame-Options, etc.
  • Structured logging — JSON output with sensitive data redaction

Testing

pip install -e ".[test]"
python -m pytest
python -m pytest --cov=oua_auth --cov-report=term-missing

License

MIT License — see the LICENSE file for details.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

oua_auth-1.8.0.tar.gz (185.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

oua_auth-1.8.0-py3-none-any.whl (80.0 kB view details)

Uploaded Python 3

File details

Details for the file oua_auth-1.8.0.tar.gz.

File metadata

  • Download URL: oua_auth-1.8.0.tar.gz
  • Upload date:
  • Size: 185.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.14

File hashes

Hashes for oua_auth-1.8.0.tar.gz
Algorithm Hash digest
SHA256 67d17dd4f75bdb50a846fca8b831a23018a95fe35af2937bb037b74df4e275f4
MD5 2d6b56312df4320f6cc7c8fd3e9ac30a
BLAKE2b-256 cf78810e3c15031811f76dae3297efe14680ba5daf8cb02ccdf57fc023b48d76

See more details on using hashes here.

File details

Details for the file oua_auth-1.8.0-py3-none-any.whl.

File metadata

  • Download URL: oua_auth-1.8.0-py3-none-any.whl
  • Upload date:
  • Size: 80.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.14

File hashes

Hashes for oua_auth-1.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d86c3869cd0d58f0ca092aa4ce440844afd25325e89ef0b94258d8c73be756ea
MD5 d2b4832730922f1a1144ac8e7478e4b8
BLAKE2b-256 a0c208249f37f9105902cba01a118e767602b0fef5f39cad03424ea5741d2320

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page