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.
Files changed (44) 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 +121 -0
  18. django_clerk_users/middleware/__init__.py +9 -0
  19. django_clerk_users/middleware/auth.py +201 -0
  20. django_clerk_users/migrations/0001_initial.py +174 -0
  21. django_clerk_users/migrations/__init__.py +0 -0
  22. django_clerk_users/models.py +174 -0
  23. django_clerk_users/organizations/__init__.py +8 -0
  24. django_clerk_users/organizations/admin.py +81 -0
  25. django_clerk_users/organizations/apps.py +8 -0
  26. django_clerk_users/organizations/middleware.py +130 -0
  27. django_clerk_users/organizations/models.py +316 -0
  28. django_clerk_users/organizations/webhooks.py +417 -0
  29. django_clerk_users/settings.py +37 -0
  30. django_clerk_users/testing.py +381 -0
  31. django_clerk_users/utils.py +210 -0
  32. django_clerk_users/webhooks/__init__.py +26 -0
  33. django_clerk_users/webhooks/handlers.py +346 -0
  34. django_clerk_users/webhooks/security.py +108 -0
  35. django_clerk_users/webhooks/signals.py +42 -0
  36. django_clerk_users/webhooks/views.py +76 -0
  37. django_clerk_users-0.0.2.dist-info/METADATA +228 -0
  38. django_clerk_users-0.0.2.dist-info/RECORD +41 -0
  39. django_clerk_users/main.py +0 -2
  40. django_clerk_users-0.0.1.dist-info/METADATA +0 -24
  41. django_clerk_users-0.0.1.dist-info/RECORD +0 -7
  42. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/WHEEL +0 -0
  43. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/licenses/LICENSE +0 -0
  44. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,381 @@
1
+ """
2
+ Testing utilities for django-clerk-users.
3
+
4
+ Provides helpers for creating test users via Clerk's Backend API,
5
+ useful for integration testing and local development.
6
+
7
+ See: https://clerk.com/docs/testing/overview
8
+
9
+ Usage:
10
+ from django_clerk_users.testing import ClerkTestClient
11
+
12
+ # In your test setup
13
+ client = ClerkTestClient()
14
+ user_data = client.create_test_user(email="test+clerk_test@example.com")
15
+ token = client.get_session_token(user_data["id"])
16
+
17
+ # Make authenticated requests
18
+ response = self.client.get(
19
+ "/api/protected/",
20
+ HTTP_AUTHORIZATION=f"Bearer {token}"
21
+ )
22
+
23
+ Test Email/Phone Patterns:
24
+ - Test emails: Use `+clerk_test` suffix (e.g., "jane+clerk_test@example.com")
25
+ - Test phones: Use `+1 (XXX) 555-0100` through `+1 (XXX) 555-0199`
26
+ - Fixed OTP code for both: 424242
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import uuid
32
+ from dataclasses import dataclass
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ from django_clerk_users.client import get_clerk_client
36
+
37
+ if TYPE_CHECKING:
38
+ from clerk_backend_api import Clerk
39
+
40
+
41
+ # Test mode constants
42
+ TEST_OTP_CODE = "424242"
43
+ TEST_EMAIL_SUFFIX = "+clerk_test"
44
+ TEST_PHONE_PREFIX = "+1"
45
+ TEST_PHONE_PATTERN = "555-01" # 555-0100 through 555-0199
46
+
47
+
48
+ def make_test_email(base: str = "testuser", domain: str = "example.com") -> str:
49
+ """
50
+ Generate a test email address with the +clerk_test suffix.
51
+
52
+ These emails won't trigger actual email delivery in test mode
53
+ and use the fixed OTP code 424242.
54
+
55
+ Args:
56
+ base: The base username (default: "testuser")
57
+ domain: The email domain (default: "example.com")
58
+
59
+ Returns:
60
+ A test email like "testuser+clerk_test@example.com"
61
+ """
62
+ unique_id = uuid.uuid4().hex[:8]
63
+ return f"{base}+clerk_test_{unique_id}@{domain}"
64
+
65
+
66
+ def make_test_phone(area_code: str = "201", suffix: int = 0) -> str:
67
+ """
68
+ Generate a test phone number using Clerk's reserved test range.
69
+
70
+ These phone numbers won't trigger actual SMS delivery in test mode
71
+ and use the fixed OTP code 424242.
72
+
73
+ Args:
74
+ area_code: US area code (default: "201")
75
+ suffix: Number from 0-99 for the 555-01XX range (default: 0)
76
+
77
+ Returns:
78
+ A test phone like "+12015550100"
79
+ """
80
+ suffix = max(0, min(99, suffix)) # Clamp to 0-99
81
+ return f"+1{area_code}55501{suffix:02d}"
82
+
83
+
84
+ @dataclass
85
+ class TestUserData:
86
+ """Data returned when creating a test user."""
87
+
88
+ id: str
89
+ email: str | None
90
+ first_name: str | None
91
+ last_name: str | None
92
+ phone_number: str | None
93
+ raw_response: dict[str, Any]
94
+
95
+ @classmethod
96
+ def from_clerk_response(cls, response: Any) -> "TestUserData":
97
+ """Create TestUserData from a Clerk API response."""
98
+ # Handle both dict and object responses
99
+ if hasattr(response, "id"):
100
+ return cls(
101
+ id=response.id,
102
+ email=getattr(response, "email_addresses", [{}])[0].get("email_address")
103
+ if getattr(response, "email_addresses", None)
104
+ else None,
105
+ first_name=getattr(response, "first_name", None),
106
+ last_name=getattr(response, "last_name", None),
107
+ phone_number=getattr(response, "phone_numbers", [{}])[0].get(
108
+ "phone_number"
109
+ )
110
+ if getattr(response, "phone_numbers", None)
111
+ else None,
112
+ raw_response=response.__dict__
113
+ if hasattr(response, "__dict__")
114
+ else {},
115
+ )
116
+ else:
117
+ # Dict response
118
+ return cls(
119
+ id=response.get("id", ""),
120
+ email=response.get("email_addresses", [{}])[0].get("email_address"),
121
+ first_name=response.get("first_name"),
122
+ last_name=response.get("last_name"),
123
+ phone_number=response.get("phone_numbers", [{}])[0].get("phone_number")
124
+ if response.get("phone_numbers")
125
+ else None,
126
+ raw_response=response,
127
+ )
128
+
129
+
130
+ class ClerkTestClient:
131
+ """
132
+ Client for creating test users and sessions via Clerk's Backend API.
133
+
134
+ This client wraps Clerk's Backend API to provide convenient methods
135
+ for testing scenarios. It's designed for use in development and test
136
+ environments only.
137
+
138
+ Example:
139
+ client = ClerkTestClient()
140
+
141
+ # Create a test user
142
+ user = client.create_test_user()
143
+
144
+ # Get a session token for authenticated requests
145
+ token = client.get_session_token(user.id)
146
+
147
+ # Use in Django test client
148
+ response = self.client.get(
149
+ "/api/protected/",
150
+ HTTP_AUTHORIZATION=f"Bearer {token}"
151
+ )
152
+
153
+ # Cleanup after test
154
+ client.delete_user(user.id)
155
+ """
156
+
157
+ def __init__(self, clerk_client: "Clerk | None" = None):
158
+ """
159
+ Initialize the test client.
160
+
161
+ Args:
162
+ clerk_client: Optional Clerk client instance. If not provided,
163
+ uses the configured client from django settings.
164
+ """
165
+ self._client = clerk_client
166
+
167
+ @property
168
+ def client(self) -> "Clerk":
169
+ """Get the Clerk client, initializing if needed."""
170
+ if self._client is None:
171
+ self._client = get_clerk_client()
172
+ return self._client
173
+
174
+ def create_test_user(
175
+ self,
176
+ email: str | None = None,
177
+ first_name: str = "Test",
178
+ last_name: str = "User",
179
+ password: str | None = None,
180
+ phone_number: str | None = None,
181
+ **kwargs: Any,
182
+ ) -> TestUserData:
183
+ """
184
+ Create a test user via Clerk's Backend API.
185
+
186
+ Args:
187
+ email: Email address. If None, generates a test email.
188
+ first_name: User's first name (default: "Test")
189
+ last_name: User's last name (default: "User")
190
+ password: Optional password. If not provided, user is passwordless.
191
+ phone_number: Optional phone number for SMS auth.
192
+ **kwargs: Additional fields to pass to Clerk API.
193
+
194
+ Returns:
195
+ TestUserData with the created user's information.
196
+
197
+ Example:
198
+ # Create user with auto-generated test email
199
+ user = client.create_test_user()
200
+
201
+ # Create user with specific email (use +clerk_test for test mode)
202
+ user = client.create_test_user(email="admin+clerk_test@example.com")
203
+
204
+ # Create user with password for email/password auth
205
+ user = client.create_test_user(password="testpass123")
206
+ """
207
+ if email is None:
208
+ email = make_test_email()
209
+
210
+ create_params: dict[str, Any] = {
211
+ "email_address": [email],
212
+ "first_name": first_name,
213
+ "last_name": last_name,
214
+ **kwargs,
215
+ }
216
+
217
+ if password:
218
+ create_params["password"] = password
219
+
220
+ if phone_number:
221
+ create_params["phone_number"] = [phone_number]
222
+
223
+ response = self.client.users.create(**create_params)
224
+ return TestUserData.from_clerk_response(response)
225
+
226
+ def create_session(self, user_id: str) -> dict[str, Any]:
227
+ """
228
+ Create a session for a user.
229
+
230
+ Args:
231
+ user_id: The Clerk user ID.
232
+
233
+ Returns:
234
+ Session data including the session ID.
235
+ """
236
+ response = self.client.sessions.create(user_id=user_id)
237
+ if hasattr(response, "__dict__"):
238
+ return {"id": response.id, "user_id": response.user_id}
239
+ return response
240
+
241
+ def get_session_token(self, user_id: str, session_id: str | None = None) -> str:
242
+ """
243
+ Get a session token for making authenticated API requests.
244
+
245
+ Creates a session if session_id is not provided, then generates
246
+ a session token.
247
+
248
+ Note: Clerk session tokens are short-lived (60 seconds). For longer
249
+ tests, you may need to refresh the token.
250
+
251
+ Args:
252
+ user_id: The Clerk user ID.
253
+ session_id: Optional existing session ID. If not provided,
254
+ creates a new session.
255
+
256
+ Returns:
257
+ A JWT session token for use in Authorization header.
258
+
259
+ Example:
260
+ token = client.get_session_token(user.id)
261
+ response = requests.get(
262
+ "http://localhost:8000/api/protected/",
263
+ headers={"Authorization": f"Bearer {token}"}
264
+ )
265
+ """
266
+ if session_id is None:
267
+ session = self.create_session(user_id)
268
+ session_id = session["id"]
269
+
270
+ response = self.client.sessions.create_session_token(session_id=session_id)
271
+
272
+ if hasattr(response, "jwt"):
273
+ return response.jwt
274
+ return response.get("jwt", "")
275
+
276
+ def delete_user(self, user_id: str) -> bool:
277
+ """
278
+ Delete a test user.
279
+
280
+ Call this in test teardown to clean up created users.
281
+
282
+ Args:
283
+ user_id: The Clerk user ID to delete.
284
+
285
+ Returns:
286
+ True if deletion was successful.
287
+ """
288
+ try:
289
+ self.client.users.delete(user_id=user_id)
290
+ return True
291
+ except Exception:
292
+ return False
293
+
294
+ def get_testing_token(self) -> str:
295
+ """
296
+ Get a testing token for bypassing bot detection.
297
+
298
+ Testing tokens are used to bypass Clerk's bot detection when
299
+ making Frontend API requests during automated testing.
300
+
301
+ Returns:
302
+ A testing token string.
303
+
304
+ See: https://clerk.com/docs/testing/testing-tokens
305
+ """
306
+ response = self.client.testing_tokens.create()
307
+ if hasattr(response, "token"):
308
+ return response.token
309
+ return response.get("token", "")
310
+
311
+
312
+ class ClerkTestMixin:
313
+ """
314
+ Mixin for Django TestCase classes that need Clerk test users.
315
+
316
+ Provides setUp/tearDown helpers for creating and cleaning up test users.
317
+
318
+ Example:
319
+ from django.test import TestCase
320
+ from django_clerk_users.testing import ClerkTestMixin
321
+
322
+ class MyAPITestCase(ClerkTestMixin, TestCase):
323
+ def test_protected_endpoint(self):
324
+ # self.test_user and self.session_token are available
325
+ response = self.client.get(
326
+ "/api/protected/",
327
+ HTTP_AUTHORIZATION=f"Bearer {self.session_token}"
328
+ )
329
+ self.assertEqual(response.status_code, 200)
330
+ """
331
+
332
+ clerk_test_client: ClerkTestClient
333
+ test_user: TestUserData
334
+ session_token: str
335
+ _created_users: list[str]
336
+
337
+ def setUp(self) -> None:
338
+ """Set up test fixtures including a test user."""
339
+ super().setUp() # type: ignore[misc]
340
+ self.clerk_test_client = ClerkTestClient()
341
+ self._created_users = []
342
+
343
+ # Create a default test user
344
+ self.test_user = self.create_test_user()
345
+ self.session_token = self.clerk_test_client.get_session_token(self.test_user.id)
346
+
347
+ def tearDown(self) -> None:
348
+ """Clean up created test users."""
349
+ for user_id in self._created_users:
350
+ self.clerk_test_client.delete_user(user_id)
351
+ super().tearDown() # type: ignore[misc]
352
+
353
+ def create_test_user(self, **kwargs: Any) -> TestUserData:
354
+ """
355
+ Create a test user and track it for cleanup.
356
+
357
+ Args:
358
+ **kwargs: Arguments to pass to ClerkTestClient.create_test_user()
359
+
360
+ Returns:
361
+ TestUserData for the created user.
362
+ """
363
+ user = self.clerk_test_client.create_test_user(**kwargs)
364
+ self._created_users.append(user.id)
365
+ return user
366
+
367
+ def get_auth_header(self, user: TestUserData | None = None) -> dict[str, str]:
368
+ """
369
+ Get an Authorization header dict for a user.
370
+
371
+ Args:
372
+ user: The user to get a token for. Defaults to self.test_user.
373
+
374
+ Returns:
375
+ Dict with HTTP_AUTHORIZATION key for use with Django test client.
376
+ """
377
+ if user is None:
378
+ return {"HTTP_AUTHORIZATION": f"Bearer {self.session_token}"}
379
+
380
+ token = self.clerk_test_client.get_session_token(user.id)
381
+ return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
@@ -0,0 +1,210 @@
1
+ """
2
+ Core utilities for django-clerk-users.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from django_clerk_users.caching import (
11
+ get_cached_user,
12
+ invalidate_clerk_user_cache,
13
+ set_cached_user,
14
+ )
15
+ from django_clerk_users.client import get_clerk_client
16
+ from django_clerk_users.exceptions import ClerkAPIError, ClerkUserNotFoundError
17
+
18
+ if TYPE_CHECKING:
19
+ from django_clerk_users.models import AbstractClerkUser
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def update_or_create_clerk_user(
25
+ clerk_user_id: str,
26
+ ) -> tuple["AbstractClerkUser", bool]:
27
+ """
28
+ Update or create a Django user from Clerk data.
29
+
30
+ Fetches user data from the Clerk API and creates or updates
31
+ the corresponding Django user.
32
+
33
+ Args:
34
+ clerk_user_id: The Clerk user ID.
35
+
36
+ Returns:
37
+ A tuple of (user, created) where created is True if the user was
38
+ newly created.
39
+
40
+ Raises:
41
+ ClerkUserNotFoundError: If the user is not found in Clerk.
42
+ ClerkAPIError: If the Clerk API returns an error.
43
+ """
44
+ from django.contrib.auth import get_user_model
45
+
46
+ User = get_user_model()
47
+
48
+ try:
49
+ # Fetch user from Clerk API
50
+ clerk = get_clerk_client()
51
+ clerk_user = clerk.users.get(user_id=clerk_user_id)
52
+
53
+ if not clerk_user:
54
+ raise ClerkUserNotFoundError(f"User not found in Clerk: {clerk_user_id}")
55
+
56
+ # Extract email from email_addresses array
57
+ primary_email = None
58
+ email_addresses = getattr(clerk_user, "email_addresses", []) or []
59
+ for email_obj in email_addresses:
60
+ email_id = getattr(clerk_user, "primary_email_address_id", None)
61
+ if email_id and getattr(email_obj, "id", None) == email_id:
62
+ primary_email = getattr(email_obj, "email_address", None)
63
+ break
64
+ if not primary_email and email_addresses:
65
+ # Fallback to first email
66
+ primary_email = getattr(email_addresses[0], "email_address", None)
67
+
68
+ if not primary_email:
69
+ raise ClerkAPIError(f"User {clerk_user_id} has no email address")
70
+
71
+ # Prepare user data
72
+ user_data = {
73
+ "email": primary_email,
74
+ "first_name": getattr(clerk_user, "first_name", "") or "",
75
+ "last_name": getattr(clerk_user, "last_name", "") or "",
76
+ "image_url": getattr(clerk_user, "image_url", "") or "",
77
+ }
78
+
79
+ # Update or create the Django user
80
+ user, created = User.objects.update_or_create(
81
+ clerk_id=clerk_user_id,
82
+ defaults=user_data,
83
+ )
84
+
85
+ # Update cache
86
+ set_cached_user(clerk_user_id, user)
87
+
88
+ return user, created
89
+
90
+ except ClerkUserNotFoundError:
91
+ raise
92
+ except Exception as e:
93
+ logger.error(f"Failed to fetch/create user from Clerk: {e}")
94
+ raise ClerkAPIError(f"Failed to fetch user from Clerk: {e}") from e
95
+
96
+
97
+ def get_clerk_user(clerk_user_id: str) -> "AbstractClerkUser | None":
98
+ """
99
+ Get a Django user by their Clerk ID.
100
+
101
+ Checks the cache first, then the database.
102
+
103
+ Args:
104
+ clerk_user_id: The Clerk user ID.
105
+
106
+ Returns:
107
+ The user instance or None if not found.
108
+ """
109
+ from django.contrib.auth import get_user_model
110
+
111
+ User = get_user_model()
112
+
113
+ # Check cache first
114
+ cached = get_cached_user(clerk_user_id)
115
+ if cached is not None:
116
+ if cached is False:
117
+ return None # Cached as "not found"
118
+ return cached
119
+
120
+ # Query database
121
+ user = User.objects.filter(clerk_id=clerk_user_id).first()
122
+
123
+ # Update cache
124
+ set_cached_user(clerk_user_id, user)
125
+
126
+ return user
127
+
128
+
129
+ def sync_user_from_clerk(clerk_user_id: str) -> "AbstractClerkUser | None":
130
+ """
131
+ Force sync a user from Clerk, ignoring cache.
132
+
133
+ Args:
134
+ clerk_user_id: The Clerk user ID.
135
+
136
+ Returns:
137
+ The synced user or None if sync failed.
138
+ """
139
+ # Invalidate cache
140
+ invalidate_clerk_user_cache(clerk_user_id)
141
+
142
+ try:
143
+ user, _ = update_or_create_clerk_user(clerk_user_id)
144
+ return user
145
+ except Exception as e:
146
+ logger.error(f"Failed to sync user: {e}")
147
+ return None
148
+
149
+
150
+ def get_user_metadata(clerk_user_id: str) -> dict[str, Any]:
151
+ """
152
+ Get user metadata from Clerk.
153
+
154
+ Args:
155
+ clerk_user_id: The Clerk user ID.
156
+
157
+ Returns:
158
+ A dict containing public and private metadata.
159
+ """
160
+ try:
161
+ clerk = get_clerk_client()
162
+ clerk_user = clerk.users.get(user_id=clerk_user_id)
163
+
164
+ if not clerk_user:
165
+ return {"public": {}, "private": {}}
166
+
167
+ return {
168
+ "public": getattr(clerk_user, "public_metadata", {}) or {},
169
+ "private": getattr(clerk_user, "private_metadata", {}) or {},
170
+ }
171
+
172
+ except Exception as e:
173
+ logger.error(f"Failed to get user metadata: {e}")
174
+ return {"public": {}, "private": {}}
175
+
176
+
177
+ def update_user_metadata(
178
+ clerk_user_id: str,
179
+ public_metadata: dict[str, Any] | None = None,
180
+ private_metadata: dict[str, Any] | None = None,
181
+ ) -> bool:
182
+ """
183
+ Update user metadata in Clerk.
184
+
185
+ Args:
186
+ clerk_user_id: The Clerk user ID.
187
+ public_metadata: Public metadata to merge (optional).
188
+ private_metadata: Private metadata to merge (optional).
189
+
190
+ Returns:
191
+ True if update succeeded, False otherwise.
192
+ """
193
+ try:
194
+ clerk = get_clerk_client()
195
+
196
+ update_data = {}
197
+ if public_metadata is not None:
198
+ update_data["public_metadata"] = public_metadata
199
+ if private_metadata is not None:
200
+ update_data["private_metadata"] = private_metadata
201
+
202
+ if not update_data:
203
+ return True
204
+
205
+ clerk.users.update(user_id=clerk_user_id, **update_data)
206
+ return True
207
+
208
+ except Exception as e:
209
+ logger.error(f"Failed to update user metadata: {e}")
210
+ return False
@@ -0,0 +1,26 @@
1
+ """
2
+ Webhook handling for Clerk events.
3
+ """
4
+
5
+ from django_clerk_users.webhooks.security import (
6
+ clerk_webhook_required,
7
+ verify_clerk_webhook,
8
+ )
9
+ from django_clerk_users.webhooks.signals import (
10
+ clerk_user_created,
11
+ clerk_user_deleted,
12
+ clerk_user_updated,
13
+ )
14
+ from django_clerk_users.webhooks.views import clerk_webhook_view
15
+
16
+ __all__ = [
17
+ # Security
18
+ "clerk_webhook_required",
19
+ "verify_clerk_webhook",
20
+ # Signals
21
+ "clerk_user_created",
22
+ "clerk_user_updated",
23
+ "clerk_user_deleted",
24
+ # Views
25
+ "clerk_webhook_view",
26
+ ]