django-clerk-users 0.0.2__py3-none-any.whl → 0.1.1__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.
@@ -8,7 +8,7 @@ import logging
8
8
  from typing import TYPE_CHECKING, Any
9
9
 
10
10
  from django.contrib.auth import get_user_model
11
- from django.contrib.auth.backends import BaseBackend
11
+ from django.contrib.auth.backends import ModelBackend
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from django.http import HttpRequest
@@ -18,72 +18,66 @@ if TYPE_CHECKING:
18
18
  logger = logging.getLogger(__name__)
19
19
 
20
20
 
21
- class ClerkBackend(BaseBackend):
21
+ class ClerkBackend(ModelBackend):
22
22
  """
23
23
  Django authentication backend for Clerk.
24
24
 
25
- This backend authenticates users by their Clerk ID rather than
26
- username/password. It's designed to work with Clerk's JWT-based
27
- authentication.
25
+ This backend extends Django's ModelBackend to add Clerk ID authentication
26
+ while preserving standard username/password authentication. This allows:
27
+
28
+ - Superusers to log into Django admin with email/password
29
+ - Clerk users to authenticate via JWT tokens (clerk_id)
30
+ - All standard Django permission checks to work as expected
28
31
 
29
32
  To use this backend, add it to AUTHENTICATION_BACKENDS in settings:
30
33
 
31
34
  AUTHENTICATION_BACKENDS = [
32
35
  'django_clerk_users.authentication.ClerkBackend',
33
36
  ]
37
+
38
+ This is the only backend you need - it handles both Clerk authentication
39
+ and standard Django authentication (for admin access, etc.).
34
40
  """
35
41
 
36
42
  def authenticate(
37
43
  self,
38
44
  request: "HttpRequest | None" = None,
39
- clerk_id: str | None = None,
45
+ username: str | None = None,
46
+ password: str | None = None,
40
47
  **kwargs: Any,
41
48
  ) -> "AbstractClerkUser | None":
42
49
  """
43
- Authenticate a user by their Clerk ID.
50
+ Authenticate a user by Clerk ID or username/password.
51
+
52
+ If a clerk_id is provided in kwargs, authenticates via Clerk ID lookup.
53
+ Otherwise, falls back to Django's standard username/password
54
+ authentication (inherited from ModelBackend).
44
55
 
45
56
  Args:
46
57
  request: The current HTTP request (optional).
47
- clerk_id: The Clerk user ID to authenticate.
48
- **kwargs: Additional keyword arguments (ignored).
58
+ username: The username (email) for standard auth (optional).
59
+ password: The password for standard auth (optional).
60
+ **kwargs: Additional keyword arguments. If 'clerk_id' is present,
61
+ Clerk authentication is used instead of password auth.
49
62
 
50
63
  Returns:
51
64
  The authenticated user or None if authentication fails.
52
65
  """
53
- if not clerk_id:
54
- return None
55
-
56
- User = get_user_model()
57
-
58
- try:
59
- user = User.objects.get(clerk_id=clerk_id)
60
- if user.is_active:
61
- return user
62
- logger.debug(f"User {clerk_id} is inactive")
63
- return None
64
- except User.DoesNotExist:
65
- logger.debug(f"No user found with clerk_id: {clerk_id}")
66
- return None
67
-
68
- def get_user(self, user_id: int) -> "AbstractClerkUser | None":
69
- """
70
- Get a user by their Django primary key.
71
-
72
- This method is called by Django's authentication middleware
73
- to restore the user from the session.
74
-
75
- Args:
76
- user_id: The user's primary key.
77
-
78
- Returns:
79
- The user instance or None if not found.
80
- """
81
- User = get_user_model()
82
-
83
- try:
84
- user = User.objects.get(pk=user_id)
85
- if user.is_active:
86
- return user
87
- return None
88
- except User.DoesNotExist:
89
- return None
66
+ # If clerk_id is provided, authenticate via Clerk
67
+ clerk_id = kwargs.pop("clerk_id", None)
68
+ if clerk_id:
69
+ User = get_user_model()
70
+
71
+ try:
72
+ user = User.objects.get(clerk_id=clerk_id)
73
+ if user.is_active:
74
+ return user
75
+ logger.debug(f"User {clerk_id} is inactive")
76
+ return None
77
+ except User.DoesNotExist:
78
+ logger.debug(f"No user found with clerk_id: {clerk_id}")
79
+ return None
80
+
81
+ # Otherwise, fall back to standard Django authentication
82
+ # This enables superuser login via Django admin
83
+ return super().authenticate(request, username, password, **kwargs)
@@ -21,29 +21,26 @@ class ClerkUserManager(BaseUserManager["AbstractClerkUser"]):
21
21
 
22
22
  def create_user(
23
23
  self,
24
- clerk_id: str,
25
24
  email: str,
25
+ clerk_id: str | None = None,
26
26
  password: str | None = None,
27
27
  **extra_fields: Any,
28
28
  ) -> "AbstractClerkUser":
29
29
  """
30
- Create and save a user with the given clerk_id and email.
30
+ Create and save a user with the given email and optional clerk_id.
31
31
 
32
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).
33
+ email: The user's email address (required).
34
+ clerk_id: The Clerk user ID (optional for Django admin users).
35
+ password: Optional password (required for Django admin users).
37
36
  **extra_fields: Additional fields for the user model.
38
37
 
39
38
  Returns:
40
39
  The created user instance.
41
40
 
42
41
  Raises:
43
- ValueError: If clerk_id or email is not provided.
42
+ ValueError: If email is not provided.
44
43
  """
45
- if not clerk_id:
46
- raise ValueError("The clerk_id must be set")
47
44
  if not email:
48
45
  raise ValueError("The email must be set")
49
46
 
@@ -62,18 +59,18 @@ class ClerkUserManager(BaseUserManager["AbstractClerkUser"]):
62
59
 
63
60
  def create_superuser(
64
61
  self,
65
- clerk_id: str,
66
62
  email: str,
67
63
  password: str | None = None,
64
+ clerk_id: str | None = None,
68
65
  **extra_fields: Any,
69
66
  ) -> "AbstractClerkUser":
70
67
  """
71
- Create and save a superuser with the given clerk_id and email.
68
+ Create and save a superuser with the given email.
72
69
 
73
70
  Args:
74
- clerk_id: The Clerk user ID.
75
- email: The user's email address.
76
- password: Optional password.
71
+ email: The user's email address (required).
72
+ password: Password for the superuser (required for Django admin access).
73
+ clerk_id: The Clerk user ID (optional).
77
74
  **extra_fields: Additional fields for the user model.
78
75
 
79
76
  Returns:
@@ -88,7 +85,9 @@ class ClerkUserManager(BaseUserManager["AbstractClerkUser"]):
88
85
  if extra_fields.get("is_superuser") is not True:
89
86
  raise ValueError("Superuser must have is_superuser=True.")
90
87
 
91
- return self.create_user(clerk_id, email, password, **extra_fields)
88
+ return self.create_user(
89
+ email=email, clerk_id=clerk_id, password=password, **extra_fields
90
+ )
92
91
 
93
92
  def get_by_clerk_id(self, clerk_id: str) -> "AbstractClerkUser | None":
94
93
  """
@@ -70,18 +70,35 @@ class ClerkAuthMiddleware:
70
70
 
71
71
  Sets request.user, request.clerk_user, request.clerk_payload,
72
72
  and request.org based on the authentication result.
73
+
74
+ Supports hybrid authentication:
75
+ - If user is authenticated via Django session (e.g., admin login),
76
+ respects that authentication and skips Clerk JWT validation
77
+ - Otherwise, attempts Clerk JWT authentication
73
78
  """
74
79
  # Initialize attributes
75
80
  request.clerk_user = None # type: ignore
76
81
  request.clerk_payload = None # type: ignore
77
82
  request.org = None # type: ignore
78
83
 
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
84
+ # Check if user is already authenticated via Django's standard auth
85
+ # (e.g., admin login with username/password)
86
+ if hasattr(request, "user") and request.user.is_authenticated:
87
+ # Check if this is a Clerk session or a traditional Django session
88
+ if self._is_clerk_session(request):
89
+ # This is a Clerk session, validate it
90
+ if self._is_session_valid(request):
91
+ # Clerk session is still valid
92
+ request.clerk_user = request.user # type: ignore
93
+ request.org = request.session.get("clerk_org_id") # type: ignore
94
+ return
95
+ # Clerk session expired, clear it and try JWT auth below
96
+ self._clear_session(request)
97
+ else:
98
+ # This is a traditional Django session (e.g., admin login)
99
+ # Don't interfere with it - just skip Clerk authentication
100
+ logger.debug("Using existing Django session (non-Clerk)")
101
+ return
85
102
 
86
103
  # Try to authenticate via JWT token
87
104
  try:
@@ -128,14 +145,26 @@ class ClerkAuthMiddleware:
128
145
  if created:
129
146
  logger.info(f"Created new user: {user.email} ({user.clerk_id})")
130
147
 
148
+ def _is_clerk_session(self, request: "HttpRequest") -> bool:
149
+ """
150
+ Check if the current session is a Clerk-authenticated session.
151
+
152
+ Returns True if this session was created by Clerk authentication,
153
+ False if it's a traditional Django session (e.g., admin login).
154
+ """
155
+ # Clerk sessions have the last_clerk_check timestamp
156
+ return "last_clerk_check" in request.session
157
+
131
158
  def _is_session_valid(self, request: "HttpRequest") -> bool:
132
159
  """
133
- Check if the current session is valid and doesn't need revalidation.
160
+ Check if the current Clerk session is valid and doesn't need revalidation.
134
161
 
135
162
  Returns True if:
136
163
  1. User is authenticated in session
137
164
  2. The session hasn't expired
138
165
  3. The re-validation interval hasn't passed
166
+
167
+ Note: This method should only be called for Clerk sessions.
139
168
  """
140
169
  if not hasattr(request, "user") or not request.user.is_authenticated:
141
170
  return False
@@ -0,0 +1,24 @@
1
+ # Generated migration for hybrid authentication support
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("django_clerk_users", "0001_initial"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AlterField(
13
+ model_name="clerkuser",
14
+ name="clerk_id",
15
+ field=models.CharField(
16
+ blank=True,
17
+ db_index=True,
18
+ help_text="Unique identifier from Clerk. Can be null for Django admin users.",
19
+ max_length=255,
20
+ null=True,
21
+ unique=True,
22
+ ),
23
+ ),
24
+ ]
@@ -43,9 +43,15 @@ class AbstractClerkUser(AbstractBaseUser, PermissionsMixin):
43
43
  max_length=255,
44
44
  unique=True,
45
45
  db_index=True,
46
- help_text="Unique identifier from Clerk.",
46
+ null=True,
47
+ blank=True,
48
+ help_text="Unique identifier from Clerk. Can be null for Django admin users.",
47
49
  )
48
50
 
51
+ # Password field for Django admin compatibility
52
+ # Inherited from AbstractBaseUser, but we make it explicit that it's optional
53
+ # for Clerk users (who authenticate via JWT) but required for admin users
54
+
49
55
  # Standard user fields
50
56
  email = models.EmailField(
51
57
  unique=True,
@@ -104,7 +110,7 @@ class AbstractClerkUser(AbstractBaseUser, PermissionsMixin):
104
110
  objects = ClerkUserManager()
105
111
 
106
112
  USERNAME_FIELD = "email"
107
- REQUIRED_FIELDS = ["clerk_id"]
113
+ REQUIRED_FIELDS = [] # Changed to empty - clerk_id is optional for admin users
108
114
 
109
115
  class Meta:
110
116
  abstract = True
@@ -0,0 +1,349 @@
1
+ # Generated by Django 5.2.8 on 2026-01-13 18:24
2
+
3
+ import django.db.models.deletion
4
+ import django.utils.timezone
5
+ import uuid
6
+ from django.conf import settings
7
+ from django.db import migrations, models
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+ initial = True
12
+
13
+ dependencies = [
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name="Organization",
20
+ fields=[
21
+ (
22
+ "id",
23
+ models.BigAutoField(
24
+ auto_created=True,
25
+ primary_key=True,
26
+ serialize=False,
27
+ verbose_name="ID",
28
+ ),
29
+ ),
30
+ (
31
+ "uid",
32
+ models.UUIDField(
33
+ db_index=True,
34
+ default=uuid.uuid4,
35
+ editable=False,
36
+ help_text="Public unique identifier for the organization.",
37
+ ),
38
+ ),
39
+ (
40
+ "clerk_id",
41
+ models.CharField(
42
+ db_index=True,
43
+ help_text="Unique identifier from Clerk.",
44
+ max_length=255,
45
+ unique=True,
46
+ ),
47
+ ),
48
+ (
49
+ "name",
50
+ models.CharField(help_text="Organization name.", max_length=255),
51
+ ),
52
+ (
53
+ "slug",
54
+ models.SlugField(
55
+ help_text="URL-friendly organization identifier.",
56
+ max_length=255,
57
+ ),
58
+ ),
59
+ (
60
+ "image_url",
61
+ models.URLField(
62
+ blank=True,
63
+ default="",
64
+ help_text="URL to organization logo from Clerk.",
65
+ max_length=500,
66
+ ),
67
+ ),
68
+ (
69
+ "public_metadata",
70
+ models.JSONField(
71
+ blank=True,
72
+ default=dict,
73
+ help_text="Public metadata from Clerk.",
74
+ ),
75
+ ),
76
+ (
77
+ "private_metadata",
78
+ models.JSONField(
79
+ blank=True,
80
+ default=dict,
81
+ help_text="Private metadata from Clerk.",
82
+ ),
83
+ ),
84
+ (
85
+ "is_active",
86
+ models.BooleanField(
87
+ db_index=True,
88
+ default=True,
89
+ help_text="Whether the organization is active.",
90
+ ),
91
+ ),
92
+ (
93
+ "members_count",
94
+ models.PositiveIntegerField(
95
+ default=0, help_text="Number of members in the organization."
96
+ ),
97
+ ),
98
+ (
99
+ "pending_invitations_count",
100
+ models.PositiveIntegerField(
101
+ default=0, help_text="Number of pending invitations."
102
+ ),
103
+ ),
104
+ (
105
+ "max_allowed_memberships",
106
+ models.PositiveIntegerField(
107
+ default=0,
108
+ help_text="Maximum allowed memberships (0 = unlimited).",
109
+ ),
110
+ ),
111
+ (
112
+ "created_at",
113
+ models.DateTimeField(
114
+ default=django.utils.timezone.now,
115
+ help_text="When the organization was created in Clerk.",
116
+ ),
117
+ ),
118
+ (
119
+ "updated_at",
120
+ models.DateTimeField(
121
+ auto_now=True,
122
+ help_text="When the organization was last updated.",
123
+ ),
124
+ ),
125
+ ],
126
+ options={
127
+ "verbose_name": "Organization",
128
+ "verbose_name_plural": "Organizations",
129
+ "ordering": ["-created_at"],
130
+ "indexes": [
131
+ models.Index(
132
+ fields=["clerk_id"], name="clerk_organ_clerk_i_b2b811_idx"
133
+ ),
134
+ models.Index(fields=["slug"], name="clerk_organ_slug_6c2124_idx"),
135
+ models.Index(
136
+ fields=["is_active"], name="clerk_organ_is_acti_67b48f_idx"
137
+ ),
138
+ ],
139
+ },
140
+ ),
141
+ migrations.CreateModel(
142
+ name="OrganizationInvitation",
143
+ fields=[
144
+ (
145
+ "id",
146
+ models.BigAutoField(
147
+ auto_created=True,
148
+ primary_key=True,
149
+ serialize=False,
150
+ verbose_name="ID",
151
+ ),
152
+ ),
153
+ (
154
+ "clerk_invitation_id",
155
+ models.CharField(
156
+ db_index=True,
157
+ help_text="Unique invitation identifier from Clerk.",
158
+ max_length=255,
159
+ unique=True,
160
+ ),
161
+ ),
162
+ (
163
+ "email_address",
164
+ models.EmailField(
165
+ help_text="Email address of the invitee.", max_length=254
166
+ ),
167
+ ),
168
+ (
169
+ "role",
170
+ models.CharField(
171
+ default="member",
172
+ help_text="Role the user will have upon accepting.",
173
+ max_length=100,
174
+ ),
175
+ ),
176
+ (
177
+ "status",
178
+ models.CharField(
179
+ choices=[
180
+ ("pending", "Pending"),
181
+ ("accepted", "Accepted"),
182
+ ("revoked", "Revoked"),
183
+ ],
184
+ db_index=True,
185
+ default="pending",
186
+ help_text="Invitation status.",
187
+ max_length=20,
188
+ ),
189
+ ),
190
+ (
191
+ "public_metadata",
192
+ models.JSONField(
193
+ blank=True,
194
+ default=dict,
195
+ help_text="Public metadata from Clerk.",
196
+ ),
197
+ ),
198
+ (
199
+ "private_metadata",
200
+ models.JSONField(
201
+ blank=True,
202
+ default=dict,
203
+ help_text="Private metadata from Clerk.",
204
+ ),
205
+ ),
206
+ (
207
+ "created_at",
208
+ models.DateTimeField(
209
+ default=django.utils.timezone.now,
210
+ help_text="When the invitation was created.",
211
+ ),
212
+ ),
213
+ (
214
+ "updated_at",
215
+ models.DateTimeField(
216
+ auto_now=True, help_text="When the invitation was last updated."
217
+ ),
218
+ ),
219
+ (
220
+ "inviter",
221
+ models.ForeignKey(
222
+ blank=True,
223
+ help_text="The user who sent the invitation.",
224
+ null=True,
225
+ on_delete=django.db.models.deletion.SET_NULL,
226
+ related_name="sent_invitations",
227
+ to=settings.AUTH_USER_MODEL,
228
+ ),
229
+ ),
230
+ (
231
+ "organization",
232
+ models.ForeignKey(
233
+ help_text="The organization.",
234
+ on_delete=django.db.models.deletion.CASCADE,
235
+ related_name="invitations",
236
+ to="clerk_organizations.organization",
237
+ ),
238
+ ),
239
+ ],
240
+ options={
241
+ "verbose_name": "Organization Invitation",
242
+ "verbose_name_plural": "Organization Invitations",
243
+ "ordering": ["-created_at"],
244
+ "indexes": [
245
+ models.Index(
246
+ fields=["clerk_invitation_id"],
247
+ name="clerk_organ_clerk_i_53d078_idx",
248
+ ),
249
+ models.Index(
250
+ fields=["status"], name="clerk_organ_status_bef241_idx"
251
+ ),
252
+ models.Index(
253
+ fields=["email_address"], name="clerk_organ_email_a_b3359c_idx"
254
+ ),
255
+ ],
256
+ },
257
+ ),
258
+ migrations.CreateModel(
259
+ name="OrganizationMember",
260
+ fields=[
261
+ (
262
+ "id",
263
+ models.BigAutoField(
264
+ auto_created=True,
265
+ primary_key=True,
266
+ serialize=False,
267
+ verbose_name="ID",
268
+ ),
269
+ ),
270
+ (
271
+ "clerk_membership_id",
272
+ models.CharField(
273
+ db_index=True,
274
+ help_text="Unique membership identifier from Clerk.",
275
+ max_length=255,
276
+ unique=True,
277
+ ),
278
+ ),
279
+ (
280
+ "role",
281
+ models.CharField(
282
+ default="member",
283
+ help_text="User's role in the organization (e.g., 'admin', 'member').",
284
+ max_length=100,
285
+ ),
286
+ ),
287
+ (
288
+ "public_metadata",
289
+ models.JSONField(
290
+ blank=True,
291
+ default=dict,
292
+ help_text="Public metadata from Clerk.",
293
+ ),
294
+ ),
295
+ (
296
+ "private_metadata",
297
+ models.JSONField(
298
+ blank=True,
299
+ default=dict,
300
+ help_text="Private metadata from Clerk.",
301
+ ),
302
+ ),
303
+ (
304
+ "joined_at",
305
+ models.DateTimeField(
306
+ default=django.utils.timezone.now,
307
+ help_text="When the user joined the organization.",
308
+ ),
309
+ ),
310
+ (
311
+ "updated_at",
312
+ models.DateTimeField(
313
+ auto_now=True, help_text="When the membership was last updated."
314
+ ),
315
+ ),
316
+ (
317
+ "organization",
318
+ models.ForeignKey(
319
+ help_text="The organization.",
320
+ on_delete=django.db.models.deletion.CASCADE,
321
+ related_name="cached_members",
322
+ to="clerk_organizations.organization",
323
+ ),
324
+ ),
325
+ (
326
+ "user",
327
+ models.ForeignKey(
328
+ help_text="The user.",
329
+ on_delete=django.db.models.deletion.CASCADE,
330
+ related_name="organization_memberships",
331
+ to=settings.AUTH_USER_MODEL,
332
+ ),
333
+ ),
334
+ ],
335
+ options={
336
+ "verbose_name": "Organization Member",
337
+ "verbose_name_plural": "Organization Members",
338
+ "ordering": ["-joined_at"],
339
+ "indexes": [
340
+ models.Index(
341
+ fields=["clerk_membership_id"],
342
+ name="clerk_organ_clerk_m_10f2c5_idx",
343
+ ),
344
+ models.Index(fields=["role"], name="clerk_organ_role_645ce1_idx"),
345
+ ],
346
+ "unique_together": {("organization", "user")},
347
+ },
348
+ ),
349
+ ]
@@ -133,10 +133,8 @@ class Organization(models.Model):
133
133
  Returns:
134
134
  Tuple of (success, message)
135
135
  """
136
- from django_clerk_users.utils import update_or_create_clerk_user
137
-
138
136
  try:
139
- from django_clerk_users.organizations.utils import (
137
+ from django_clerk_users.organizations.webhooks import (
140
138
  update_or_create_organization,
141
139
  )
142
140
 
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-clerk-users
3
- Version: 0.0.2
3
+ Version: 0.1.1
4
4
  Summary: Integrate Clerk with Django
5
5
  Project-URL: Changelog, https://github.com/jmitchel3/django-clerk-users
6
6
  Project-URL: Documentation, https://github.com/jmitchel3/django-clerk-users
7
7
  Project-URL: Funding, https://github.com/jmitchel3/django-clerk-users
8
8
  Project-URL: Repository, https://github.com/jmitchel3/django-clerk-users
9
+ License-File: LICENSE
9
10
  Classifier: Development Status :: 4 - Beta
10
11
  Classifier: Framework :: Django
11
12
  Classifier: Framework :: Django :: 4.2
@@ -14,14 +15,12 @@ Classifier: Programming Language :: Python :: 3.12
14
15
  Classifier: Programming Language :: Python :: 3.13
15
16
  Classifier: Programming Language :: Python :: 3.14
16
17
  Requires-Python: >=3.12
17
- Description-Content-Type: text/markdown
18
- License-File: LICENSE
19
- Requires-Dist: django>=4.2
20
18
  Requires-Dist: clerk-backend-api>=1.0.0
19
+ Requires-Dist: django>=4.2
21
20
  Requires-Dist: svix>=1.0.0
22
21
  Provides-Extra: drf
23
- Requires-Dist: djangorestframework>=3.14; extra == "drf"
24
- Dynamic: license-file
22
+ Requires-Dist: djangorestframework>=3.14; extra == 'drf'
23
+ Description-Content-Type: text/markdown
25
24
 
26
25
  # Django Clerk Users
27
26
 
@@ -114,12 +113,30 @@ MIDDLEWARE = [
114
113
 
115
114
  ### 5. Add authentication backend
116
115
 
116
+ **For Clerk-only authentication:**
117
+
117
118
  ```python
118
119
  AUTHENTICATION_BACKENDS = [
119
120
  "django_clerk_users.authentication.ClerkBackend",
120
121
  ]
121
122
  ```
122
123
 
124
+ **For hybrid authentication (Clerk + Django admin):**
125
+
126
+ If you want to support both Clerk authentication (JWT) and traditional Django admin login (username/password), use both backends:
127
+
128
+ ```python
129
+ AUTHENTICATION_BACKENDS = [
130
+ "django.contrib.auth.backends.ModelBackend", # For Django admin
131
+ "django_clerk_users.authentication.ClerkBackend", # For Clerk JWT
132
+ ]
133
+ ```
134
+
135
+ This allows:
136
+ - Admin users to log in via Django admin with username/password
137
+ - Frontend users to authenticate via Clerk JWT tokens
138
+ - The middleware automatically detects which authentication method was used
139
+
123
140
  ### 6. Run migrations
124
141
 
125
142
  ```bash
@@ -141,6 +158,21 @@ urlpatterns = [
141
158
 
142
159
  Then configure your Clerk Dashboard to send webhooks to `https://your-app.com/webhooks/clerk/`.
143
160
 
161
+ ### 8. Create admin users (for hybrid authentication)
162
+
163
+ If you're using hybrid authentication, create an admin user for Django admin access:
164
+
165
+ ```bash
166
+ python manage.py createsuperuser
167
+ ```
168
+
169
+ This creates a user with:
170
+ - Username/password authentication (for Django admin)
171
+ - No `clerk_id` (since they're not Clerk users)
172
+ - Access to Django admin panel
173
+
174
+ Note: Regular Clerk users are created automatically via webhooks when they sign up through your frontend.
175
+
144
176
  ## Usage
145
177
 
146
178
  ### Accessing the user in views
@@ -179,6 +211,55 @@ REST_FRAMEWORK = {
179
211
  }
180
212
  ```
181
213
 
214
+ ## Hybrid Authentication (Clerk + Django Admin)
215
+
216
+ The package supports hybrid authentication, allowing you to use both Clerk (JWT-based) authentication for your frontend users and traditional Django admin authentication for internal staff.
217
+
218
+ ### How it works
219
+
220
+ 1. **Frontend users**: Authenticate via Clerk JWT tokens (handled by `ClerkAuthMiddleware`)
221
+ 2. **Admin users**: Authenticate via username/password (handled by Django's `ModelBackend`)
222
+ 3. The middleware automatically detects which authentication method was used and respects existing sessions
223
+
224
+ ### Configuration
225
+
226
+ ```python
227
+ # settings.py
228
+ AUTHENTICATION_BACKENDS = [
229
+ "django.contrib.auth.backends.ModelBackend", # For Django admin
230
+ "django_clerk_users.authentication.ClerkBackend", # For Clerk JWT
231
+ ]
232
+ ```
233
+
234
+ ### Creating admin users
235
+
236
+ Admin users don't need a `clerk_id` (it's optional in hybrid mode):
237
+
238
+ ```bash
239
+ python manage.py createsuperuser
240
+ # Email: admin@example.com
241
+ # Password: ********
242
+ ```
243
+
244
+ This creates a user with:
245
+ - Username/password authentication (no Clerk integration)
246
+ - Access to Django admin panel at `/admin/`
247
+ - Standard Django permissions (is_staff, is_superuser)
248
+
249
+ ### Session handling
250
+
251
+ - **Django admin sessions**: Traditional session cookies (set by Django's auth system)
252
+ - **Clerk sessions**: JWT validated once, then cached in session with `last_clerk_check` marker
253
+ - The middleware checks for `last_clerk_check` to distinguish between the two types
254
+
255
+ ### Use cases
256
+
257
+ This is particularly useful when:
258
+ - Your admin panel is on a different domain than your frontend
259
+ - You want internal staff to access Django admin without Clerk accounts
260
+ - You need traditional Django auth features (permissions, groups, etc.)
261
+ - You're migrating from Django auth to Clerk gradually
262
+
182
263
  ## Organizations (Optional)
183
264
 
184
265
  For Clerk organization support:
@@ -215,9 +296,11 @@ python manage.py sync_clerk_organizations
215
296
  | `CLERK_SECRET_KEY` | Yes | - | Your Clerk secret key |
216
297
  | `CLERK_WEBHOOK_SIGNING_KEY` | Yes* | - | Webhook signing secret (*required for webhooks) |
217
298
  | `CLERK_FRONTEND_HOSTS` | Yes | `[]` | Authorized frontend URLs |
218
- | `CLERK_SESSION_REVALIDATION_SECONDS` | No | `300` | JWT revalidation interval |
219
- | `CLERK_CACHE_TIMEOUT` | No | `300` | User cache timeout |
220
- | `CLERK_ORG_CACHE_TIMEOUT` | No | `900` | Organization cache timeout |
299
+ | `CLERK_AUTH_PARTIES` | No | `[]` | Alias for `CLERK_FRONTEND_HOSTS` |
300
+ | `CLERK_SESSION_REVALIDATION_SECONDS` | No | `300` | JWT revalidation interval (seconds) |
301
+ | `CLERK_CACHE_TIMEOUT` | No | `300` | User cache timeout (seconds) |
302
+ | `CLERK_ORG_CACHE_TIMEOUT` | No | `900` | Organization cache timeout (seconds) |
303
+ | `CLERK_WEBHOOK_DEDUP_TIMEOUT` | No | `45` | Webhook deduplication cache timeout (seconds) |
221
304
 
222
305
  ## License
223
306
 
@@ -5,13 +5,13 @@ django_clerk_users/checks.py,sha256=gnHccAyXixtGToGhgWl4gfCY-qPB5ckimpDVadOP3E4,
5
5
  django_clerk_users/client.py,sha256=-nBXsPOibVwD7zXQ-Z-qTBb7NyPuUZpvlDcAlDVFUBA,815
6
6
  django_clerk_users/decorators.py,sha256=Hm86XIxNdSiuDmqT8tFRrz6sQR9IOxB7zfxfO8MeJLg,5011
7
7
  django_clerk_users/exceptions.py,sha256=nVTJR1d5PxuMqC8js1Sj-MRHJHkI4KUwphjuCEN4fiM,890
8
- django_clerk_users/managers.py,sha256=PH1sk0LB9Fj8qrYG5jzpBKqWUbmaSZ3zaXEbx4eNjdY,3581
9
- django_clerk_users/models.py,sha256=cCSgKoBivdijfLeiOoxwpber2tqNSCua5pBcQ7BbXQQ,4873
8
+ django_clerk_users/managers.py,sha256=HvSkGeiQhjA6EFXrW4GTlxXtlRDtlT54BbwbvnNuOWg,3610
9
+ django_clerk_users/models.py,sha256=DMcEnGNioGX5xsP4U47ich1L_UoZNAFEDb-vZVVV8Tk,5210
10
10
  django_clerk_users/settings.py,sha256=pRyt_kSPWOT8CkTvyce3sf0RxfIoG5DEwnGUT8bIDi8,1305
11
11
  django_clerk_users/testing.py,sha256=ZoYi7dG_HjSp19_c1AvILOoCfHlofs7LN-5ALS_UhDw,12160
12
12
  django_clerk_users/utils.py,sha256=bQWfPUKfVvXi69Ctny1T1Kxzk-qMDpXQBRVa6URH53I,5886
13
13
  django_clerk_users/authentication/__init__.py,sha256=PStQVzC-CA2Guyo4ksrxP58o93mfygW4qcJXWEjs6ak,614
14
- django_clerk_users/authentication/backends.py,sha256=We0P2AhMv_DB_ZwtFGmmjWKvT5Cewy441Iua2lAZZCg,2400
14
+ django_clerk_users/authentication/backends.py,sha256=bDJ2mEO66xhcJPc6f-jYa3F9x-PJ4RJrm9GD-pCTo6Y,2855
15
15
  django_clerk_users/authentication/drf.py,sha256=AqHvZTe9RnxN7FVzlSUXR72QBj2imGglkx9rpOrRDIk,3215
16
16
  django_clerk_users/authentication/utils.py,sha256=tpnRXQLbQPkosKV8OhxtXpapuema4c08g58YYmMl3js,5326
17
17
  django_clerk_users/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -20,22 +20,24 @@ django_clerk_users/management/commands/migrate_users_to_clerk.py,sha256=qjw4Q6pU
20
20
  django_clerk_users/management/commands/sync_clerk_organizations.py,sha256=G1kasAPvvwM-PPb5xXxKNlex_-gWyKDP2HLS5g8hVkk,6656
21
21
  django_clerk_users/management/commands/sync_clerk_users.py,sha256=hvWrvcqAkZHvjqQAOj3-e6pKx5TKtArmlsHDUottRMM,3755
22
22
  django_clerk_users/middleware/__init__.py,sha256=tnr4eBer0KGVBAZBgBQZVpcL7jf8t2_CBLsMnau5JW4,153
23
- django_clerk_users/middleware/auth.py,sha256=LTUE_gFXlBRNg9kk2mHmHNKz3gpWKgCs66xyKE1_k1c,7435
23
+ django_clerk_users/middleware/auth.py,sha256=FLo4DHkiL3QnEPZsvJDGFIahfH1HUcqkBEzYXEx0fN0,8889
24
24
  django_clerk_users/migrations/0001_initial.py,sha256=tnPvGlLnWrItuhYS0s5mr3TJ8__2e_yNKJS4nsFuWhk,6246
25
+ django_clerk_users/migrations/0002_make_clerk_id_nullable.py,sha256=OXad63KPFTMLn2lkwkuNkr8ZbM6BiNlsw6z72fhY-eU,640
25
26
  django_clerk_users/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
27
  django_clerk_users/organizations/__init__.py,sha256=NZU2C8F4Trm63_qaUf78jROKz6XFnu36uREkw7g_yho,270
27
28
  django_clerk_users/organizations/admin.py,sha256=u7qHMgb11akHiJ2FCzqbSLQ3_TsTiejf0wH10f5_PPQ,1838
28
29
  django_clerk_users/organizations/apps.py,sha256=IgKNl5REkAgLDHC06GFI-a3x3Mi2HWN4L-El0Ef6CE8,252
29
30
  django_clerk_users/organizations/middleware.py,sha256=5Gvu28SCEqpuIjr2Fah5O85m0wnjMiDp4vxdvVzJh68,4129
30
- django_clerk_users/organizations/models.py,sha256=2YWLHuWCo2Q3pEq_Gx5SadUGqSmi8MZhEDdfeVn1URo,8673
31
+ django_clerk_users/organizations/models.py,sha256=rGy3udfo2xO6XBMPHS7eTTk9meyxx_3GI9zL9lH-ScY,8602
31
32
  django_clerk_users/organizations/webhooks.py,sha256=zp16_vL5j7DEaVC5ZxgYwrE8zPpUsRIq8TGBd8SYhoo,14321
33
+ django_clerk_users/organizations/migrations/0001_initial.py,sha256=NXZ63PLqkX0ebavvLk6aHOffiFDq0pZamEelb6PG4Yg,12625
34
+ django_clerk_users/organizations/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
35
  django_clerk_users/webhooks/__init__.py,sha256=XlpsPmc_lHQkZVMeuXce_2hsq7WZzXOXNy36kiQA5Lk,558
33
36
  django_clerk_users/webhooks/handlers.py,sha256=GnotJNhN809DsrbfZjtJD0KurThKyAXZS982a41ljwg,9457
34
37
  django_clerk_users/webhooks/security.py,sha256=Ig2ZxF8SxX5o-4bNRehFhip4hVvcQxoGsTj3sTY3WSU,3461
35
38
  django_clerk_users/webhooks/signals.py,sha256=bytshg7IMDnlvnCZ0_TGjUXZZLRNxtn2RSx97qacZ-w,1668
36
39
  django_clerk_users/webhooks/views.py,sha256=0-ilzzO7tBfc-pENMy0ZSSkQ4uPqH2QAt249EK2wQKA,2287
37
- django_clerk_users-0.0.2.dist-info/licenses/LICENSE,sha256=X4PZDRQG4RmPhHU5c0G21Ki9LXWDCuLQ8W4mnED5RDU,1071
38
- django_clerk_users-0.0.2.dist-info/METADATA,sha256=pQfVSZMxsGZH7UB9mBkus-SKoictxbzp9fvkXHh9Jiw,5700
39
- django_clerk_users-0.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
40
- django_clerk_users-0.0.2.dist-info/top_level.txt,sha256=m2CUZNVRrrVHorKdGlRLuGJbc0NFgx1f2GGjpTLuGXY,19
41
- django_clerk_users-0.0.2.dist-info/RECORD,,
40
+ django_clerk_users-0.1.1.dist-info/METADATA,sha256=8wVBDTc-ndo9AlgsDuoxglUmzS1SfV4tM3E0iHgP5jk,8687
41
+ django_clerk_users-0.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
42
+ django_clerk_users-0.1.1.dist-info/licenses/LICENSE,sha256=X4PZDRQG4RmPhHU5c0G21Ki9LXWDCuLQ8W4mnED5RDU,1071
43
+ django_clerk_users-0.1.1.dist-info/RECORD,,
@@ -1,5 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
-
@@ -1 +0,0 @@
1
- django_clerk_users