django-clerk-users 0.0.1__py3-none-any.whl → 0.1.0__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.
Files changed (47) hide show
  1. django_clerk_users/__init__.py +78 -7
  2. django_clerk_users/apps.py +20 -0
  3. django_clerk_users/authentication/__init__.py +24 -0
  4. django_clerk_users/authentication/backends.py +89 -0
  5. django_clerk_users/authentication/drf.py +111 -0
  6. django_clerk_users/authentication/utils.py +171 -0
  7. django_clerk_users/caching.py +161 -0
  8. django_clerk_users/checks.py +127 -0
  9. django_clerk_users/client.py +32 -0
  10. django_clerk_users/decorators.py +181 -0
  11. django_clerk_users/exceptions.py +51 -0
  12. django_clerk_users/management/__init__.py +0 -0
  13. django_clerk_users/management/commands/__init__.py +0 -0
  14. django_clerk_users/management/commands/migrate_users_to_clerk.py +223 -0
  15. django_clerk_users/management/commands/sync_clerk_organizations.py +191 -0
  16. django_clerk_users/management/commands/sync_clerk_users.py +114 -0
  17. django_clerk_users/managers.py +120 -0
  18. django_clerk_users/middleware/__init__.py +9 -0
  19. django_clerk_users/middleware/auth.py +230 -0
  20. django_clerk_users/migrations/0001_initial.py +174 -0
  21. django_clerk_users/migrations/0002_make_clerk_id_nullable.py +24 -0
  22. django_clerk_users/migrations/__init__.py +0 -0
  23. django_clerk_users/models.py +180 -0
  24. django_clerk_users/organizations/__init__.py +8 -0
  25. django_clerk_users/organizations/admin.py +81 -0
  26. django_clerk_users/organizations/apps.py +8 -0
  27. django_clerk_users/organizations/middleware.py +130 -0
  28. django_clerk_users/organizations/migrations/0001_initial.py +349 -0
  29. django_clerk_users/organizations/migrations/__init__.py +0 -0
  30. django_clerk_users/organizations/models.py +314 -0
  31. django_clerk_users/organizations/webhooks.py +417 -0
  32. django_clerk_users/settings.py +37 -0
  33. django_clerk_users/testing.py +381 -0
  34. django_clerk_users/utils.py +210 -0
  35. django_clerk_users/webhooks/__init__.py +26 -0
  36. django_clerk_users/webhooks/handlers.py +346 -0
  37. django_clerk_users/webhooks/security.py +108 -0
  38. django_clerk_users/webhooks/signals.py +42 -0
  39. django_clerk_users/webhooks/views.py +76 -0
  40. django_clerk_users-0.1.0.dist-info/METADATA +311 -0
  41. django_clerk_users-0.1.0.dist-info/RECORD +43 -0
  42. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.1.0.dist-info}/WHEEL +1 -2
  43. django_clerk_users/main.py +0 -2
  44. django_clerk_users-0.0.1.dist-info/METADATA +0 -24
  45. django_clerk_users-0.0.1.dist-info/RECORD +0 -7
  46. django_clerk_users-0.0.1.dist-info/top_level.txt +0 -1
  47. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,346 @@
1
+ """
2
+ Webhook event handlers for Clerk.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from datetime import datetime, timezone
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from django.core.cache import cache
12
+ from django.db import transaction
13
+
14
+ from django_clerk_users.settings import CLERK_WEBHOOK_DEDUP_TIMEOUT
15
+ from django_clerk_users.webhooks.signals import (
16
+ clerk_session_created,
17
+ clerk_session_ended,
18
+ clerk_user_created,
19
+ clerk_user_deleted,
20
+ clerk_user_updated,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from django_clerk_users.models import AbstractClerkUser
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def parse_clerk_timestamp(timestamp: int | str | datetime | None) -> datetime | None:
30
+ """
31
+ Parse a Clerk timestamp into a datetime object.
32
+
33
+ Clerk can send timestamps in various formats:
34
+ - Unix milliseconds (integer): 1704067200000
35
+ - ISO string: "2025-01-15T10:30:00Z"
36
+ - datetime object: already parsed
37
+
38
+ Args:
39
+ timestamp: The timestamp to parse.
40
+
41
+ Returns:
42
+ A timezone-aware datetime object or None.
43
+ """
44
+ if timestamp is None:
45
+ return None
46
+
47
+ if isinstance(timestamp, datetime):
48
+ if timestamp.tzinfo is None:
49
+ return timestamp.replace(tzinfo=timezone.utc)
50
+ return timestamp
51
+
52
+ if isinstance(timestamp, int):
53
+ # Unix milliseconds
54
+ return datetime.fromtimestamp(timestamp / 1000, tz=timezone.utc)
55
+
56
+ if isinstance(timestamp, str):
57
+ # ISO format string
58
+ try:
59
+ dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
60
+ if dt.tzinfo is None:
61
+ dt = dt.replace(tzinfo=timezone.utc)
62
+ return dt
63
+ except ValueError:
64
+ logger.warning(f"Failed to parse timestamp: {timestamp}")
65
+ return None
66
+
67
+ return None
68
+
69
+
70
+ def is_duplicate_webhook(event_type: str, instance_id: str) -> bool:
71
+ """
72
+ Check if this webhook has already been processed.
73
+
74
+ Uses cache to prevent duplicate processing within a short window.
75
+
76
+ Args:
77
+ event_type: The Clerk event type.
78
+ instance_id: The webhook instance ID.
79
+
80
+ Returns:
81
+ True if this is a duplicate, False otherwise.
82
+ """
83
+ cache_key = f"webhook:{event_type}:{instance_id}"
84
+ if cache.get(cache_key):
85
+ return True
86
+ cache.set(cache_key, True, timeout=CLERK_WEBHOOK_DEDUP_TIMEOUT)
87
+ return False
88
+
89
+
90
+ @transaction.atomic
91
+ def handle_user_created(data: dict[str, Any]) -> "AbstractClerkUser | None":
92
+ """
93
+ Handle user.created webhook event.
94
+
95
+ Args:
96
+ data: The webhook event data.
97
+
98
+ Returns:
99
+ The created user or None if creation failed.
100
+ """
101
+ from django_clerk_users.utils import update_or_create_clerk_user
102
+
103
+ clerk_user_id = data.get("id")
104
+ if not clerk_user_id:
105
+ logger.error("user.created webhook missing user ID")
106
+ return None
107
+
108
+ try:
109
+ user, created = update_or_create_clerk_user(clerk_user_id)
110
+
111
+ # Emit signal
112
+ clerk_user_created.send(
113
+ sender=user.__class__,
114
+ user=user,
115
+ clerk_data=data,
116
+ )
117
+
118
+ logger.info(f"User created via webhook: {user.email}")
119
+ return user
120
+
121
+ except Exception as e:
122
+ logger.error(f"Failed to handle user.created: {e}")
123
+ return None
124
+
125
+
126
+ @transaction.atomic
127
+ def handle_user_updated(data: dict[str, Any]) -> "AbstractClerkUser | None":
128
+ """
129
+ Handle user.updated webhook event.
130
+
131
+ Args:
132
+ data: The webhook event data.
133
+
134
+ Returns:
135
+ The updated user or None if update failed.
136
+ """
137
+ from django_clerk_users.caching import invalidate_clerk_user_cache
138
+ from django_clerk_users.utils import update_or_create_clerk_user
139
+
140
+ clerk_user_id = data.get("id")
141
+ if not clerk_user_id:
142
+ logger.error("user.updated webhook missing user ID")
143
+ return None
144
+
145
+ try:
146
+ # Invalidate cache before update
147
+ invalidate_clerk_user_cache(clerk_user_id)
148
+
149
+ user, created = update_or_create_clerk_user(clerk_user_id)
150
+
151
+ # Emit signal
152
+ clerk_user_updated.send(
153
+ sender=user.__class__,
154
+ user=user,
155
+ clerk_data=data,
156
+ )
157
+
158
+ logger.info(f"User updated via webhook: {user.email}")
159
+ return user
160
+
161
+ except Exception as e:
162
+ logger.error(f"Failed to handle user.updated: {e}")
163
+ return None
164
+
165
+
166
+ @transaction.atomic
167
+ def handle_user_deleted(data: dict[str, Any]) -> "AbstractClerkUser | None":
168
+ """
169
+ Handle user.deleted webhook event.
170
+
171
+ Performs a soft delete by setting is_active=False.
172
+
173
+ Args:
174
+ data: The webhook event data.
175
+
176
+ Returns:
177
+ The deleted user or None if deletion failed.
178
+ """
179
+ from django.contrib.auth import get_user_model
180
+
181
+ from django_clerk_users.caching import invalidate_clerk_user_cache
182
+
183
+ User = get_user_model()
184
+
185
+ clerk_user_id = data.get("id")
186
+ if not clerk_user_id:
187
+ logger.error("user.deleted webhook missing user ID")
188
+ return None
189
+
190
+ try:
191
+ # Invalidate cache
192
+ invalidate_clerk_user_cache(clerk_user_id)
193
+
194
+ user = User.objects.filter(clerk_id=clerk_user_id).first()
195
+ if not user:
196
+ logger.warning(f"User not found for deletion: {clerk_user_id}")
197
+ return None
198
+
199
+ # Soft delete
200
+ user.is_active = False
201
+ user.save(update_fields=["is_active", "updated_at"])
202
+
203
+ # Emit signal
204
+ clerk_user_deleted.send(
205
+ sender=user.__class__,
206
+ user=user,
207
+ clerk_data=data,
208
+ )
209
+
210
+ logger.info(f"User deleted via webhook: {user.email}")
211
+ return user
212
+
213
+ except Exception as e:
214
+ logger.error(f"Failed to handle user.deleted: {e}")
215
+ return None
216
+
217
+
218
+ @transaction.atomic
219
+ def handle_session_created(data: dict[str, Any]) -> None:
220
+ """
221
+ Handle session.created webhook event.
222
+
223
+ Updates the user's last_login timestamp.
224
+
225
+ Args:
226
+ data: The webhook event data.
227
+ """
228
+ from django.contrib.auth import get_user_model
229
+
230
+ User = get_user_model()
231
+
232
+ user_id = data.get("user_id")
233
+ if not user_id:
234
+ logger.error("session.created webhook missing user_id")
235
+ return
236
+
237
+ try:
238
+ user = User.objects.filter(clerk_id=user_id).first()
239
+ if not user:
240
+ logger.debug(f"User not found for session.created: {user_id}")
241
+ return
242
+
243
+ # Update last_login
244
+ user.last_login = parse_clerk_timestamp(data.get("created_at"))
245
+ user.save(update_fields=["last_login", "updated_at"])
246
+
247
+ # Emit signal
248
+ clerk_session_created.send(
249
+ sender=user.__class__,
250
+ user=user,
251
+ clerk_data=data,
252
+ )
253
+
254
+ except Exception as e:
255
+ logger.error(f"Failed to handle session.created: {e}")
256
+
257
+
258
+ @transaction.atomic
259
+ def handle_session_ended(data: dict[str, Any]) -> None:
260
+ """
261
+ Handle session.ended/removed/revoked webhook events.
262
+
263
+ Updates the user's last_logout timestamp.
264
+
265
+ Args:
266
+ data: The webhook event data.
267
+ """
268
+ from django.contrib.auth import get_user_model
269
+
270
+ User = get_user_model()
271
+
272
+ user_id = data.get("user_id")
273
+ if not user_id:
274
+ logger.error("session.ended webhook missing user_id")
275
+ return
276
+
277
+ try:
278
+ user = User.objects.filter(clerk_id=user_id).first()
279
+ if not user:
280
+ logger.debug(f"User not found for session.ended: {user_id}")
281
+ return
282
+
283
+ # Update last_logout
284
+ user.last_logout = parse_clerk_timestamp(
285
+ data.get("abandoned_at") or data.get("updated_at")
286
+ )
287
+ user.save(update_fields=["last_logout", "updated_at"])
288
+
289
+ # Emit signal
290
+ clerk_session_ended.send(
291
+ sender=user.__class__,
292
+ user=user,
293
+ clerk_data=data,
294
+ )
295
+
296
+ except Exception as e:
297
+ logger.error(f"Failed to handle session.ended: {e}")
298
+
299
+
300
+ def process_webhook_event(event_type: str, data: dict[str, Any]) -> bool:
301
+ """
302
+ Process a Clerk webhook event.
303
+
304
+ This is the main entry point for webhook event handling.
305
+ It routes events to the appropriate handler.
306
+
307
+ Args:
308
+ event_type: The Clerk event type (e.g., "user.created").
309
+ data: The event data from the webhook payload.
310
+
311
+ Returns:
312
+ True if the event was handled successfully, False otherwise.
313
+ """
314
+ handlers = {
315
+ "user.created": handle_user_created,
316
+ "user.updated": handle_user_updated,
317
+ "user.deleted": handle_user_deleted,
318
+ "session.created": handle_session_created,
319
+ "session.ended": handle_session_ended,
320
+ "session.removed": handle_session_ended,
321
+ "session.revoked": handle_session_ended,
322
+ }
323
+
324
+ handler = handlers.get(event_type)
325
+ if handler:
326
+ try:
327
+ handler(data)
328
+ return True
329
+ except Exception as e:
330
+ logger.error(f"Error handling {event_type}: {e}")
331
+ return False
332
+
333
+ # Check if organizations app handles this event
334
+ if event_type.startswith(("organization", "organizationMembership", "organizationInvitation")):
335
+ try:
336
+ from django_clerk_users.organizations.webhooks import (
337
+ process_organization_event,
338
+ )
339
+
340
+ return process_organization_event(event_type, data)
341
+ except ImportError:
342
+ logger.debug(f"Organizations app not installed, skipping {event_type}")
343
+ return True
344
+
345
+ logger.debug(f"Unhandled webhook event type: {event_type}")
346
+ return True
@@ -0,0 +1,108 @@
1
+ """
2
+ Webhook security utilities for Clerk.
3
+
4
+ Uses Svix for webhook signature verification.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import logging
11
+ from typing import TYPE_CHECKING, Any, Callable
12
+
13
+ from django.http import HttpResponseBadRequest, HttpResponseForbidden
14
+ from django.views.decorators.csrf import csrf_exempt
15
+ from svix.webhooks import Webhook, WebhookVerificationError
16
+
17
+ from django_clerk_users.exceptions import ClerkWebhookError
18
+ from django_clerk_users.settings import CLERK_WEBHOOK_SIGNING_KEY
19
+
20
+ if TYPE_CHECKING:
21
+ from django.http import HttpRequest
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def verify_clerk_webhook(request: "HttpRequest") -> dict[str, Any]:
27
+ """
28
+ Verify a Clerk webhook signature using Svix.
29
+
30
+ Args:
31
+ request: The Django HTTP request containing the webhook payload.
32
+
33
+ Returns:
34
+ The verified webhook payload as a dictionary.
35
+
36
+ Raises:
37
+ ClerkWebhookError: If verification fails or signing key is not configured.
38
+ """
39
+ if not CLERK_WEBHOOK_SIGNING_KEY:
40
+ raise ClerkWebhookError(
41
+ "CLERK_WEBHOOK_SIGNING_KEY is not configured. "
42
+ "Set it in your Django settings to enable webhook verification."
43
+ )
44
+
45
+ try:
46
+ wh = Webhook(CLERK_WEBHOOK_SIGNING_KEY)
47
+
48
+ # Svix expects specific headers
49
+ headers = {
50
+ "svix-id": request.headers.get("svix-id", ""),
51
+ "svix-timestamp": request.headers.get("svix-timestamp", ""),
52
+ "svix-signature": request.headers.get("svix-signature", ""),
53
+ }
54
+
55
+ # Verify and parse the payload
56
+ payload = wh.verify(request.body, headers)
57
+ return payload
58
+
59
+ except WebhookVerificationError as e:
60
+ logger.warning(f"Webhook verification failed: {e}")
61
+ raise ClerkWebhookError(f"Webhook verification failed: {e}") from e
62
+ except Exception as e:
63
+ logger.error(f"Unexpected error during webhook verification: {e}")
64
+ raise ClerkWebhookError(f"Webhook verification error: {e}") from e
65
+
66
+
67
+ def clerk_webhook_required(view_func: Callable) -> Callable:
68
+ """
69
+ Decorator that verifies Clerk webhook signatures.
70
+
71
+ Use this decorator on webhook view functions to automatically
72
+ verify the webhook signature and attach the verified payload
73
+ to the request.
74
+
75
+ Example:
76
+ from django_clerk_users.webhooks import clerk_webhook_required
77
+
78
+ @clerk_webhook_required
79
+ def my_webhook_view(request):
80
+ data = request.clerk_webhook_data
81
+ # Process the webhook...
82
+ return HttpResponse("OK")
83
+
84
+ The decorator:
85
+ 1. Exempts the view from CSRF protection (webhooks can't have CSRF tokens)
86
+ 2. Verifies the Svix signature
87
+ 3. Attaches the verified payload to request.clerk_webhook_data
88
+ 4. Returns 400/403 responses on verification failure
89
+ """
90
+
91
+ @csrf_exempt
92
+ @functools.wraps(view_func)
93
+ def wrapper(request: "HttpRequest", *args, **kwargs):
94
+ if request.method != "POST":
95
+ return HttpResponseBadRequest("Only POST requests are allowed")
96
+
97
+ try:
98
+ payload = verify_clerk_webhook(request)
99
+ except ClerkWebhookError as e:
100
+ logger.warning(f"Webhook verification failed: {e}")
101
+ return HttpResponseForbidden(str(e))
102
+
103
+ # Attach verified payload to request
104
+ request.clerk_webhook_data = payload # type: ignore
105
+
106
+ return view_func(request, *args, **kwargs)
107
+
108
+ return wrapper
@@ -0,0 +1,42 @@
1
+ """
2
+ Django signals for Clerk webhook events.
3
+
4
+ These signals allow you to hook into Clerk events without modifying
5
+ the core webhook handlers.
6
+
7
+ Example usage:
8
+
9
+ from django.dispatch import receiver
10
+ from django_clerk_users.webhooks.signals import clerk_user_created
11
+
12
+ @receiver(clerk_user_created)
13
+ def handle_new_user(sender, user, clerk_data, **kwargs):
14
+ # Send welcome email, create related objects, etc.
15
+ send_welcome_email(user.email)
16
+ """
17
+
18
+ from django.dispatch import Signal
19
+
20
+ # User signals
21
+ clerk_user_created = Signal() # Provides: user, clerk_data
22
+ clerk_user_updated = Signal() # Provides: user, clerk_data
23
+ clerk_user_deleted = Signal() # Provides: user, clerk_data
24
+
25
+ # Session signals
26
+ clerk_session_created = Signal() # Provides: user, clerk_data
27
+ clerk_session_ended = Signal() # Provides: user, clerk_data
28
+
29
+ # Organization signals (used by organizations sub-app)
30
+ clerk_organization_created = Signal() # Provides: organization, clerk_data
31
+ clerk_organization_updated = Signal() # Provides: organization, clerk_data
32
+ clerk_organization_deleted = Signal() # Provides: organization, clerk_data
33
+
34
+ # Membership signals (used by organizations sub-app)
35
+ clerk_membership_created = Signal() # Provides: membership, clerk_data
36
+ clerk_membership_updated = Signal() # Provides: membership, clerk_data
37
+ clerk_membership_deleted = Signal() # Provides: membership, clerk_data
38
+
39
+ # Invitation signals (used by organizations sub-app)
40
+ clerk_invitation_created = Signal() # Provides: invitation, clerk_data
41
+ clerk_invitation_accepted = Signal() # Provides: invitation, clerk_data
42
+ clerk_invitation_revoked = Signal() # Provides: invitation, clerk_data
@@ -0,0 +1,76 @@
1
+ """
2
+ Webhook views for Clerk events.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING
9
+
10
+ from django.http import HttpResponse, JsonResponse
11
+
12
+ from django_clerk_users.webhooks.handlers import is_duplicate_webhook, process_webhook_event
13
+ from django_clerk_users.webhooks.security import clerk_webhook_required
14
+
15
+ if TYPE_CHECKING:
16
+ from django.http import HttpRequest
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @clerk_webhook_required
22
+ def clerk_webhook_view(request: "HttpRequest") -> HttpResponse:
23
+ """
24
+ Handle Clerk webhook events.
25
+
26
+ This view receives webhook events from Clerk, verifies the signature,
27
+ and processes the event.
28
+
29
+ The @clerk_webhook_required decorator handles:
30
+ - CSRF exemption
31
+ - Signature verification
32
+ - Attaching verified payload to request.clerk_webhook_data
33
+
34
+ URL Configuration:
35
+ Add to your urls.py:
36
+
37
+ from django_clerk_users.webhooks import clerk_webhook_view
38
+
39
+ urlpatterns = [
40
+ path("webhooks/clerk/", clerk_webhook_view, name="clerk_webhook"),
41
+ ]
42
+
43
+ Returns:
44
+ 200 OK on success
45
+ 400 Bad Request on invalid payload
46
+ 403 Forbidden on signature verification failure
47
+ """
48
+ data = request.clerk_webhook_data # type: ignore
49
+
50
+ # Extract event metadata
51
+ event_type = data.get("type")
52
+ event_data = data.get("data", {})
53
+ event_id = data.get("id", "")
54
+
55
+ if not event_type:
56
+ logger.warning("Webhook received without event type")
57
+ return JsonResponse({"error": "Missing event type"}, status=400)
58
+
59
+ # Check for duplicate webhook
60
+ instance_id = event_data.get("id", event_id)
61
+ if is_duplicate_webhook(event_type, instance_id):
62
+ logger.debug(f"Duplicate webhook ignored: {event_type} {instance_id}")
63
+ return HttpResponse("OK (duplicate)", status=200)
64
+
65
+ logger.info(f"Processing webhook: {event_type}")
66
+
67
+ # Process the event
68
+ success = process_webhook_event(event_type, event_data)
69
+
70
+ if success:
71
+ return HttpResponse("OK", status=200)
72
+ else:
73
+ # Return 200 anyway to prevent Clerk from retrying
74
+ # Log the error for monitoring
75
+ logger.error(f"Webhook processing failed: {event_type}")
76
+ return HttpResponse("OK (processing failed)", status=200)