django-clerk-users 0.0.1__py3-none-any.whl → 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_clerk_users/__init__.py +78 -7
- django_clerk_users/apps.py +20 -0
- django_clerk_users/authentication/__init__.py +24 -0
- django_clerk_users/authentication/backends.py +89 -0
- django_clerk_users/authentication/drf.py +111 -0
- django_clerk_users/authentication/utils.py +171 -0
- django_clerk_users/caching.py +161 -0
- django_clerk_users/checks.py +127 -0
- django_clerk_users/client.py +32 -0
- django_clerk_users/decorators.py +181 -0
- django_clerk_users/exceptions.py +51 -0
- django_clerk_users/management/__init__.py +0 -0
- django_clerk_users/management/commands/__init__.py +0 -0
- django_clerk_users/management/commands/migrate_users_to_clerk.py +223 -0
- django_clerk_users/management/commands/sync_clerk_organizations.py +191 -0
- django_clerk_users/management/commands/sync_clerk_users.py +114 -0
- django_clerk_users/managers.py +121 -0
- django_clerk_users/middleware/__init__.py +9 -0
- django_clerk_users/middleware/auth.py +201 -0
- django_clerk_users/migrations/0001_initial.py +174 -0
- django_clerk_users/migrations/__init__.py +0 -0
- django_clerk_users/models.py +174 -0
- django_clerk_users/organizations/__init__.py +8 -0
- django_clerk_users/organizations/admin.py +81 -0
- django_clerk_users/organizations/apps.py +8 -0
- django_clerk_users/organizations/middleware.py +130 -0
- django_clerk_users/organizations/models.py +316 -0
- django_clerk_users/organizations/webhooks.py +417 -0
- django_clerk_users/settings.py +37 -0
- django_clerk_users/testing.py +381 -0
- django_clerk_users/utils.py +210 -0
- django_clerk_users/webhooks/__init__.py +26 -0
- django_clerk_users/webhooks/handlers.py +346 -0
- django_clerk_users/webhooks/security.py +108 -0
- django_clerk_users/webhooks/signals.py +42 -0
- django_clerk_users/webhooks/views.py +76 -0
- django_clerk_users-0.0.2.dist-info/METADATA +228 -0
- django_clerk_users-0.0.2.dist-info/RECORD +41 -0
- django_clerk_users/main.py +0 -2
- django_clerk_users-0.0.1.dist-info/METADATA +0 -24
- django_clerk_users-0.0.1.dist-info/RECORD +0 -7
- {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/WHEEL +0 -0
- {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/licenses/LICENSE +0 -0
- {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Management command to sync organizations from Clerk to Django.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django.core.management.base import BaseCommand
|
|
6
|
+
|
|
7
|
+
from django_clerk_users.client import get_clerk_client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Command(BaseCommand):
|
|
11
|
+
help = "Sync organizations from Clerk to Django database"
|
|
12
|
+
|
|
13
|
+
def add_arguments(self, parser):
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--limit",
|
|
16
|
+
type=int,
|
|
17
|
+
default=100,
|
|
18
|
+
help="Maximum number of organizations to sync per batch (default: 100)",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--offset",
|
|
22
|
+
type=int,
|
|
23
|
+
default=0,
|
|
24
|
+
help="Offset to start syncing from (default: 0)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--all",
|
|
28
|
+
action="store_true",
|
|
29
|
+
help="Sync all organizations (paginate through all results)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--sync-members",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Also sync organization members",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--dry-run",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Show what would be synced without making changes",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def handle(self, *args, **options):
|
|
43
|
+
# Check if organizations app is installed
|
|
44
|
+
try:
|
|
45
|
+
from django_clerk_users.organizations.models import Organization
|
|
46
|
+
from django_clerk_users.organizations.webhooks import (
|
|
47
|
+
update_or_create_organization,
|
|
48
|
+
)
|
|
49
|
+
except ImportError:
|
|
50
|
+
self.stderr.write(
|
|
51
|
+
self.style.ERROR(
|
|
52
|
+
"Organizations app is not installed. "
|
|
53
|
+
"Add 'django_clerk_users.organizations' to INSTALLED_APPS."
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
limit = options["limit"]
|
|
59
|
+
offset = options["offset"]
|
|
60
|
+
sync_all = options["all"]
|
|
61
|
+
sync_members = options["sync_members"]
|
|
62
|
+
dry_run = options["dry_run"]
|
|
63
|
+
|
|
64
|
+
clerk = get_clerk_client()
|
|
65
|
+
|
|
66
|
+
created_count = 0
|
|
67
|
+
updated_count = 0
|
|
68
|
+
error_count = 0
|
|
69
|
+
total_count = 0
|
|
70
|
+
|
|
71
|
+
self.stdout.write("Starting organization sync from Clerk...")
|
|
72
|
+
|
|
73
|
+
if dry_run:
|
|
74
|
+
self.stdout.write(self.style.WARNING("DRY RUN - No changes will be made"))
|
|
75
|
+
|
|
76
|
+
while True:
|
|
77
|
+
self.stdout.write(
|
|
78
|
+
f"Fetching organizations (offset={offset}, limit={limit})..."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
response = clerk.organizations.list(limit=limit, offset=offset)
|
|
83
|
+
orgs = response.data if hasattr(response, "data") else response
|
|
84
|
+
except Exception as e:
|
|
85
|
+
self.stderr.write(
|
|
86
|
+
self.style.ERROR(f"Failed to fetch organizations: {e}")
|
|
87
|
+
)
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
if not orgs:
|
|
91
|
+
self.stdout.write("No more organizations to sync.")
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
for clerk_org in orgs:
|
|
95
|
+
total_count += 1
|
|
96
|
+
org_id = getattr(clerk_org, "id", None)
|
|
97
|
+
name = getattr(clerk_org, "name", "Unknown")
|
|
98
|
+
|
|
99
|
+
if not org_id:
|
|
100
|
+
error_count += 1
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
if dry_run:
|
|
104
|
+
self.stdout.write(f" Would sync: {name} ({org_id})")
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
organization, created = update_or_create_organization(org_id)
|
|
109
|
+
if created:
|
|
110
|
+
created_count += 1
|
|
111
|
+
self.stdout.write(
|
|
112
|
+
self.style.SUCCESS(f" Created: {organization.name}")
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
updated_count += 1
|
|
116
|
+
self.stdout.write(f" Updated: {organization.name}")
|
|
117
|
+
|
|
118
|
+
if sync_members and organization:
|
|
119
|
+
self._sync_organization_members(organization, org_id, dry_run)
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
error_count += 1
|
|
123
|
+
self.stderr.write(
|
|
124
|
+
self.style.ERROR(f" Failed to sync {name}: {e}")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if not sync_all:
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
offset += limit
|
|
131
|
+
|
|
132
|
+
self.stdout.write("")
|
|
133
|
+
self.stdout.write(self.style.SUCCESS("Sync complete!"))
|
|
134
|
+
self.stdout.write(f" Total processed: {total_count}")
|
|
135
|
+
if not dry_run:
|
|
136
|
+
self.stdout.write(f" Created: {created_count}")
|
|
137
|
+
self.stdout.write(f" Updated: {updated_count}")
|
|
138
|
+
self.stdout.write(f" Errors: {error_count}")
|
|
139
|
+
|
|
140
|
+
def _sync_organization_members(self, organization, org_id, dry_run):
|
|
141
|
+
"""Sync members for a specific organization."""
|
|
142
|
+
from django.contrib.auth import get_user_model
|
|
143
|
+
|
|
144
|
+
from django_clerk_users.client import get_clerk_client
|
|
145
|
+
from django_clerk_users.organizations.models import OrganizationMember
|
|
146
|
+
from django_clerk_users.utils import update_or_create_clerk_user
|
|
147
|
+
|
|
148
|
+
User = get_user_model()
|
|
149
|
+
clerk = get_clerk_client()
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
response = clerk.organizations.get_membership_list(
|
|
153
|
+
organization_id=org_id, limit=100
|
|
154
|
+
)
|
|
155
|
+
memberships = response.data if hasattr(response, "data") else response
|
|
156
|
+
except Exception as e:
|
|
157
|
+
self.stderr.write(
|
|
158
|
+
self.style.WARNING(f" Failed to fetch members: {e}")
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
for membership in memberships or []:
|
|
163
|
+
membership_id = getattr(membership, "id", None)
|
|
164
|
+
user_data = getattr(membership, "public_user_data", None)
|
|
165
|
+
user_id = getattr(user_data, "user_id", None) if user_data else None
|
|
166
|
+
|
|
167
|
+
if not membership_id or not user_id:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
if dry_run:
|
|
171
|
+
self.stdout.write(f" Would sync member: {user_id}")
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
user = User.objects.filter(clerk_id=user_id).first()
|
|
176
|
+
if not user:
|
|
177
|
+
user, _ = update_or_create_clerk_user(user_id)
|
|
178
|
+
|
|
179
|
+
OrganizationMember.objects.update_or_create(
|
|
180
|
+
clerk_membership_id=membership_id,
|
|
181
|
+
defaults={
|
|
182
|
+
"organization": organization,
|
|
183
|
+
"user": user,
|
|
184
|
+
"role": getattr(membership, "role", "member"),
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
self.stdout.write(f" Synced member: {user.email}")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
self.stderr.write(
|
|
190
|
+
self.style.WARNING(f" Failed to sync member {user_id}: {e}")
|
|
191
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Management command to sync users from Clerk to Django.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django.core.management.base import BaseCommand
|
|
6
|
+
|
|
7
|
+
from django_clerk_users.client import get_clerk_client
|
|
8
|
+
from django_clerk_users.utils import update_or_create_clerk_user
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
help = "Sync users from Clerk to Django database"
|
|
13
|
+
|
|
14
|
+
def add_arguments(self, parser):
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--limit",
|
|
17
|
+
type=int,
|
|
18
|
+
default=100,
|
|
19
|
+
help="Maximum number of users to sync per batch (default: 100)",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--offset",
|
|
23
|
+
type=int,
|
|
24
|
+
default=0,
|
|
25
|
+
help="Offset to start syncing from (default: 0)",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--all",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="Sync all users (paginate through all results)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--dry-run",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Show what would be synced without making changes",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def handle(self, *args, **options):
|
|
39
|
+
limit = options["limit"]
|
|
40
|
+
offset = options["offset"]
|
|
41
|
+
sync_all = options["all"]
|
|
42
|
+
dry_run = options["dry_run"]
|
|
43
|
+
|
|
44
|
+
clerk = get_clerk_client()
|
|
45
|
+
|
|
46
|
+
created_count = 0
|
|
47
|
+
updated_count = 0
|
|
48
|
+
error_count = 0
|
|
49
|
+
total_count = 0
|
|
50
|
+
|
|
51
|
+
self.stdout.write("Starting user sync from Clerk...")
|
|
52
|
+
|
|
53
|
+
if dry_run:
|
|
54
|
+
self.stdout.write(self.style.WARNING("DRY RUN - No changes will be made"))
|
|
55
|
+
|
|
56
|
+
while True:
|
|
57
|
+
self.stdout.write(f"Fetching users (offset={offset}, limit={limit})...")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
response = clerk.users.list(limit=limit, offset=offset)
|
|
61
|
+
users = response.data if hasattr(response, "data") else response
|
|
62
|
+
except Exception as e:
|
|
63
|
+
self.stderr.write(self.style.ERROR(f"Failed to fetch users: {e}"))
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
if not users:
|
|
67
|
+
self.stdout.write("No more users to sync.")
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
for clerk_user in users:
|
|
71
|
+
total_count += 1
|
|
72
|
+
clerk_id = getattr(clerk_user, "id", None)
|
|
73
|
+
|
|
74
|
+
if not clerk_id:
|
|
75
|
+
error_count += 1
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
email = None
|
|
79
|
+
email_addresses = getattr(clerk_user, "email_addresses", []) or []
|
|
80
|
+
if email_addresses:
|
|
81
|
+
email = getattr(email_addresses[0], "email_address", None)
|
|
82
|
+
|
|
83
|
+
if dry_run:
|
|
84
|
+
self.stdout.write(f" Would sync: {email} ({clerk_id})")
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
user, created = update_or_create_clerk_user(clerk_id)
|
|
89
|
+
if created:
|
|
90
|
+
created_count += 1
|
|
91
|
+
self.stdout.write(
|
|
92
|
+
self.style.SUCCESS(f" Created: {user.email}")
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
updated_count += 1
|
|
96
|
+
self.stdout.write(f" Updated: {user.email}")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
error_count += 1
|
|
99
|
+
self.stderr.write(
|
|
100
|
+
self.style.ERROR(f" Failed to sync {clerk_id}: {e}")
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not sync_all:
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
offset += limit
|
|
107
|
+
|
|
108
|
+
self.stdout.write("")
|
|
109
|
+
self.stdout.write(self.style.SUCCESS("Sync complete!"))
|
|
110
|
+
self.stdout.write(f" Total processed: {total_count}")
|
|
111
|
+
if not dry_run:
|
|
112
|
+
self.stdout.write(f" Created: {created_count}")
|
|
113
|
+
self.stdout.write(f" Updated: {updated_count}")
|
|
114
|
+
self.stdout.write(f" Errors: {error_count}")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom managers for django-clerk-users models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from django.contrib.auth.models import BaseUserManager
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from django_clerk_users.models import AbstractClerkUser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClerkUserManager(BaseUserManager["AbstractClerkUser"]):
|
|
16
|
+
"""
|
|
17
|
+
Custom manager for ClerkUser model.
|
|
18
|
+
|
|
19
|
+
Handles user creation with clerk_id as the primary identifier.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def create_user(
|
|
23
|
+
self,
|
|
24
|
+
clerk_id: str,
|
|
25
|
+
email: str,
|
|
26
|
+
password: str | None = None,
|
|
27
|
+
**extra_fields: Any,
|
|
28
|
+
) -> "AbstractClerkUser":
|
|
29
|
+
"""
|
|
30
|
+
Create and save a user with the given clerk_id and email.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
clerk_id: The Clerk user ID.
|
|
34
|
+
email: The user's email address.
|
|
35
|
+
password: Optional password (not used for Clerk auth, but required
|
|
36
|
+
for Django admin compatibility).
|
|
37
|
+
**extra_fields: Additional fields for the user model.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The created user instance.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If clerk_id or email is not provided.
|
|
44
|
+
"""
|
|
45
|
+
if not clerk_id:
|
|
46
|
+
raise ValueError("The clerk_id must be set")
|
|
47
|
+
if not email:
|
|
48
|
+
raise ValueError("The email must be set")
|
|
49
|
+
|
|
50
|
+
email = self.normalize_email(email)
|
|
51
|
+
extra_fields.setdefault("is_active", True)
|
|
52
|
+
extra_fields.setdefault("is_staff", False)
|
|
53
|
+
extra_fields.setdefault("is_superuser", False)
|
|
54
|
+
|
|
55
|
+
user = self.model(clerk_id=clerk_id, email=email, **extra_fields)
|
|
56
|
+
if password:
|
|
57
|
+
user.set_password(password)
|
|
58
|
+
else:
|
|
59
|
+
user.set_unusable_password()
|
|
60
|
+
user.save(using=self._db)
|
|
61
|
+
return user
|
|
62
|
+
|
|
63
|
+
def create_superuser(
|
|
64
|
+
self,
|
|
65
|
+
clerk_id: str,
|
|
66
|
+
email: str,
|
|
67
|
+
password: str | None = None,
|
|
68
|
+
**extra_fields: Any,
|
|
69
|
+
) -> "AbstractClerkUser":
|
|
70
|
+
"""
|
|
71
|
+
Create and save a superuser with the given clerk_id and email.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
clerk_id: The Clerk user ID.
|
|
75
|
+
email: The user's email address.
|
|
76
|
+
password: Optional password.
|
|
77
|
+
**extra_fields: Additional fields for the user model.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The created superuser instance.
|
|
81
|
+
"""
|
|
82
|
+
extra_fields.setdefault("is_staff", True)
|
|
83
|
+
extra_fields.setdefault("is_superuser", True)
|
|
84
|
+
extra_fields.setdefault("is_active", True)
|
|
85
|
+
|
|
86
|
+
if extra_fields.get("is_staff") is not True:
|
|
87
|
+
raise ValueError("Superuser must have is_staff=True.")
|
|
88
|
+
if extra_fields.get("is_superuser") is not True:
|
|
89
|
+
raise ValueError("Superuser must have is_superuser=True.")
|
|
90
|
+
|
|
91
|
+
return self.create_user(clerk_id, email, password, **extra_fields)
|
|
92
|
+
|
|
93
|
+
def get_by_clerk_id(self, clerk_id: str) -> "AbstractClerkUser | None":
|
|
94
|
+
"""
|
|
95
|
+
Get a user by their Clerk ID.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
clerk_id: The Clerk user ID.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The user instance or None if not found.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
return self.get(clerk_id=clerk_id)
|
|
105
|
+
except self.model.DoesNotExist:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def get_by_email(self, email: str) -> "AbstractClerkUser | None":
|
|
109
|
+
"""
|
|
110
|
+
Get a user by their email address.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
email: The user's email address.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The user instance or None if not found.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
return self.get(email=self.normalize_email(email))
|
|
120
|
+
except self.model.DoesNotExist:
|
|
121
|
+
return None
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clerk authentication middleware.
|
|
3
|
+
|
|
4
|
+
This middleware validates Clerk JWT tokens and creates Django sessions
|
|
5
|
+
for authenticated users. It uses manual session handling instead of
|
|
6
|
+
Django's login() to avoid triggering signals that may conflict with Clerk.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from typing import TYPE_CHECKING, Callable
|
|
14
|
+
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
from django.contrib.auth.models import AnonymousUser
|
|
17
|
+
|
|
18
|
+
from django_clerk_users.authentication.utils import (
|
|
19
|
+
get_clerk_payload_from_request,
|
|
20
|
+
get_or_create_user_from_payload,
|
|
21
|
+
)
|
|
22
|
+
from django_clerk_users.exceptions import ClerkAuthenticationError, ClerkTokenError
|
|
23
|
+
from django_clerk_users.settings import CLERK_SESSION_REVALIDATION_SECONDS
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from django.http import HttpRequest, HttpResponse
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Authentication backend path
|
|
31
|
+
CLERK_BACKEND = "django_clerk_users.authentication.ClerkBackend"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ClerkAuthMiddleware:
|
|
35
|
+
"""
|
|
36
|
+
Middleware that authenticates users via Clerk JWT tokens.
|
|
37
|
+
|
|
38
|
+
This middleware implements a session-based optimization strategy:
|
|
39
|
+
1. On first request: Validates JWT, creates Django session
|
|
40
|
+
2. On subsequent requests: Uses session (avoids repeated validation)
|
|
41
|
+
3. Periodically re-validates to detect token expiration
|
|
42
|
+
|
|
43
|
+
After processing, the middleware sets:
|
|
44
|
+
- request.user: The authenticated Django user (or AnonymousUser)
|
|
45
|
+
- request.clerk_user: Same as request.user (for explicit Clerk access)
|
|
46
|
+
- request.clerk_payload: The decoded JWT payload (if authenticated)
|
|
47
|
+
- request.org: The organization ID from the token (if present)
|
|
48
|
+
|
|
49
|
+
Note: This middleware manually sets session data instead of using Django's
|
|
50
|
+
login() function to avoid triggering signals that may conflict with Clerk's
|
|
51
|
+
authentication flow.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, get_response: Callable[["HttpRequest"], "HttpResponse"]):
|
|
55
|
+
self.get_response = get_response
|
|
56
|
+
self.debug = getattr(settings, "DEBUG", False)
|
|
57
|
+
|
|
58
|
+
def __call__(self, request: "HttpRequest") -> "HttpResponse":
|
|
59
|
+
# Process authentication before the view
|
|
60
|
+
self.process_request(request)
|
|
61
|
+
|
|
62
|
+
# Call the next middleware/view
|
|
63
|
+
response = self.get_response(request)
|
|
64
|
+
|
|
65
|
+
return response
|
|
66
|
+
|
|
67
|
+
def process_request(self, request: "HttpRequest") -> None:
|
|
68
|
+
"""
|
|
69
|
+
Process the request and authenticate the user.
|
|
70
|
+
|
|
71
|
+
Sets request.user, request.clerk_user, request.clerk_payload,
|
|
72
|
+
and request.org based on the authentication result.
|
|
73
|
+
"""
|
|
74
|
+
# Initialize attributes
|
|
75
|
+
request.clerk_user = None # type: ignore
|
|
76
|
+
request.clerk_payload = None # type: ignore
|
|
77
|
+
request.org = None # type: ignore
|
|
78
|
+
|
|
79
|
+
# Check if user is already authenticated via session
|
|
80
|
+
if self._is_session_valid(request):
|
|
81
|
+
# User is already authenticated, just set clerk attributes
|
|
82
|
+
request.clerk_user = request.user # type: ignore
|
|
83
|
+
request.org = request.session.get("clerk_org_id") # type: ignore
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Try to authenticate via JWT token
|
|
87
|
+
try:
|
|
88
|
+
payload = get_clerk_payload_from_request(request)
|
|
89
|
+
except ClerkTokenError as e:
|
|
90
|
+
logger.debug(f"Token validation failed: {e}")
|
|
91
|
+
self._set_anonymous(request)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if not payload:
|
|
95
|
+
# No token provided - anonymous user
|
|
96
|
+
self._set_anonymous(request)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# Get or create the Django user
|
|
100
|
+
try:
|
|
101
|
+
user, created = get_or_create_user_from_payload(payload)
|
|
102
|
+
except ClerkAuthenticationError as e:
|
|
103
|
+
logger.warning(f"Failed to get/create user: {e}")
|
|
104
|
+
self._set_anonymous(request)
|
|
105
|
+
return
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"ClerkAuthMiddleware error: {e}", exc_info=True)
|
|
108
|
+
if self.debug:
|
|
109
|
+
raise
|
|
110
|
+
self._set_anonymous(request)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if not user.is_active:
|
|
114
|
+
logger.debug(f"User {user.clerk_id} is inactive")
|
|
115
|
+
self._set_anonymous(request)
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Create Django session (without calling login())
|
|
119
|
+
self._create_session(request, user, payload)
|
|
120
|
+
|
|
121
|
+
# Set request attributes
|
|
122
|
+
user.backend = CLERK_BACKEND
|
|
123
|
+
request.user = user
|
|
124
|
+
request.clerk_user = user # type: ignore
|
|
125
|
+
request.clerk_payload = payload # type: ignore
|
|
126
|
+
request.org = payload.get("org_id") # type: ignore
|
|
127
|
+
|
|
128
|
+
if created:
|
|
129
|
+
logger.info(f"Created new user: {user.email} ({user.clerk_id})")
|
|
130
|
+
|
|
131
|
+
def _is_session_valid(self, request: "HttpRequest") -> bool:
|
|
132
|
+
"""
|
|
133
|
+
Check if the current session is valid and doesn't need revalidation.
|
|
134
|
+
|
|
135
|
+
Returns True if:
|
|
136
|
+
1. User is authenticated in session
|
|
137
|
+
2. The session hasn't expired
|
|
138
|
+
3. The re-validation interval hasn't passed
|
|
139
|
+
"""
|
|
140
|
+
if not hasattr(request, "user") or not request.user.is_authenticated:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
# Check if re-validation is needed
|
|
144
|
+
last_check = request.session.get("last_clerk_check", 0)
|
|
145
|
+
now = int(time.time())
|
|
146
|
+
|
|
147
|
+
if now - last_check > CLERK_SESSION_REVALIDATION_SECONDS:
|
|
148
|
+
# Re-validation needed - try to validate the token
|
|
149
|
+
try:
|
|
150
|
+
payload = get_clerk_payload_from_request(request)
|
|
151
|
+
if payload:
|
|
152
|
+
# Token is still valid, update session
|
|
153
|
+
request.session["last_clerk_check"] = now
|
|
154
|
+
request.session["clerk_org_id"] = payload.get("org_id")
|
|
155
|
+
request.clerk_payload = payload # type: ignore
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
# Missing token or invalid payload - end the session
|
|
159
|
+
self._clear_session(request)
|
|
160
|
+
return False
|
|
161
|
+
except ClerkTokenError:
|
|
162
|
+
# Token is invalid - invalidate session
|
|
163
|
+
self._clear_session(request)
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
def _create_session(self, request: "HttpRequest", user, payload: dict) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Create a Django session for the authenticated user.
|
|
171
|
+
|
|
172
|
+
Note: We manually set session data instead of calling login() to avoid
|
|
173
|
+
triggering Django signals (like user_logged_in) that may conflict with
|
|
174
|
+
Clerk's authentication flow.
|
|
175
|
+
"""
|
|
176
|
+
# Manually set session auth data (what login() would do internally)
|
|
177
|
+
request.session["_auth_user_id"] = str(user.pk)
|
|
178
|
+
request.session["_auth_user_backend"] = CLERK_BACKEND
|
|
179
|
+
request.session["_auth_user_hash"] = ""
|
|
180
|
+
|
|
181
|
+
# Store Clerk-specific session data
|
|
182
|
+
request.session["last_clerk_check"] = int(time.time())
|
|
183
|
+
request.session["clerk_org_id"] = payload.get("org_id")
|
|
184
|
+
|
|
185
|
+
logger.debug(f"Created session for user {user.email}")
|
|
186
|
+
|
|
187
|
+
def _clear_session(self, request: "HttpRequest") -> None:
|
|
188
|
+
"""
|
|
189
|
+
Clear the Django session.
|
|
190
|
+
"""
|
|
191
|
+
request.session.flush()
|
|
192
|
+
|
|
193
|
+
def _set_anonymous(self, request: "HttpRequest") -> None:
|
|
194
|
+
"""
|
|
195
|
+
Set the request user to anonymous.
|
|
196
|
+
"""
|
|
197
|
+
if not hasattr(request, "user") or request.user.is_authenticated:
|
|
198
|
+
request.user = AnonymousUser()
|
|
199
|
+
request.clerk_user = None # type: ignore
|
|
200
|
+
request.clerk_payload = None # type: ignore
|
|
201
|
+
request.org = None # type: ignore
|