# Django-Allauth Integration Guide for ThrillWiki

This guide documents how to properly integrate django-allauth for authentication in ThrillWiki, covering JWT tokens, password authentication, MFA (TOTP/WebAuthn), and social OAuth (Google/Discord).

---

## Table of Contents

1. [Installation & Setup](#installation--setup)
2. [JWT Token Authentication](#jwt-token-authentication)
3. [Password Authentication](#password-authentication)
4. [MFA: TOTP (Authenticator App)](#mfa-totp-authenticator-app)
5. [MFA: WebAuthn/Passkeys](#mfa-webauthnpasskeys)
6. [Social OAuth: Google](#social-oauth-google)
7. [Social OAuth: Discord](#social-oauth-discord)
8. [API Patterns & DRF Integration](#api-patterns--drf-integration)
9. [Internal API Reference](#internal-api-reference)

---

## Installation & Setup

### Required Packages

```bash
# Add packages to pyproject.toml
uv add "django-allauth[headless,mfa,socialaccount]"
uv add fido2  # For WebAuthn support

# Install all dependencies
uv sync
```

### Running Django Commands

```bash
# Run migrations
uv run manage.py migrate

# Create superuser
uv run manage.py createsuperuser

# Run development server
uv run manage.py runserver

# Collect static files
uv run manage.py collectstatic
```

### INSTALLED_APPS Configuration

```python
# config/django/base.py
INSTALLED_APPS = [
    # Django built-in
    "django.contrib.auth",
    "django.contrib.sites",
    
    # Allauth core (required)
    "allauth",
    "allauth.account",
    
    # Optional modules
    "allauth.headless",              # For headless/API mode
    "allauth.mfa",                   # MFA support (TOTP, recovery codes)
    "allauth.mfa.webauthn",          # WebAuthn/Passkey support
    "allauth.socialaccount",         # Social auth base
    "allauth.socialaccount.providers.google",
    "allauth.socialaccount.providers.discord",
]
```

### Middleware

```python
MIDDLEWARE = [
    # ... other middleware
    "allauth.account.middleware.AccountMiddleware",
]
```

### URL Configuration

```python
# urls.py
urlpatterns = [
    # Allauth browser views (needed for OAuth callbacks)
    path("accounts/", include("allauth.urls")),
    
    # Allauth headless API endpoints
    path("_allauth/", include("allauth.headless.urls")),
]
```

---

## JWT Token Authentication

### Configuration

```python
# settings.py

# Token strategy - use JWT
HEADLESS_TOKEN_STRATEGY = "allauth.headless.tokens.JWTTokenStrategy"

# Generate private key: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
HEADLESS_JWT_PRIVATE_KEY = """
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...
-----END PRIVATE KEY-----
"""

# Token lifetimes
HEADLESS_JWT_ACCESS_TOKEN_EXPIRES_IN = 300        # 5 minutes
HEADLESS_JWT_REFRESH_TOKEN_EXPIRES_IN = 86400     # 24 hours

# Authorization header scheme
HEADLESS_JWT_AUTHORIZATION_HEADER_SCHEME = "Bearer"

# Stateful validation (invalidates tokens on logout)
HEADLESS_JWT_STATEFUL_VALIDATION_ENABLED = True

# Rotate refresh tokens on use
HEADLESS_JWT_ROTATE_REFRESH_TOKEN = True
```

### DRF Integration

```python
from allauth.headless.contrib.rest_framework.authentication import JWTTokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView

class ProtectedAPIView(APIView):
    authentication_classes = [JWTTokenAuthentication]
    permission_classes = [IsAuthenticated]
    
    def get(self, request):
        return Response({"user": request.user.email})
```

### JWT Flow

1. User authenticates (password/social/passkey)
2. During auth, pass `X-Session-Token` header to allauth API
3. Upon successful authentication, response `meta` contains:
   ```json
   {
     "meta": {
       "access_token": "eyJ...",
       "refresh_token": "abc123..."
     }
   }
   ```
4. Use `Authorization: Bearer <access_token>` for subsequent requests
5. Refresh tokens via `POST /_allauth/browser/v1/auth/token/refresh`

---

## Password Authentication

### Configuration

```python
# config/settings/third_party.py

# Signup fields (* = required)
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]

# Login methods
ACCOUNT_LOGIN_METHODS = {"email", "username"}  # Allow both

# Email verification
ACCOUNT_EMAIL_VERIFICATION = "mandatory"  # Options: "mandatory", "optional", "none"
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_RESEND = True

# Security
ACCOUNT_REAUTHENTICATION_REQUIRED = True  # Require re-auth for sensitive operations
ACCOUNT_EMAIL_NOTIFICATIONS = True
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False  # Don't reveal if email exists

# Redirects
LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/"

# Custom adapters
ACCOUNT_ADAPTER = "apps.accounts.adapters.CustomAccountAdapter"
```

### Headless API Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/_allauth/browser/v1/auth/login` | POST | Login with email/username + password |
| `/_allauth/browser/v1/auth/signup` | POST | Register new account |
| `/_allauth/browser/v1/auth/logout` | POST | Logout (invalidate tokens) |
| `/_allauth/browser/v1/auth/password/reset` | POST | Request password reset |
| `/_allauth/browser/v1/auth/password/reset/key` | POST | Complete password reset |
| `/_allauth/browser/v1/auth/password/change` | POST | Change password (authenticated) |

### Magic Link (Login by Code)

```python
# Enable magic link authentication
ACCOUNT_LOGIN_BY_CODE_ENABLED = True
ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = 3
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 300  # 5 minutes
```

---

## MFA: TOTP (Authenticator App)

### Configuration

```python
# config/settings/third_party.py

# Enable TOTP in supported types
MFA_SUPPORTED_TYPES = ["totp", "webauthn"]

# TOTP settings
MFA_TOTP_ISSUER = "ThrillWiki"  # Shows in authenticator app
MFA_TOTP_DIGITS = 6             # Code length
MFA_TOTP_PERIOD = 30            # Seconds per code
```

### TOTP API Flow

1. **Get TOTP Secret** (for QR code):
   ```
   GET /_allauth/browser/v1/account/authenticators/totp
   ```
   Response contains `totp_url` for QR code generation.

2. **Activate TOTP**:
   ```
   POST /_allauth/browser/v1/account/authenticators/totp
   {
     "code": "123456"
   }
   ```

3. **Deactivate TOTP**:
   ```
   DELETE /_allauth/browser/v1/account/authenticators/totp
   ```

4. **MFA Login Flow**:
   - After password auth, if MFA enabled, receive `401` with `mfa_required`
   - Submit TOTP code:
     ```
     POST /_allauth/browser/v1/auth/2fa/authenticate
     {
       "code": "123456"
     }
     ```

---

## MFA: WebAuthn/Passkeys

### Configuration

```python
# config/settings/third_party.py

# Include webauthn in supported types
MFA_SUPPORTED_TYPES = ["totp", "webauthn"]

# Enable passkey-only login
MFA_PASSKEY_LOGIN_ENABLED = True

# Allow insecure origin for localhost development
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True  # Only for DEBUG=True
```

### Internal WebAuthn API Functions

The `allauth.mfa.webauthn.internal.auth` module provides these functions:

```python
from allauth.mfa.webauthn.internal import auth as webauthn_auth

# Registration Flow
def begin_registration(user, passwordless: bool) -> Dict:
    """
    Start passkey registration.
    
    Args:
        user: The Django user object
        passwordless: True for passkey login, False for MFA-only
        
    Returns:
        Dict with WebAuthn creation options (challenge, rp, user, etc.)
    
    Note: State is stored internally via set_state()
    """

def complete_registration(credential: Dict) -> AuthenticatorData:
    """
    Complete passkey registration.
    
    Args:
        credential: The parsed credential response from browser
        
    Returns:
        AuthenticatorData (binding) - NOT an Authenticator model
    
    Note: You must create the Authenticator record yourself!
    """

# Authentication Flow
def begin_authentication(user=None) -> Dict:
    """
    Start passkey authentication.
    
    Args:
        user: Optional user (for MFA). None for passwordless login.
        
    Returns:
        Dict with WebAuthn request options
        
    Note: State is stored internally via set_state()
    """

def complete_authentication(user, response: Dict) -> Authenticator:
    """
    Complete passkey authentication.
    
    Args:
        user: The Django user object
        response: The credential response from browser
        
    Returns:
        The matching Authenticator model instance
    """

# State Management (internal, use context)
def get_state() -> Optional[Dict]:
    """Get stored WebAuthn state from session."""

def set_state(state: Dict) -> None:
    """Store WebAuthn state in session."""

def clear_state() -> None:
    """Clear WebAuthn state from session."""

# Helper functions
def parse_registration_response(response: Any) -> RegistrationResponse:
    """Parse browser registration response."""

def parse_authentication_response(response: Any) -> AuthenticationResponse:
    """Parse browser authentication response."""
```

### Custom Passkey API Implementation

```python
# apps/api/v1/auth/passkey.py

from allauth.mfa.webauthn.internal import auth as webauthn_auth
from allauth.mfa.models import Authenticator

@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_registration_options(request):
    """Get WebAuthn registration options."""
    # passwordless=False for MFA passkeys, True for passwordless login
    creation_options = webauthn_auth.begin_registration(
        request.user, 
        passwordless=False
    )
    # State is stored internally
    return Response({"options": creation_options})

@api_view(["POST"])
@permission_classes([IsAuthenticated])
def register_passkey(request):
    """Complete passkey registration."""
    credential = request.data.get("credential")
    name = request.data.get("name", "Passkey")
    
    # Check for pending registration
    state = webauthn_auth.get_state()
    if not state:
        return Response({"error": "No pending registration"}, status=400)
    
    # Parse and complete registration
    credential_data = webauthn_auth.parse_registration_response(credential)
    authenticator_data = webauthn_auth.complete_registration(credential_data)
    
    # Create Authenticator record manually
    authenticator = Authenticator.objects.create(
        user=request.user,
        type=Authenticator.Type.WEBAUTHN,
        data={"name": name},
    )
    
    return Response({"id": str(authenticator.id), "name": name})

@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_authentication_options(request):
    """Get WebAuthn authentication options."""
    request_options = webauthn_auth.begin_authentication(request.user)
    return Response({"options": request_options})

@api_view(["POST"])
@permission_classes([IsAuthenticated])
def authenticate_passkey(request):
    """Verify passkey authentication."""
    credential = request.data.get("credential")
    
    state = webauthn_auth.get_state()
    if not state:
        return Response({"error": "No pending authentication"}, status=400)
    
    # Complete authentication (handles state internally)
    webauthn_auth.complete_authentication(request.user, credential)
    
    return Response({"success": True})
```

---

## Social OAuth: Google

### Google Cloud Console Setup

1. Go to [Google Cloud Console](https://console.developers.google.com/)
2. Create/select project → APIs & Services → Credentials
3. Create OAuth 2.0 Client ID (Web application)
4. Set Authorized JavaScript origins:
   - `http://localhost:3000` (development)
   - `https://thrillwiki.com` (production)
5. Set Authorized redirect URIs:
   - `http://localhost:8000/accounts/google/login/callback/`
   - `https://api.thrillwiki.com/accounts/google/login/callback/`
6. Note the Client ID and Client Secret

### Django Configuration

```python
# config/settings/third_party.py

SOCIALACCOUNT_PROVIDERS = {
    "google": {
        "SCOPE": ["profile", "email"],
        "AUTH_PARAMS": {"access_type": "online"},  # Use "offline" for refresh tokens
        "OAUTH_PKCE_ENABLED": True,
        # "FETCH_USERINFO": True,  # If you need avatar_url for private profiles
    },
}
```

### Admin Setup

1. Go to `/admin/socialaccount/socialapp/`
2. Add new Social Application:
   - Provider: Google
   - Name: Google
   - Client ID: (from Google Console)
   - Secret key: (from Google Console)
   - Sites: Select your site

---

## Social OAuth: Discord

### Discord Developer Portal Setup

1. Go to [Discord Developer Portal](https://discordapp.com/developers/applications/me)
2. Create New Application
3. Go to OAuth2 → General
4. Add Redirect URIs:
   - `http://127.0.0.1:8000/accounts/discord/login/callback/`
   - `https://api.thrillwiki.com/accounts/discord/login/callback/`
5. Note Client ID and Client Secret

### Django Configuration

```python
# config/settings/third_party.py

SOCIALACCOUNT_PROVIDERS = {
    "discord": {
        "SCOPE": ["identify", "email"],  # "identify" is required
        "OAUTH_PKCE_ENABLED": True,
    },
}
```

### Admin Setup

1. Go to `/admin/socialaccount/socialapp/`
2. Add new Social Application:
   - Provider: Discord
   - Name: Discord
   - Client ID: (from Discord Portal)
   - Secret key: (from Discord Portal)
   - Sites: Select your site

---

## API Patterns & DRF Integration

### Authentication Classes

```python
from allauth.headless.contrib.rest_framework.authentication import (
    JWTTokenAuthentication,
    SessionTokenAuthentication,
)

# For JWT-based authentication
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "allauth.headless.contrib.rest_framework.authentication.JWTTokenAuthentication",
    ],
}
```

### Headless Frontend URLs

```python
# Required for email verification, password reset links
HEADLESS_FRONTEND_URLS = {
    "account_confirm_email": "https://thrillwiki.com/account/verify-email/{key}",
    "account_reset_password_from_key": "https://thrillwiki.com/account/password/reset/{key}",
    "account_signup": "https://thrillwiki.com/signup",
}
```

### Custom Adapters

```python
# apps/accounts/adapters.py
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter

class CustomAccountAdapter(DefaultAccountAdapter):
    def save_user(self, request, user, form, commit=True):
        """Customize user creation."""
        user = super().save_user(request, user, form, commit=False)
        # Custom logic here
        if commit:
            user.save()
        return user
    
    def is_open_for_signup(self, request):
        """Control signup availability."""
        return True

class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
    def pre_social_login(self, request, sociallogin):
        """Hook before social login completes."""
        # Link social account to existing user by email
        if sociallogin.is_existing:
            return
        
        email = sociallogin.account.extra_data.get("email")
        if email:
            User = get_user_model()
            try:
                user = User.objects.get(email=email)
                sociallogin.connect(request, user)
            except User.DoesNotExist:
                pass
```

---

## Internal API Reference

### Authenticator Model

```python
from allauth.mfa.models import Authenticator

# Types
Authenticator.Type.TOTP      # TOTP authenticator
Authenticator.Type.WEBAUTHN  # WebAuthn/Passkey
Authenticator.Type.RECOVERY_CODES  # Recovery codes

# Query user's authenticators
passkeys = Authenticator.objects.filter(
    user=user,
    type=Authenticator.Type.WEBAUTHN
)

# Check if MFA is enabled
from allauth.mfa.adapter import get_adapter
is_mfa_enabled = get_adapter().is_mfa_enabled(user)
```

### Session Token Header

For headless mode during authentication flow:
```
X-Session-Token: <session-token>
```

After authentication completes with JWT enabled:
```
Authorization: Bearer <access-token>
```

## Current ThrillWiki Implementation Summary

ThrillWiki uses a hybrid authentication system with django-allauth for MFA and social auth, and SimpleJWT for API tokens.

### Backend Configuration

| Feature | Status | Notes |
|---------|--------|-------|
| Password Auth | ✅ Active | Email + username login |
| Email Verification | ✅ Mandatory | With resend support |
| TOTP MFA | ✅ Active | 6-digit codes, 30s period |
| WebAuthn/Passkeys | ✅ Active | Passkey login enabled, counts as MFA |
| Google OAuth | ✅ Configured | Requires admin SocialApp setup |
| Discord OAuth | ✅ Configured | Requires admin SocialApp setup |
| Magic Link | ✅ Configured | 5-minute timeout |
| JWT Tokens | ✅ SimpleJWT | 15min access, 7 day refresh |

### Frontend MFA Integration (Updated 2026-01-10)

The frontend recognizes both TOTP and Passkeys as valid MFA factors:

```typescript
// authService.ts - getEnrolledFactors()
// Checks both Supabase TOTP AND Django passkeys
const mfaStatus = await djangoClient.rpc('get_mfa_status', {});
if (statusData.passkey_enabled && statusData.passkey_count > 0) {
  factors.push({ id: 'passkey', factor_type: 'webauthn', ... });
}
```

### Admin Panel MFA Requirements

Admins and moderators must have MFA enabled to access protected routes:

1. `useAdminGuard()` hook checks MFA enrollment via `useRequireMFA()`
2. `getEnrolledFactors()` queries Django's `get_mfa_status` endpoint
3. Backend returns `has_second_factor: true` if TOTP or Passkey is enabled
4. Users with only passkeys (no TOTP) now pass the MFA requirement

### API Endpoints Reference

#### Authentication
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/auth/login/` | POST | Password login |
| `/api/v1/auth/login/mfa-verify/` | POST | Complete MFA (TOTP or Passkey) |
| `/api/v1/auth/signup/` | POST | Register new account |
| `/api/v1/auth/logout/` | POST | Logout, blacklist tokens |
| `/api/v1/auth/token/refresh/` | POST | Refresh JWT access token |

#### MFA (TOTP)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/auth/mfa/status/` | GET | Get MFA status (TOTP + Passkey) |
| `/api/v1/auth/mfa/totp/setup/` | POST | Start TOTP enrollment |
| `/api/v1/auth/mfa/totp/activate/` | POST | Activate with 6-digit code |
| `/api/v1/auth/mfa/totp/deactivate/` | POST | Remove TOTP (requires password) |

#### Passkeys (WebAuthn)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/auth/passkey/status/` | GET | List registered passkeys |
| `/api/v1/auth/passkey/registration-options/` | GET | Get WebAuthn creation options |
| `/api/v1/auth/passkey/register/` | POST | Complete passkey registration |
| `/api/v1/auth/passkey/login-options/` | POST | Get auth options (uses mfa_token) |
| `/api/v1/auth/passkey/{id}/` | DELETE | Remove passkey |

#### Social Authentication
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/auth/social/providers/` | GET | List configured providers |
| `/api/v1/auth/social/connect/{provider}/` | POST | Start OAuth flow |
| `/api/v1/auth/social/disconnect/{provider}/` | POST | Unlink provider |

### Login Flow with MFA

```
1. POST /api/v1/auth/login/ {username, password}
   └── If MFA enabled: Returns {mfa_required: true, mfa_token, mfa_types: ["totp", "webauthn"]}

2a. TOTP Verification:
    POST /api/v1/auth/login/mfa-verify/ {mfa_token, code: "123456"}
    
2b. Passkey Verification:
    POST /api/v1/auth/passkey/login-options/ {mfa_token}  ← Get challenge
    Browser: navigator.credentials.get()                   ← User authenticates
    POST /api/v1/auth/login/mfa-verify/ {mfa_token, credential: {...}}

3. Returns: {access, refresh, user, message: "Login successful"}
```

### Frontend Components

| Component | Purpose |
|-----------|---------|
| `MFAChallenge.tsx` | TOTP code entry during login |
| `MFAEnrollmentRequired.tsx` | Prompts admin/mod to set up MFA |
| `MFAGuard.tsx` | Wraps routes requiring MFA |
| `useRequireMFA` | Hook checking MFA enrollment |
| `useAdminGuard` | Combines auth + role + MFA checks |

### Admin MFA Requirements

Moderators, admins, and superusers **must have MFA enrolled** to access admin pages.

The system uses **enrollment-based verification** rather than per-session AAL2 tokens:
- MFA verification happens at login time via the `mfa_token` flow
- Django-allauth doesn't embed AAL claims in JWT tokens
- The frontend checks if the user has TOTP or passkey enrolled
- Mid-session MFA step-up is not supported (user must re-login)

This means:
- `useRequireMFA` returns `hasMFA: true` if user has any factor enrolled
- `useAdminGuard` blocks access if `needsEnrollment` is true
- Users prompted to enroll MFA on their first admin page visit
