Allauth extracted to its own repository (~/dev/mizan-allauth)

The auth-provider concern becomes a dedicated Django system. Removed from
mizan: the Django integration (mizan/integrations/allauth — auth contexts
+ ~15 form wrappers), the legacy/ pre-kernel TypeScript client, the
allauth and webauthn dependency extras (fido2 was consumed only by the
WebAuthn form wrappers), and the HEADLESS_JWT_* settings fallbacks — the
allauth-headless compat seam belongs to the dedicated system, not to
mizan's JWT module. Duplicate-name registration in discovery now surfaces
a warning instead of passing silently. README claims updated to point at
mizan-allauth; the root README's hand-maintained status matrix collapsed
into the tests/afi conformance suite as the parity authority.
OWED_SURFACE.md refreshed against the post-extraction tree (22 units).

mizan-django suite: 350 passed, 21 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 17:43:16 -04:00
parent 587be8c4ab
commit 81ea0cea9f
59 changed files with 515 additions and 6038 deletions

View File

@@ -7,8 +7,6 @@ function. Typed React client generated. Invalidation automatic.
```bash
uv add "mizan[channels]"
# or with allauth integration:
uv add "mizan[channels,allauth]"
```
## Setup
@@ -116,6 +114,9 @@ class ContactForm(mizanFormMixin, forms.Form):
Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Frontend
gets `useContactForm()`.
Auth-provider forms (django-allauth login, signup, MFA, WebAuthn) live in the
dedicated `mizan-allauth` repository, built on this mixin.
## Channels
WebSocket-native RPC via a flag flip:

View File

@@ -25,12 +25,6 @@ channels = [
"channels>=4.0",
"channels-redis>=4.0",
]
allauth = [
"django-allauth>=65.0",
]
webauthn = [
"fido2>=2.0",
]
shapes = [
"django-readers>=2.0",
]

View File

@@ -1,25 +0,0 @@
"""
mizan Allauth Integration
Backend support for django-allauth with mizan server functions.
Provides:
- Auth contexts (auth_status, user) - required by frontend allauth module
- Allauth form wrappers - expose allauth forms as server functions
Usage:
# In your app's apps.py
class MyAppConfig(AppConfig):
def ready(self):
import mizan.allauth.forms # noqa - registers forms
import mizan.allauth.contexts # noqa - registers contexts
"""
from .contexts import auth_status, user, AuthStatusOutput, UserOutput
__all__ = [
"auth_status",
"user",
"AuthStatusOutput",
"UserOutput",
]

View File

@@ -1,118 +0,0 @@
"""
Auth contexts for mizan Allauth integration.
These are the core auth primitives that the frontend allauth module depends on.
Separated into two concerns:
- auth_status: Authentication state and permission guards (fast, no DB hit with JWT)
- user: Full user profile data (may require DB query for JWT auth)
Both are registered as global contexts for SSR hydration.
"""
from django.http import HttpRequest
from pydantic import BaseModel
from mizan.client import client
# =============================================================================
# Auth Status Context
# =============================================================================
class AuthStatusOutput(BaseModel):
"""Authentication status and permission guards."""
is_authenticated: bool
user_id: int | None = None
is_staff: bool = False
is_superuser: bool = False
@client(context="global")
def auth_status(request: HttpRequest) -> AuthStatusOutput:
"""
Auth status context - provides authentication state and guards.
This works identically for both session and JWT auth. The data comes
from the request.user object (either full User or JWTUser with claims).
Frontend:
const auth = useAuthStatus()
if (auth.is_authenticated) { ... }
if (auth.is_staff) { ... }
"""
user = request.user
if not user.is_authenticated:
return AuthStatusOutput(is_authenticated=False)
return AuthStatusOutput(
is_authenticated=True,
user_id=user.id,
is_staff=user.is_staff,
is_superuser=user.is_superuser,
)
# =============================================================================
# User Profile Context
# =============================================================================
class UserOutput(BaseModel):
"""Full user profile data."""
id: int
email: str
first_name: str = ""
last_name: str = ""
@client(context="global")
def user(request: HttpRequest) -> UserOutput | None:
"""
User profile context - provides full user data.
Unlike auth_status, this may require a DB query (for JWT auth where
the user object is a minimal JWTUser with only claims).
Returns None if not authenticated.
Frontend:
const user = useUser()
if (user) {
console.log(user.email)
}
"""
req_user = request.user
if not req_user.is_authenticated:
return None
# Check if we have full user data or just JWT claims
if hasattr(req_user, "email") and req_user.email:
# Full User object (session auth)
return UserOutput(
id=req_user.id,
email=req_user.email,
first_name=getattr(req_user, "first_name", "") or "",
last_name=getattr(req_user, "last_name", "") or "",
)
# JWTUser - need to fetch from DB
from django.contrib.auth import get_user_model
User = get_user_model()
try:
db_user = User.objects.get(pk=req_user.id)
return UserOutput(
id=db_user.id,
email=db_user.email,
first_name=db_user.first_name or "",
last_name=db_user.last_name or "",
)
except User.DoesNotExist:
return None

View File

@@ -1,408 +0,0 @@
"""
Allauth forms as mizan server functions.
This module wraps allauth forms with mizanFormMixin, exposing them as
typed server functions for the React frontend.
Each form becomes three server functions:
- {name}.schema - Get form field definitions
- {name}.validate - Validate form data
- {name}.submit - Submit form
Import this module in your app's ready() to register the forms:
class MyAppConfig(AppConfig):
def ready(self):
import mizan.allauth.forms # noqa
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from django.http import HttpRequest
from mizan.forms import mizanFormMixin, mizanFormMeta
# Account forms
from allauth.account.forms import (
AddEmailForm,
ChangePasswordForm,
ConfirmLoginCodeForm,
LoginForm,
RequestLoginCodeForm,
ResetPasswordForm,
ResetPasswordKeyForm,
SetPasswordForm,
SignupForm,
UserTokenForm,
)
# Password reauthentication form - conditionally import
try:
from allauth.account.forms import ReauthenticateForm
HAS_REAUTH = True
except ImportError:
HAS_REAUTH = False
# MFA forms - conditionally import
try:
from allauth.mfa.base.forms import AuthenticateForm as MFAAuthenticateForm
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
HAS_MFA = True
except ImportError:
HAS_MFA = False
# WebAuthn forms (if available)
try:
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
HAS_WEBAUTHN = True
except ImportError:
HAS_WEBAUTHN = False
if TYPE_CHECKING:
from mizan.forms.schemas import FormValidation
# =============================================================================
# Account Forms
# =============================================================================
class mizanLoginForm(LoginForm, mizanFormMixin):
"""Sign in with email and password."""
mizan = mizanFormMeta(
name="login",
title="Sign In",
subtitle="Welcome back. Enter your credentials to continue.",
submit_label="Sign In",
live_validation=False, # Don't validate credentials as user types
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.login(request)
return None
class mizanSignupForm(SignupForm, mizanFormMixin):
"""Create a new account."""
mizan = mizanFormMeta(
name="signup",
title="Create Account",
subtitle="Enter your details to get started.",
submit_label="Create Account",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save(request)
return None
class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
"""Add another email address to your account."""
mizan = mizanFormMeta(
name="add_email",
title="Add Email Address",
subtitle="Add another email address to your account.",
submit_label="Add Email",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
"""Change your account password."""
mizan = mizanFormMeta(
name="change_password",
title="Change Password",
subtitle="Update your password to keep your account secure.",
submit_label="Change Password",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
"""Set a password for accounts created via social login."""
mizan = mizanFormMeta(
name="set_password",
title="Set Password",
subtitle="Create a password for your account.",
submit_label="Set Password",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
"""Request a password reset email."""
mizan = mizanFormMeta(
name="reset_password",
title="Reset Password",
subtitle="Enter your email address and we'll send you a link to reset your password.",
submit_label="Send Reset Link",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save(request)
return None
class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
"""Set a new password using a reset key."""
mizan = mizanFormMeta(
name="reset_password_from_key",
title="Set New Password",
subtitle="Enter your new password below.",
submit_label="Reset Password",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
"""Request a login code via email."""
mizan = mizanFormMeta(
name="request_login_code",
title="Sign In with Code",
subtitle="Enter your email address and we'll send you a login code.",
submit_label="Send Code",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
"""Confirm a login code."""
mizan = mizanFormMeta(
name="confirm_login_code",
title="Enter Code",
subtitle="Enter the code we sent to your email.",
submit_label="Verify Code",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
"""Verify an email with a token."""
mizan = mizanFormMeta(
name="user_token",
title="Verify Email",
subtitle="Enter the verification code from your email.",
submit_label="Verify",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
# Password reauthentication - conditionally define
if HAS_REAUTH:
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
"""Re-authenticate with password for sensitive actions."""
mizan = mizanFormMeta(
name="reauthenticate",
title="Confirm Your Identity",
subtitle="Please enter your password to continue.",
submit_label="Confirm",
live_validation=False,
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
from allauth.account.internal.flows import reauthentication
reauthentication.reauthenticate_by_password(request)
return None
# =============================================================================
# MFA Forms
# =============================================================================
if HAS_MFA:
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
"""Authenticate with MFA during login."""
mizan = mizanFormMeta(
name="mfa_authenticate",
title="Two-Factor Authentication",
subtitle="Enter your authentication code to continue.",
submit_label="Verify",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
"""Re-authenticate with MFA for sensitive actions."""
mizan = mizanFormMeta(
name="mfa_reauthenticate",
title="Confirm Your Identity",
subtitle="Enter your authentication code to continue.",
submit_label="Confirm",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
"""Activate TOTP authenticator."""
mizan = mizanFormMeta(
name="activate_totp",
title="Set Up Authenticator",
subtitle="Enter the code from your authenticator app to complete setup.",
submit_label="Activate",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
"""Deactivate TOTP authenticator."""
mizan = mizanFormMeta(
name="deactivate_totp",
title="Disable Authenticator",
subtitle="Enter your password to disable two-factor authentication.",
submit_label="Disable",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
"""Generate new recovery codes."""
mizan = mizanFormMeta(
name="generate_recovery_codes",
title="Recovery Codes",
subtitle="Generate new recovery codes for your account.",
submit_label="Generate Codes",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None
if HAS_WEBAUTHN:
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
"""Authenticate with WebAuthn security key."""
mizan = mizanFormMeta(
name="webauthn_authenticate",
title="Security Key",
subtitle="Use your security key to authenticate.",
submit_label="Use Security Key",
)
@classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
return {"request": request, "user": request.user}
def on_submit_success(self, request: HttpRequest) -> dict | None:
self.save()
return None

View File

@@ -1,10 +1,3 @@
"""
JWT Hybrid Settings
Configuration is read from Django settings with sensible defaults.
Supports both symmetric (HS256) and asymmetric (RS256) algorithms.
"""
from dataclasses import dataclass
from functools import lru_cache
@@ -13,8 +6,6 @@ from django.conf import settings as django_settings
@dataclass
class JWTSettings:
"""JWT configuration."""
# Signing keys
private_key: str # Used for signing (required)
public_key: str # Used for verification (same as private for HS256)
@@ -33,26 +24,8 @@ class JWTSettings:
@lru_cache
def get_settings() -> JWTSettings:
"""
Load JWT settings from Django settings.
Settings:
JWT_PRIVATE_KEY: Signing key (required)
JWT_PUBLIC_KEY: Verification key (defaults to private key for HS256)
JWT_ALGORITHM: Algorithm to use (default: HS256)
JWT_ACCESS_TOKEN_EXPIRES_IN: Access token lifetime (default: 300)
JWT_REFRESH_TOKEN_EXPIRES_IN: Refresh token lifetime (default: 604800)
JWT_VALIDATE_SESSION: Validate session on token use (default: True)
JWT_ROTATE_REFRESH_TOKEN: Rotate refresh tokens (default: True)
"""
private_key = getattr(django_settings, "JWT_PRIVATE_KEY", None)
if not private_key:
# Fall back to allauth setting if available (for compatibility)
headless_key = getattr(django_settings, "HEADLESS_JWT_PRIVATE_KEY", None)
if headless_key:
private_key = headless_key
if private_key is None:
raise ValueError(
"JWT_PRIVATE_KEY must be set in Django settings. "
@@ -60,7 +33,6 @@ def get_settings() -> JWTSettings:
"For RS256, use a PEM-encoded RSA private key."
)
# Auto-detect algorithm based on key format if not explicitly set
algorithm = getattr(django_settings, "JWT_ALGORITHM", None)
if algorithm is None:
@@ -100,14 +72,10 @@ def get_settings() -> JWTSettings:
public_key=public_key,
algorithm=algorithm,
access_token_expires_in=getattr(
django_settings,
"JWT_ACCESS_TOKEN_EXPIRES_IN",
getattr(django_settings, "HEADLESS_JWT_ACCESS_TOKEN_EXPIRES_IN", 300),
django_settings, "JWT_ACCESS_TOKEN_EXPIRES_IN", 300
),
refresh_token_expires_in=getattr(
django_settings,
"JWT_REFRESH_TOKEN_EXPIRES_IN",
getattr(django_settings, "HEADLESS_JWT_REFRESH_TOKEN_EXPIRES_IN", 604800),
django_settings, "JWT_REFRESH_TOKEN_EXPIRES_IN", 604800
),
validate_session=getattr(
django_settings, "JWT_VALIDATE_SESSION", True

View File

@@ -1,19 +1,4 @@
"""
mizan Auto-Discovery
Scans Django apps for server functions following the 'clients' layer convention:
- <app>/clients.py
- <app>/clients/**/*.py
Usage in urls.py:
from mizan.setup.discovery import mizan_clients
mizan_clients('apps') # Scans apps/*/clients.py
mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
This replaces manual "import to register" patterns with explicit auto-discovery.
"""
import logging
from typing import Any
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
@@ -21,14 +6,13 @@ from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
from mizan_core.registry import register, get_function
from mizan_core.client.function import ServerFunction
logger = logging.getLogger(__name__)
class _RegisterServerFunctions:
"""Visitor handler that registers ServerFunction subclasses."""
def on_module(
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
) -> None:
"""Process discovered module members."""
for name, member in members:
# Register ServerFunction subclasses
if (
@@ -47,44 +31,23 @@ class _RegisterServerFunctions:
try:
register(member, fn_name)
except ValueError:
# Already registered with different class - skip
pass
logger.warning(
"Server function name %r already registered with a "
"different class; skipping %s.%s",
fn_name,
member.__module__,
member.__qualname__,
)
# Scans <app>/<layer>.py and <app>/<layer>/**/*.py under apps_root
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
"""
Discover and register server functions from Django apps.
Scans for the specified layer (default: 'clients') in each app:
- <app>/<layer>.py
- <app>/<layer>/**/*.py
Args:
apps_root: Root package containing Django apps (e.g., 'apps')
layer: Module name pattern to scan (default: 'clients')
Example:
# In urls.py
mizan_clients('apps') # Scans apps/*/clients.py
mizan_clients('apps', 'functions') # Scans apps/*/functions.py
"""
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
visitor.visit(_RegisterServerFunctions())
# Registers server functions from one module path, e.g. 'mizan.jwt.functions'
def mizan_module(module_path: str) -> None:
"""
Register server functions from a specific module.
Use this for library modules that don't follow the app convention.
Args:
module_path: Full module path (e.g., 'mizan.integrations.allauth')
Example:
mizan_module('mizan.integrations.allauth')
mizan_module('mizan.jwt.functions')
"""
members = get_members(module_path)
handler = _RegisterServerFunctions()
handler.on_module("", [], members)