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.
- 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 +120 -0
- django_clerk_users/middleware/__init__.py +9 -0
- django_clerk_users/middleware/auth.py +230 -0
- django_clerk_users/migrations/0001_initial.py +174 -0
- django_clerk_users/migrations/0002_make_clerk_id_nullable.py +24 -0
- django_clerk_users/migrations/__init__.py +0 -0
- django_clerk_users/models.py +180 -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/migrations/0001_initial.py +349 -0
- django_clerk_users/organizations/migrations/__init__.py +0 -0
- django_clerk_users/organizations/models.py +314 -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.1.0.dist-info/METADATA +311 -0
- django_clerk_users-0.1.0.dist-info/RECORD +43 -0
- {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.1.0.dist-info}/WHEEL +1 -2
- 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/top_level.txt +0 -1
- {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)
|