# SSO with Allauth & Casdoor Pattern v1.0.0

Standardizes OIDC-based Single Sign-On using Django Allauth and Casdoor, covering adapter customization, user provisioning, group mapping, superuser protection, and configurable local-login fallback. Used by the `core` Django application.

## 🐾 Red Panda Approval™

This pattern follows Red Panda Approval standards.

---

## Why a Pattern, Not a Shared Implementation

Every Django project that adopts SSO has different identity-provider configurations, claim schemas, permission models, and organizational structures:

- A **project management** app needs role claims mapped to project-scoped permissions
- An **e-commerce** app needs tenant/store claims with purchase-limit groups
- An **RFP tool** (Spelunker) needs organization + group claims mapped to View Only / Staff / SME / Admin groups

Instead, this pattern defines:

- **Required components** — every implementation must have
- **Required settings** — Django & Allauth configuration values
- **Standard conventions** — group names, claim mappings, redirect URL format
- **Extension guidelines** — for domain-specific provisioning logic

---

## Required Components

Every SSO implementation following this pattern must provide these files:

| Component | Location | Purpose |
|-----------|----------|---------|
| Social account adapter | `<app>/adapters.py` | User provisioning, group mapping, superuser protection |
| Local account adapter | `<app>/adapters.py` | Disable local signup, authentication logging |
| Management command | `<app>/management/commands/create_sso_groups.py` | Idempotent group + permission creation |
| Login template | `templates/account/login.html` | SSO button + conditional local login form |
| Context processor | `<app>/context_processors.py` | Expose `CASDOOR_ENABLED` / `ALLOW_LOCAL_LOGIN` to templates |
| SSL patch (optional) | `<app>/ssl_patch.py` | Development-only SSL bypass |

### Minimum settings.py configuration

```python
# INSTALLED_APPS — required entries
INSTALLED_APPS = [
    # ... standard Django apps ...
    'allauth',
    'allauth.account',
    'allauth.socialaccount',
    'allauth.socialaccount.providers.openid_connect',
    '<your_app>',
]

# MIDDLEWARE — Allauth middleware is required
MIDDLEWARE = [
    # ... standard Django middleware ...
    'allauth.account.middleware.AccountMiddleware',
]

# AUTHENTICATION_BACKENDS — both local and SSO
AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
    'allauth.account.auth_backends.AuthenticationBackend',
]
```

---

## Standard Values / Conventions

### Environment Variables

Every deployment must set these environment variables (or `.env` entries):

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CASDOOR_ENABLED` | Yes | — | Enable/disable SSO (`true`/`false`) |
| `CASDOOR_ORIGIN` | Yes | — | Casdoor backend URL for OIDC discovery |
| `CASDOOR_ORIGIN_FRONTEND` | Yes | — | Casdoor frontend URL (may differ behind reverse proxy) |
| `CASDOOR_CLIENT_ID` | Yes | — | OAuth client ID from Casdoor application |
| `CASDOOR_CLIENT_SECRET` | Yes | — | OAuth client secret from Casdoor application |
| `CASDOOR_ORG_NAME` | Yes | — | Default organization slug in Casdoor |
| `ALLOW_LOCAL_LOGIN` | No | `false` | Show local login form for non-superusers |
| `CASDOOR_SSL_VERIFY` | No | `true` | SSL verification (`true`, `false`, or CA-bundle path) |

### Redirect URL Convention

The Allauth OIDC callback URL follows a fixed format. Register this URL in Casdoor:

```
/accounts/oidc/<provider_id>/login/callback/
```

For Spelunker with `provider_id = casdoor`:

```
/accounts/oidc/casdoor/login/callback/
```

> **Important:** The path segment is `oidc`, not `openid_connect`.

### Standard Group Mapping

Casdoor group names map to Django groups with consistent naming:

| Casdoor Group | Django Group | `is_staff` | Permissions |
|---------------|-------------|------------|-------------|
| `view_only` | `View Only` | `False` | `view_*` |
| `staff` | `Staff` | `True` | `view_*`, `add_*`, `change_*` |
| `sme` | `SME` | `True` | `view_*`, `add_*`, `change_*` |
| `admin` | `Admin` | `True` | `view_*`, `add_*`, `change_*`, `delete_*` |

### Standard OIDC Claim Mapping

| Casdoor Claim | Django Field | Notes |
|---------------|-------------|-------|
| `email` | `User.username`, `User.email` | Full email used as username |
| `given_name` | `User.first_name` | — |
| `family_name` | `User.last_name` | — |
| `name` | Parsed into first/last | Fallback when given/family absent |
| `organization` | Organization lookup/create | Via adapter |
| `groups` | Django Group membership | Via adapter mapping |

---

## Recommended Settings

Most implementations should include these Allauth settings:

```python
# Authentication mode
ACCOUNT_LOGIN_METHODS = {'email'}
ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*']
ACCOUNT_EMAIL_VERIFICATION = 'optional'
ACCOUNT_SESSION_REMEMBER = True
ACCOUNT_LOGIN_ON_PASSWORD_RESET = True
ACCOUNT_UNIQUE_EMAIL = True

# Redirects
LOGIN_REDIRECT_URL = '/dashboard/'
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
LOGIN_URL = '/accounts/login/'

# Social account behavior
SOCIALACCOUNT_AUTO_SIGNUP = True
SOCIALACCOUNT_EMAIL_VERIFICATION = 'none'
SOCIALACCOUNT_QUERY_EMAIL = True
SOCIALACCOUNT_STORE_TOKENS = True
SOCIALACCOUNT_ADAPTER = '<app>.adapters.CasdoorAccountAdapter'
ACCOUNT_ADAPTER = '<app>.adapters.LocalAccountAdapter'

# Session management
SESSION_COOKIE_AGE = 28800   # 8 hours
SESSION_SAVE_EVERY_REQUEST = True

# Account linking — auto-connect SSO to an existing local account with
# the same verified email instead of raising a conflict error
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
```

### Multi-Factor Authentication (Recommended)

Add `allauth.mfa` for TOTP/WebAuthn second-factor support:

```python
INSTALLED_APPS += ['allauth.mfa']
MFA_ADAPTER = 'allauth.mfa.adapter.DefaultMFAAdapter'
```

MFA is enforced per-user inside Django; Casdoor may also enforce its own MFA upstream.

### Rate Limiting on Local Login (Recommended)

Protect the local login form from brute-force attacks with `django-axes` or similar:

```python
# pip install django-axes
INSTALLED_APPS += ['axes']
AUTHENTICATION_BACKENDS = [
    'axes.backends.AxesStandaloneBackend',
    'django.contrib.auth.backends.ModelBackend',
    'allauth.account.auth_backends.AuthenticationBackend',
]
AXES_FAILURE_LIMIT = 5            # Lock after 5 failures
AXES_COOLOFF_TIME = 1             # 1-hour cooloff
AXES_LOCKOUT_PARAMETERS = ['ip_address', 'username']
```

---

## Social Account Adapter

The social account adapter is the core of the pattern. It handles user provisioning on SSO login, maps claims to Django fields, enforces superuser protection, and assigns groups.

```python
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.exceptions import ImmediateHttpResponse
from django.contrib.auth.models import User, Group
from django.contrib import messages
from django.shortcuts import redirect
import logging

logger = logging.getLogger(__name__)


class CasdoorAccountAdapter(DefaultSocialAccountAdapter):

    def is_open_for_signup(self, request, sociallogin):
        """Always allow SSO-initiated signup."""
        return True

    def pre_social_login(self, request, sociallogin):
        """
        Runs on every SSO login (new and returning users).

        1. Blocks superusers — they must use local auth.
        2. Re-syncs organization and group claims for returning users
           so that IdP changes are reflected immediately.
        """
        if sociallogin.user.id:
            user = sociallogin.user

            # --- Superuser gate ---
            if user.is_superuser:
                logger.warning(
                    f"SSO login blocked for superuser {user.username}. "
                    "Superusers must use local authentication."
                )
                messages.error(
                    request,
                    "Superuser accounts must use local authentication."
                )
                raise ImmediateHttpResponse(redirect('account_login'))

            # --- Re-sync claims for returning users ---
            extra_data = sociallogin.account.extra_data

            org_identifier = extra_data.get('organization', '')
            if org_identifier:
                self._assign_organization(user, org_identifier)

            groups = extra_data.get('groups', [])
            self._assign_groups(user, groups)

            user.is_staff = any(
                g in ['staff', 'sme', 'admin'] for g in groups
            )
            user.save(update_fields=['is_staff'])

    def populate_user(self, request, sociallogin, data):
        """Map Casdoor claims to Django User fields."""
        user = super().populate_user(request, sociallogin, data)

        email = data.get('email', '')
        user.username = email
        user.email = email

        user.first_name = data.get('given_name', '')
        user.last_name = data.get('family_name', '')

        # Fallback: parse full 'name' claim
        if not user.first_name and not user.last_name:
            full_name = data.get('name', '')
            if full_name:
                parts = full_name.split(' ', 1)
                user.first_name = parts[0]
                user.last_name = parts[1] if len(parts) > 1 else ''

        # Security: SSO users are never superusers
        user.is_superuser = False

        # Set is_staff from group membership
        groups = data.get('groups', [])
        user.is_staff = any(g in ['staff', 'sme', 'admin'] for g in groups)

        return user

    def save_user(self, request, sociallogin, form=None):
        """Save user and handle organization + group mapping."""
        user = super().save_user(request, sociallogin, form)
        extra_data = sociallogin.account.extra_data

        org_identifier = extra_data.get('organization', '')
        if org_identifier:
            self._assign_organization(user, org_identifier)

        groups = extra_data.get('groups', [])
        self._assign_groups(user, groups)
        return user

    def _assign_organization(self, user, org_identifier):
        """Assign (or create) organization from the OIDC claim."""
        # Domain-specific — see Extension Examples below
        raise NotImplementedError("Override per project")

    def _assign_groups(self, user, group_names):
        """Map Casdoor groups to Django groups."""
        group_mapping = {
            'view_only': 'View Only',
            'staff': 'Staff',
            'sme': 'SME',
            'admin': 'Admin',
        }
        user.groups.clear()
        for casdoor_group in group_names:
            django_group_name = group_mapping.get(casdoor_group.lower())
            if django_group_name:
                group, _ = Group.objects.get_or_create(name=django_group_name)
                user.groups.add(group)
                logger.info(f"Added {user.username} to group {django_group_name}")
```

---

## Local Account Adapter

Prevents local registration and logs authentication failures:

```python
from allauth.account.adapter import DefaultAccountAdapter
import logging

logger = logging.getLogger(__name__)


class LocalAccountAdapter(DefaultAccountAdapter):

    def is_open_for_signup(self, request):
        """Disable local signup — all users come via SSO or admin."""
        return False

    def authentication_failed(self, request, **kwargs):
        """Log failures for security monitoring."""
        logger.warning(
            f"Local authentication failed from {request.META.get('REMOTE_ADDR')}"
        )
        super().authentication_failed(request, **kwargs)
```

---

## OIDC Provider Configuration

Register Casdoor as an OpenID Connect provider in `settings.py`:

```python
SOCIALACCOUNT_PROVIDERS = {
    'openid_connect': {
        'APPS': [
            {
                'provider_id': 'casdoor',
                'name': 'Casdoor SSO',
                'client_id': CASDOOR_CLIENT_ID,
                'secret': CASDOOR_CLIENT_SECRET,
                'settings': {
                    'server_url': f'{CASDOOR_ORIGIN}/.well-known/openid-configuration',
                },
            }
        ],
        'OAUTH_PKCE_ENABLED': True,
    }
}
```

---

## Management Command — Group Creation

An idempotent management command ensures groups and permissions exist:

```python
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission


class Command(BaseCommand):
    help = 'Create Django groups for Casdoor SSO integration'

    def handle(self, *args, **options):
        groups_config = {
            'View Only': {'permissions': ['view']},
            'Staff':     {'permissions': ['view', 'add', 'change']},
            'SME':       {'permissions': ['view', 'add', 'change']},
            'Admin':     {'permissions': ['view', 'add', 'change', 'delete']},
        }

        # Add your domain-specific model names here
        models_to_permission = [
            'vendor', 'document', 'rfp', 'rfpquestion',
        ]

        for group_name, config in groups_config.items():
            group, created = Group.objects.get_or_create(name=group_name)
            status = 'Created' if created else 'Exists'
            self.stdout.write(f'{status}: {group_name}')

            for perm_prefix in config['permissions']:
                for model in models_to_permission:
                    try:
                        perm = Permission.objects.get(
                            codename=f'{perm_prefix}_{model}'
                        )
                        group.permissions.add(perm)
                    except Permission.DoesNotExist:
                        pass

        self.stdout.write(self.style.SUCCESS('SSO groups created successfully'))
```

---

## Login Template

The login template shows an SSO button when Casdoor is enabled and conditionally reveals the local login form:

```html
{% load socialaccount %}

<!-- SSO Login Button (POST form for CSRF protection) -->
{% if CASDOOR_ENABLED %}
<form method="post" action="{% provider_login_url 'casdoor' %}">
    {% csrf_token %}
    <button type="submit">Sign in with SSO</button>
</form>
{% endif %}

<!-- Local Login Form (conditional) -->
{% if ALLOW_LOCAL_LOGIN or user.is_superuser %}
<form method="post" action="{% url 'account_login' %}">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Sign In Locally</button>
</form>
{% endif %}
```

> **Why POST?** Using a `<a href>` GET link to initiate the OAuth flow skips CSRF
> validation. Allauth's `{% provider_login_url %}` is designed for use inside a
> `<form method="post">` so the CSRF token is verified before the redirect.

---

## Context Processor

Exposes SSO settings to every template:

```python
from django.conf import settings


def user_preferences(request):
    context = {}

    # Always expose SSO flags for the login page
    context['CASDOOR_ENABLED'] = getattr(settings, 'CASDOOR_ENABLED', False)
    context['ALLOW_LOCAL_LOGIN'] = getattr(settings, 'ALLOW_LOCAL_LOGIN', False)

    return context
```

Register in `settings.py`:

```python
TEMPLATES = [{
    'OPTIONS': {
        'context_processors': [
            # ... standard processors ...
            '<app>.context_processors.user_preferences',
        ],
    },
}]
```

---

## SSL Bypass (Development Only)

For sandbox environments with self-signed certificates, an optional SSL patch disables verification at the `requests` library level:

```python
import os, logging
logger = logging.getLogger(__name__)


def apply_ssl_bypass():
    ssl_verify = os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower()
    if ssl_verify != 'false':
        return

    logger.warning("SSL verification DISABLED — sandbox only")

    import urllib3
    from requests.adapters import HTTPAdapter
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    _original_send = HTTPAdapter.send

    def _patched_send(self, request, stream=False, timeout=None,
                      verify=True, cert=None, proxies=None):
        return _original_send(self, request, stream=stream,
                              timeout=timeout, verify=False,
                              cert=cert, proxies=proxies)

    HTTPAdapter.send = _patched_send

apply_ssl_bypass()
```

Load it at the top of `settings.py` **before** any library imports that make HTTP calls:

```python
_ssl_verify = os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower()
if _ssl_verify == 'false':
    import <app>.ssl_patch  # noqa: F401
```

---

## Logout Flow

By default, Django's `account_logout` destroys the local session but does **not** terminate the upstream Casdoor session. The user remains logged in at the IdP and will be silently re-authenticated on next visit.

### Options

| Strategy | Behaviour | Implementation |
|----------|-----------|----------------|
| **Local-only logout** (default) | Destroys Django session; IdP session survives | No extra work |
| **IdP redirect logout** | Redirects to Casdoor's `/api/logout` after local logout | Override `ACCOUNT_LOGOUT_REDIRECT_URL` to point at Casdoor |
| **OIDC back-channel logout** | Casdoor notifies Django to invalidate sessions | Requires Casdoor back-channel support + a Django webhook endpoint |

### Recommended: IdP redirect logout

```python
# settings.py
ACCOUNT_LOGOUT_REDIRECT_URL = (
    f'{CASDOOR_ORIGIN}/api/logout'
    f'?post_logout_redirect_uri=https://your-app.example.com/'
)
```

This ensures the Casdoor session cookie is cleared before the user returns to your app.

---

## Domain Extension Examples

### Spelunker (RFP Tool)

Spelunker's adapter creates organizations on first encounter and links them to user profiles:

```python
def _assign_organization(self, user, org_identifier):
    from django.db import models
    from django.utils.text import slugify
    from core.models import Organization

    try:
        org = Organization.objects.filter(
            models.Q(slug=org_identifier) | models.Q(name=org_identifier)
        ).first()

        if not org:
            org = Organization.objects.create(
                name=org_identifier,
                slug=slugify(org_identifier),
                type='for-profit',
                legal_country='CA',
                status='active',
            )
            logger.info(f"Created organization: {org.name}")

        if hasattr(user, 'profile'):
            logger.info(f"Assigned {user.username} → {org.name}")

    except Exception as e:
        logger.error(f"Organization assignment error: {e}")
```

### Multi-Tenant SaaS App

A multi-tenant app might restrict users to a single tenant and enforce tenant isolation:

```python
def _assign_organization(self, user, org_identifier):
    from tenants.models import Tenant

    tenant = Tenant.objects.filter(external_id=org_identifier).first()
    if not tenant:
        raise ValueError(f"Unknown tenant: {org_identifier}")

    user.tenant = tenant
    user.save(update_fields=['tenant'])
```

---

## Anti-Patterns

- ❌ Don't allow SSO to grant `is_superuser` — always force `is_superuser = False` in `populate_user`
- ❌ Don't *log-and-continue* for superuser SSO attempts — raise `ImmediateHttpResponse` to actually block the login
- ❌ Don't disable local login for superusers — they need emergency access when SSO is unavailable
- ❌ Don't rely on SSO username claims — use email as the canonical identifier
- ❌ Don't hard-code the OIDC provider URL — always read from environment variables
- ❌ Don't skip the management command — groups and permissions must be idempotent and repeatable
- ❌ Don't use `CASDOOR_SSL_VERIFY=false` in production — only for sandbox environments with self-signed certificates
- ❌ Don't forget PKCE — always set `OAUTH_PKCE_ENABLED: True` for Authorization Code flow
- ❌ Don't sync groups only on first login — re-sync in `pre_social_login` so IdP changes take effect immediately
- ❌ Don't use a GET link (`<a href>`) to start the OAuth flow — use a POST form so CSRF protection applies
- ❌ Don't assume Django logout kills the IdP session — configure an IdP redirect or back-channel logout
- ❌ Don't leave the local login endpoint unprotected — add rate limiting (e.g. `django-axes`) to prevent brute-force attacks

---

## Settings

All Django settings this pattern recognizes:

```python
# settings.py

# --- SSO Provider ---
CASDOOR_ENABLED = env.bool('CASDOOR_ENABLED')           # Master SSO toggle
CASDOOR_ORIGIN = env('CASDOOR_ORIGIN')                  # OIDC discovery base URL
CASDOOR_ORIGIN_FRONTEND = env('CASDOOR_ORIGIN_FRONTEND') # Frontend URL (may differ)
CASDOOR_CLIENT_ID = env('CASDOOR_CLIENT_ID')            # OAuth client ID
CASDOOR_CLIENT_SECRET = env('CASDOOR_CLIENT_SECRET')    # OAuth client secret
CASDOOR_ORG_NAME = env('CASDOOR_ORG_NAME')              # Default organization
CASDOOR_SSL_VERIFY = env('CASDOOR_SSL_VERIFY')          # true | false | /path/to/ca.pem

# --- Login Behavior ---
ALLOW_LOCAL_LOGIN = env.bool('ALLOW_LOCAL_LOGIN', default=False)  # Show local form

# --- Allauth ---
SOCIALACCOUNT_ADAPTER = '<app>.adapters.CasdoorAccountAdapter'
ACCOUNT_ADAPTER = '<app>.adapters.LocalAccountAdapter'
```

---

## Testing

Standard test cases every implementation should cover:

```python
from django.test import TestCase, override_settings
from unittest.mock import MagicMock
from django.contrib.auth.models import User, Group
from <app>.adapters import CasdoorAccountAdapter, LocalAccountAdapter


class CasdoorAdapterTest(TestCase):

    def setUp(self):
        self.adapter = CasdoorAccountAdapter()

    def test_signup_always_open(self):
        """SSO signup must always be permitted."""
        self.assertTrue(self.adapter.is_open_for_signup(MagicMock(), MagicMock()))

    def test_superuser_never_set_via_sso(self):
        """populate_user must force is_superuser=False."""
        sociallogin = MagicMock()
        data = {'email': 'admin@example.com', 'groups': ['admin']}
        user = self.adapter.populate_user(MagicMock(), sociallogin, data)
        self.assertFalse(user.is_superuser)

    def test_email_used_as_username(self):
        """Username must be the full email address."""
        sociallogin = MagicMock()
        data = {'email': 'jane@example.com'}
        user = self.adapter.populate_user(MagicMock(), sociallogin, data)
        self.assertEqual(user.username, 'jane@example.com')

    def test_staff_flag_from_groups(self):
        """is_staff must be True when user belongs to staff/sme/admin."""
        sociallogin = MagicMock()
        for group in ['staff', 'sme', 'admin']:
            data = {'email': 'user@example.com', 'groups': [group]}
            user = self.adapter.populate_user(MagicMock(), sociallogin, data)
            self.assertTrue(user.is_staff, f"is_staff should be True for group '{group}'")

    def test_name_fallback_parsing(self):
        """When given_name/family_name absent, parse 'name' claim."""
        sociallogin = MagicMock()
        data = {'email': 'user@example.com', 'name': 'Jane Doe'}
        user = self.adapter.populate_user(MagicMock(), sociallogin, data)
        self.assertEqual(user.first_name, 'Jane')
        self.assertEqual(user.last_name, 'Doe')

    def test_group_mapping(self):
        """Casdoor groups must map to correctly named Django groups."""
        Group.objects.create(name='View Only')
        Group.objects.create(name='Staff')
        user = User.objects.create_user('test@example.com', 'test@example.com')
        self.adapter._assign_groups(user, ['view_only', 'staff'])
        group_names = set(user.groups.values_list('name', flat=True))
        self.assertEqual(group_names, {'View Only', 'Staff'})

    def test_superuser_sso_login_blocked(self):
        """pre_social_login must raise ImmediateHttpResponse for superusers."""
        from allauth.exceptions import ImmediateHttpResponse
        user = User.objects.create_superuser(
            'admin@example.com', 'admin@example.com', 'pass'
        )
        sociallogin = MagicMock()
        sociallogin.user = user
        sociallogin.user.id = user.id
        with self.assertRaises(ImmediateHttpResponse):
            self.adapter.pre_social_login(MagicMock(), sociallogin)

    def test_groups_resync_on_returning_login(self):
        """pre_social_login must re-sync groups for existing users."""
        Group.objects.create(name='Admin')
        Group.objects.create(name='Staff')
        user = User.objects.create_user('user@example.com', 'user@example.com')
        user.groups.add(Group.objects.get(name='Staff'))

        sociallogin = MagicMock()
        sociallogin.user = user
        sociallogin.user.id = user.id
        sociallogin.account.extra_data = {
            'groups': ['admin'],
            'organization': '',
        }
        self.adapter.pre_social_login(MagicMock(), sociallogin)
        group_names = set(user.groups.values_list('name', flat=True))
        self.assertEqual(group_names, {'Admin'})


class LocalAdapterTest(TestCase):

    def test_local_signup_disabled(self):
        """Local signup must always be disabled."""
        adapter = LocalAccountAdapter()
        self.assertFalse(adapter.is_open_for_signup(MagicMock()))
```
