diff --git a/docs/Pattern_SSO-Allauth-Casdoor_V1-02.md b/docs/Pattern_SSO-Allauth-Casdoor_V1-02.md new file mode 100644 index 0000000..87b1f74 --- /dev/null +++ b/docs/Pattern_SSO-Allauth-Casdoor_V1-02.md @@ -0,0 +1,807 @@ +# SSO with Allauth & Casdoor Pattern v1.02 + +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 | `/adapters.py` | User provisioning, group mapping, superuser protection | +| Local account adapter | `/adapters.py` | Disable local signup, authentication logging | +| Management command | `/management/commands/create_sso_groups.py` | Idempotent group + permission creation | +| Login template | `templates/account/login.html` | SSO button + conditional local login form | +| SSO signup template | `templates/socialaccount/signup.html` | Email confirmation step for first-time SSO users | +| Context processor | `/context_processors.py` | Expose `CASDOOR_ENABLED` / `ALLOW_LOCAL_LOGIN` to templates | +| SSL patch (optional) | `/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', + '', +] + +# 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//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 = '.adapters.CasdoorAccountAdapter' +ACCOUNT_ADAPTER = '.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.core.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 %} + + +{% if CASDOOR_ENABLED %} +
+ {% csrf_token %} + +
+{% endif %} + + +{% if ALLOW_LOCAL_LOGIN or user.is_superuser %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endif %} +``` + +> **Why POST?** Using a `` GET link to initiate the OAuth flow skips CSRF +> validation. Allauth's `{% provider_login_url %}` is designed for use inside a +> `
` so the CSRF token is verified before the redirect. + +--- + +## SSO Signup Template + +When a new SSO user has no existing account, allauth redirects them to `accounts/3rdparty/signup/` to confirm their email before the account is created. Without a custom template this page renders with no styling. + +Create `templates/socialaccount/signup.html` extending the project base: + +```html +{% extends "/base.html" %} + +{% block title %}Complete Sign Up β€” {{ themis_app_name }}{% endblock %} + +{% block content %} +
+
+
+

Complete Sign Up

+

+ Confirm your email address to finish signing in with SSO. +

+ + {% if form.errors %} +
+ Please correct the errors below. +
+ {% endif %} + + + {% csrf_token %} + +
+ + + {% if form.email.errors %} + + {% endif %} +
+ +
+ +
+ + +
+
+
+{% endblock %} +``` + +Key context variables allauth provides to this template: + +| Variable | Description | +|----------|-------------| +| `form` | `SignupForm` with a single `email` field pre-populated from the OIDC claim | +| `action_url` | POST target (`/accounts/3rdparty/signup/`) β€” always use this, not a hard-coded path | +| `sociallogin` | The in-progress social login object (rarely needed in the template) | + +> **Why this page exists:** `SOCIALACCOUNT_AUTO_SIGNUP = True` skips it when the IdP provides a valid email. It only appears when allauth cannot confirm the email (e.g. the IdP omitted it or there is a conflict with an existing account). + +--- + +## 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 ... + '.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 .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 (`
`) 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 is NOT a Django setting β€” it is read directly from the +# environment at the top of settings.py (before any imports) to apply SSL +# bypass via ssl_patch.py or set REQUESTS_CA_BUNDLE for a custom CA. +# See the SSL Bypass section above for the correct implementation. + +# --- Login Behavior --- +ALLOW_LOCAL_LOGIN = env.bool('ALLOW_LOCAL_LOGIN', default=False) # Show local form + +# --- Allauth --- +SOCIALACCOUNT_ADAPTER = '.adapters.CasdoorAccountAdapter' +ACCOUNT_ADAPTER = '.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 .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.core.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())) +```