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
django_clerk_users/__init__.py
CHANGED
|
@@ -1,22 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
django-clerk-users: Integrate Clerk authentication with Django.
|
|
3
|
+
"""
|
|
4
|
+
|
|
1
5
|
from importlib.metadata import PackageNotFoundError, version
|
|
2
6
|
|
|
3
7
|
try:
|
|
4
8
|
__version__ = version("django-clerk-users")
|
|
5
9
|
except PackageNotFoundError:
|
|
6
|
-
# Package is not installed
|
|
7
10
|
__version__ = "unknown"
|
|
8
11
|
|
|
12
|
+
# Re-export default app config
|
|
13
|
+
default_app_config = "django_clerk_users.apps.DjangoClerkUsersConfig"
|
|
14
|
+
|
|
9
15
|
|
|
10
|
-
def __getattr__(name):
|
|
16
|
+
def __getattr__(name: str):
|
|
11
17
|
"""Lazy import to avoid loading Django models before apps are ready."""
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
# Models
|
|
19
|
+
if name == "AbstractClerkUser":
|
|
20
|
+
from django_clerk_users.models import AbstractClerkUser
|
|
21
|
+
|
|
22
|
+
return AbstractClerkUser
|
|
23
|
+
if name == "ClerkUser":
|
|
24
|
+
from django_clerk_users.models import ClerkUser
|
|
25
|
+
|
|
26
|
+
return ClerkUser
|
|
27
|
+
if name == "ClerkUserManager":
|
|
28
|
+
from django_clerk_users.models import ClerkUserManager
|
|
29
|
+
|
|
30
|
+
return ClerkUserManager
|
|
31
|
+
|
|
32
|
+
# Client
|
|
33
|
+
if name == "get_clerk_client":
|
|
34
|
+
from django_clerk_users.client import get_clerk_client
|
|
35
|
+
|
|
36
|
+
return get_clerk_client
|
|
37
|
+
|
|
38
|
+
# Exceptions
|
|
39
|
+
if name in (
|
|
40
|
+
"ClerkError",
|
|
41
|
+
"ClerkConfigurationError",
|
|
42
|
+
"ClerkAuthenticationError",
|
|
43
|
+
"ClerkTokenError",
|
|
44
|
+
"ClerkWebhookError",
|
|
45
|
+
"ClerkAPIError",
|
|
46
|
+
"ClerkUserNotFoundError",
|
|
47
|
+
"ClerkOrganizationNotFoundError",
|
|
48
|
+
):
|
|
49
|
+
from django_clerk_users import exceptions
|
|
50
|
+
|
|
51
|
+
return getattr(exceptions, name)
|
|
52
|
+
|
|
53
|
+
# Testing utilities
|
|
54
|
+
if name in (
|
|
55
|
+
"ClerkTestClient",
|
|
56
|
+
"ClerkTestMixin",
|
|
57
|
+
"TestUserData",
|
|
58
|
+
"make_test_email",
|
|
59
|
+
"make_test_phone",
|
|
60
|
+
"TEST_OTP_CODE",
|
|
61
|
+
):
|
|
62
|
+
from django_clerk_users import testing
|
|
63
|
+
|
|
64
|
+
return getattr(testing, name)
|
|
14
65
|
|
|
15
|
-
|
|
16
|
-
raise AttributeError(f"Module 'django_clerk' has no attribute '{name}'")
|
|
66
|
+
raise AttributeError(f"Module 'django_clerk_users' has no attribute '{name}'")
|
|
17
67
|
|
|
18
68
|
|
|
19
69
|
__all__ = [
|
|
20
70
|
"__version__",
|
|
21
|
-
|
|
71
|
+
# Models
|
|
72
|
+
"AbstractClerkUser",
|
|
73
|
+
"ClerkUser",
|
|
74
|
+
"ClerkUserManager",
|
|
75
|
+
# Client
|
|
76
|
+
"get_clerk_client",
|
|
77
|
+
# Exceptions
|
|
78
|
+
"ClerkError",
|
|
79
|
+
"ClerkConfigurationError",
|
|
80
|
+
"ClerkAuthenticationError",
|
|
81
|
+
"ClerkTokenError",
|
|
82
|
+
"ClerkWebhookError",
|
|
83
|
+
"ClerkAPIError",
|
|
84
|
+
"ClerkUserNotFoundError",
|
|
85
|
+
"ClerkOrganizationNotFoundError",
|
|
86
|
+
# Testing utilities
|
|
87
|
+
"ClerkTestClient",
|
|
88
|
+
"ClerkTestMixin",
|
|
89
|
+
"TestUserData",
|
|
90
|
+
"make_test_email",
|
|
91
|
+
"make_test_phone",
|
|
92
|
+
"TEST_OTP_CODE",
|
|
22
93
|
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DjangoClerkUsersConfig(AppConfig):
|
|
5
|
+
name = "django_clerk_users"
|
|
6
|
+
verbose_name = "Django Clerk Users"
|
|
7
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
# Import checks to register them with Django
|
|
11
|
+
from django_clerk_users import checks # noqa: F401
|
|
12
|
+
|
|
13
|
+
# Disconnect Django's update_last_login signal
|
|
14
|
+
# Clerk manages authentication externally
|
|
15
|
+
from django.contrib.auth import get_user_model
|
|
16
|
+
from django.contrib.auth.models import update_last_login
|
|
17
|
+
from django.contrib.auth.signals import user_logged_in
|
|
18
|
+
|
|
19
|
+
User = get_user_model()
|
|
20
|
+
user_logged_in.disconnect(update_last_login, sender=User)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication backends and utilities for django-clerk-users.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django_clerk_users.authentication.backends import ClerkBackend
|
|
6
|
+
from django_clerk_users.authentication.utils import (
|
|
7
|
+
get_clerk_payload_from_request,
|
|
8
|
+
get_or_create_user_from_payload,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ClerkBackend",
|
|
13
|
+
"get_clerk_payload_from_request",
|
|
14
|
+
"get_or_create_user_from_payload",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# Conditionally export DRF authentication if available
|
|
18
|
+
try:
|
|
19
|
+
from django_clerk_users.authentication.drf import ClerkAuthentication
|
|
20
|
+
|
|
21
|
+
__all__.append("ClerkAuthentication")
|
|
22
|
+
except ImportError:
|
|
23
|
+
# DRF is not installed
|
|
24
|
+
pass
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django authentication backend for Clerk.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from django.contrib.auth import get_user_model
|
|
11
|
+
from django.contrib.auth.backends import BaseBackend
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from django.http import HttpRequest
|
|
15
|
+
|
|
16
|
+
from django_clerk_users.models import AbstractClerkUser
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClerkBackend(BaseBackend):
|
|
22
|
+
"""
|
|
23
|
+
Django authentication backend for Clerk.
|
|
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.
|
|
28
|
+
|
|
29
|
+
To use this backend, add it to AUTHENTICATION_BACKENDS in settings:
|
|
30
|
+
|
|
31
|
+
AUTHENTICATION_BACKENDS = [
|
|
32
|
+
'django_clerk_users.authentication.ClerkBackend',
|
|
33
|
+
]
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def authenticate(
|
|
37
|
+
self,
|
|
38
|
+
request: "HttpRequest | None" = None,
|
|
39
|
+
clerk_id: str | None = None,
|
|
40
|
+
**kwargs: Any,
|
|
41
|
+
) -> "AbstractClerkUser | None":
|
|
42
|
+
"""
|
|
43
|
+
Authenticate a user by their Clerk ID.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
request: The current HTTP request (optional).
|
|
47
|
+
clerk_id: The Clerk user ID to authenticate.
|
|
48
|
+
**kwargs: Additional keyword arguments (ignored).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The authenticated user or None if authentication fails.
|
|
52
|
+
"""
|
|
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
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django REST Framework authentication class for Clerk.
|
|
3
|
+
|
|
4
|
+
This module requires djangorestframework to be installed.
|
|
5
|
+
Install it with: pip install django-clerk-users[drf]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from django.http import HttpRequest
|
|
15
|
+
|
|
16
|
+
from django_clerk_users.models import AbstractClerkUser
|
|
17
|
+
|
|
18
|
+
from django_clerk_users.authentication.utils import (
|
|
19
|
+
get_clerk_payload_from_request,
|
|
20
|
+
get_or_create_user_from_payload,
|
|
21
|
+
)
|
|
22
|
+
from django_clerk_users.exceptions import ClerkAuthenticationError, ClerkTokenError
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Defer DRF import - check at class instantiation time
|
|
27
|
+
_drf_available = False
|
|
28
|
+
_drf_import_error: str | None = None
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from rest_framework import authentication, exceptions
|
|
32
|
+
|
|
33
|
+
_drf_available = True
|
|
34
|
+
_BaseAuthentication: Any = authentication.BaseAuthentication
|
|
35
|
+
except ImportError:
|
|
36
|
+
_drf_import_error = (
|
|
37
|
+
"Django REST Framework is required for ClerkAuthentication. "
|
|
38
|
+
"Install it with: pip install django-clerk-users[drf]"
|
|
39
|
+
)
|
|
40
|
+
_BaseAuthentication = object
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ClerkAuthentication(_BaseAuthentication):
|
|
44
|
+
"""
|
|
45
|
+
Django REST Framework authentication class for Clerk.
|
|
46
|
+
|
|
47
|
+
This authentication class validates Clerk JWT tokens and returns
|
|
48
|
+
the corresponding Django user.
|
|
49
|
+
|
|
50
|
+
To use this class, add it to DEFAULT_AUTHENTICATION_CLASSES in settings:
|
|
51
|
+
|
|
52
|
+
REST_FRAMEWORK = {
|
|
53
|
+
'DEFAULT_AUTHENTICATION_CLASSES': [
|
|
54
|
+
'django_clerk_users.authentication.ClerkAuthentication',
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
if not _drf_available:
|
|
61
|
+
raise ImportError(_drf_import_error)
|
|
62
|
+
super().__init__()
|
|
63
|
+
|
|
64
|
+
def authenticate(
|
|
65
|
+
self, request: "HttpRequest"
|
|
66
|
+
) -> tuple["AbstractClerkUser", dict] | None:
|
|
67
|
+
"""
|
|
68
|
+
Authenticate the request and return a tuple of (user, auth).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
request: The incoming HTTP request.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A tuple of (user, payload) if authentication succeeds,
|
|
75
|
+
or None if no authentication credentials were provided.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
AuthenticationFailed: If authentication fails.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
payload = get_clerk_payload_from_request(request)
|
|
82
|
+
except ClerkTokenError as e:
|
|
83
|
+
raise exceptions.AuthenticationFailed(str(e))
|
|
84
|
+
|
|
85
|
+
if payload is None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
user, _ = get_or_create_user_from_payload(payload)
|
|
90
|
+
except ClerkAuthenticationError as e:
|
|
91
|
+
raise exceptions.AuthenticationFailed(str(e))
|
|
92
|
+
|
|
93
|
+
if not user.is_active:
|
|
94
|
+
raise exceptions.AuthenticationFailed("User is inactive.")
|
|
95
|
+
|
|
96
|
+
# Attach the Clerk payload to the request for later use
|
|
97
|
+
request.clerk_payload = payload # type: ignore
|
|
98
|
+
|
|
99
|
+
return (user, payload)
|
|
100
|
+
|
|
101
|
+
def authenticate_header(self, request: "HttpRequest") -> str:
|
|
102
|
+
"""
|
|
103
|
+
Return a string to be used as the WWW-Authenticate header.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
request: The incoming HTTP request.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The authentication scheme identifier.
|
|
110
|
+
"""
|
|
111
|
+
return "Bearer"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication utilities for JWT token validation and user retrieval.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from clerk_backend_api.security.types import AuthenticateRequestOptions
|
|
13
|
+
from django.core.cache import cache
|
|
14
|
+
|
|
15
|
+
from django_clerk_users.client import get_clerk_client
|
|
16
|
+
from django_clerk_users.exceptions import ClerkAuthenticationError, ClerkTokenError
|
|
17
|
+
from django_clerk_users.settings import CLERK_AUTH_PARTIES, CLERK_CACHE_TIMEOUT
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from django.http import HttpRequest
|
|
21
|
+
|
|
22
|
+
from django_clerk_users.models import AbstractClerkUser
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_bearer_token(request: "HttpRequest") -> str | None:
|
|
28
|
+
"""
|
|
29
|
+
Extract the Bearer token from the Authorization header.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
request: The Django HTTP request.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The bearer token string or None if not present.
|
|
36
|
+
"""
|
|
37
|
+
auth_header = request.headers.get("Authorization", "")
|
|
38
|
+
if auth_header.startswith("Bearer "):
|
|
39
|
+
return auth_header[7:] # Remove "Bearer " prefix
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_clerk_payload_from_request(request: "HttpRequest") -> dict[str, Any] | None:
|
|
44
|
+
"""
|
|
45
|
+
Validate a Clerk JWT token and return the payload.
|
|
46
|
+
|
|
47
|
+
This function extracts the JWT from the Authorization header,
|
|
48
|
+
validates it using the Clerk SDK, and returns the decoded payload.
|
|
49
|
+
Results are cached to avoid repeated validation.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
request: The Django HTTP request.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The decoded JWT payload dict or None if validation fails.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ClerkTokenError: If the token is invalid or expired.
|
|
59
|
+
"""
|
|
60
|
+
token = get_bearer_token(request)
|
|
61
|
+
if not token:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
# Create a cache key based on the token hash (never store raw tokens)
|
|
65
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
66
|
+
cache_key = f"clerk:payload:{token_hash}"
|
|
67
|
+
|
|
68
|
+
# Check cache first
|
|
69
|
+
cached_payload = cache.get(cache_key)
|
|
70
|
+
if cached_payload is not None:
|
|
71
|
+
return cached_payload
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
clerk = get_clerk_client()
|
|
75
|
+
|
|
76
|
+
# Build auth options with authorized parties
|
|
77
|
+
auth_options = None
|
|
78
|
+
if CLERK_AUTH_PARTIES:
|
|
79
|
+
auth_options = AuthenticateRequestOptions(
|
|
80
|
+
authorized_parties=CLERK_AUTH_PARTIES
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Validate the token using Clerk SDK
|
|
84
|
+
request_state = clerk.authenticate_request(request, options=auth_options)
|
|
85
|
+
|
|
86
|
+
if not request_state.is_signed_in:
|
|
87
|
+
logger.debug("Clerk token validation failed: not signed in")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
payload = request_state.payload
|
|
91
|
+
if not payload:
|
|
92
|
+
logger.debug("Clerk token validation failed: no payload")
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
# Calculate cache timeout based on token expiration
|
|
96
|
+
# This ensures we never use an expired token from cache
|
|
97
|
+
exp = payload.get("exp")
|
|
98
|
+
if exp:
|
|
99
|
+
current_time = int(time.time())
|
|
100
|
+
# Cache until 1 minute before expiration, with a minimum of 60s
|
|
101
|
+
# and maximum of CLERK_CACHE_TIMEOUT (default 5 minutes)
|
|
102
|
+
time_until_exp = exp - current_time
|
|
103
|
+
cache_timeout = max(60, min(time_until_exp - 60, CLERK_CACHE_TIMEOUT))
|
|
104
|
+
else:
|
|
105
|
+
# Default to cache timeout if no exp claim
|
|
106
|
+
cache_timeout = CLERK_CACHE_TIMEOUT
|
|
107
|
+
|
|
108
|
+
cache.set(cache_key, payload, timeout=cache_timeout)
|
|
109
|
+
logger.debug(f"Cached Clerk payload for {cache_timeout} seconds")
|
|
110
|
+
|
|
111
|
+
return payload
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.warning(f"Clerk token validation error: {e}")
|
|
115
|
+
raise ClerkTokenError(f"Token validation failed: {e}") from e
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_or_create_user_from_payload(
|
|
119
|
+
payload: dict[str, Any],
|
|
120
|
+
) -> tuple["AbstractClerkUser", bool]:
|
|
121
|
+
"""
|
|
122
|
+
Get or create a Django user from a Clerk JWT payload.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
payload: The decoded Clerk JWT payload.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A tuple of (user, created) where created is True if the user was newly created.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ClerkAuthenticationError: If the payload is invalid or user creation fails.
|
|
132
|
+
"""
|
|
133
|
+
from django.contrib.auth import get_user_model
|
|
134
|
+
|
|
135
|
+
User = get_user_model()
|
|
136
|
+
|
|
137
|
+
clerk_user_id = payload.get("sub")
|
|
138
|
+
if not clerk_user_id:
|
|
139
|
+
raise ClerkAuthenticationError("Invalid payload: missing 'sub' claim")
|
|
140
|
+
|
|
141
|
+
# Try to get existing user first
|
|
142
|
+
user = User.objects.filter(clerk_id=clerk_user_id).first()
|
|
143
|
+
if user:
|
|
144
|
+
return user, False
|
|
145
|
+
|
|
146
|
+
# User doesn't exist - we need to create them
|
|
147
|
+
# Fetch full user data from Clerk API
|
|
148
|
+
try:
|
|
149
|
+
from django_clerk_users.utils import update_or_create_clerk_user
|
|
150
|
+
|
|
151
|
+
user, created = update_or_create_clerk_user(clerk_user_id)
|
|
152
|
+
return user, created
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.error(f"Failed to create user from Clerk: {e}")
|
|
155
|
+
raise ClerkAuthenticationError(f"Failed to create user: {e}") from e
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_user_from_clerk_id(clerk_id: str) -> "AbstractClerkUser | None":
|
|
159
|
+
"""
|
|
160
|
+
Get a Django user by their Clerk ID.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
clerk_id: The Clerk user ID.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The user instance or None if not found.
|
|
167
|
+
"""
|
|
168
|
+
from django.contrib.auth import get_user_model
|
|
169
|
+
|
|
170
|
+
User = get_user_model()
|
|
171
|
+
return User.objects.filter(clerk_id=clerk_id).first()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Caching utilities for django-clerk-users.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from django.core.cache import cache
|
|
11
|
+
|
|
12
|
+
from django_clerk_users.settings import CLERK_CACHE_TIMEOUT, CLERK_ORG_CACHE_TIMEOUT
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from django_clerk_users.models import AbstractClerkUser
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Cache key prefixes
|
|
21
|
+
USER_CACHE_PREFIX = "clerk:user:"
|
|
22
|
+
ORG_CACHE_PREFIX = "clerk:org:"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_user_cache_key(clerk_id: str) -> str:
|
|
26
|
+
"""Get the cache key for a user."""
|
|
27
|
+
return f"{USER_CACHE_PREFIX}{clerk_id}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_org_cache_key(clerk_id: str) -> str:
|
|
31
|
+
"""Get the cache key for an organization."""
|
|
32
|
+
return f"{ORG_CACHE_PREFIX}{clerk_id}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_cached_user(clerk_id: str, query_db: bool = True) -> "AbstractClerkUser | None":
|
|
36
|
+
"""
|
|
37
|
+
Get a user from cache or database.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
clerk_id: The Clerk user ID.
|
|
41
|
+
query_db: If True, query database on cache miss and cache the result.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The user instance, or None if not found.
|
|
45
|
+
"""
|
|
46
|
+
from django.contrib.auth import get_user_model
|
|
47
|
+
|
|
48
|
+
if not clerk_id:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
cache_key = get_user_cache_key(clerk_id)
|
|
52
|
+
cached_user = cache.get(cache_key)
|
|
53
|
+
|
|
54
|
+
if cached_user is not None:
|
|
55
|
+
# Cache hit - could be a User instance or False (cached "not found")
|
|
56
|
+
return cached_user if cached_user is not False else None
|
|
57
|
+
|
|
58
|
+
if not query_db:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# Cache miss - query database
|
|
62
|
+
User = get_user_model()
|
|
63
|
+
try:
|
|
64
|
+
user = User.objects.get(clerk_id=clerk_id, is_active=True)
|
|
65
|
+
# Cache the user instance
|
|
66
|
+
cache.set(cache_key, user, timeout=CLERK_CACHE_TIMEOUT)
|
|
67
|
+
return user
|
|
68
|
+
except User.DoesNotExist:
|
|
69
|
+
# Cache the "not found" result to prevent repeated DB queries
|
|
70
|
+
cache.set(cache_key, False, timeout=CLERK_CACHE_TIMEOUT)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_cached_user(clerk_id: str, user: "AbstractClerkUser | None") -> None:
|
|
75
|
+
"""
|
|
76
|
+
Cache a user by Clerk ID.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
clerk_id: The Clerk user ID.
|
|
80
|
+
user: The user to cache, or None to cache "not found".
|
|
81
|
+
"""
|
|
82
|
+
cache_key = get_user_cache_key(clerk_id)
|
|
83
|
+
# Cache False for "not found" to distinguish from "not cached"
|
|
84
|
+
value = user if user is not None else False
|
|
85
|
+
cache.set(cache_key, value, timeout=CLERK_CACHE_TIMEOUT)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def invalidate_clerk_user_cache(clerk_id: str) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Invalidate the cache for a user.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
clerk_id: The Clerk user ID.
|
|
94
|
+
"""
|
|
95
|
+
cache_key = get_user_cache_key(clerk_id)
|
|
96
|
+
cache.delete(cache_key)
|
|
97
|
+
logger.debug(f"Invalidated user cache: {clerk_id}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_cached_organization(clerk_id: str, query_db: bool = True):
|
|
101
|
+
"""
|
|
102
|
+
Get an organization from cache or database.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
clerk_id: The Clerk organization ID.
|
|
106
|
+
query_db: If True, query database on cache miss and cache the result.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The organization instance, or None if not found.
|
|
110
|
+
"""
|
|
111
|
+
if not clerk_id:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
cache_key = get_org_cache_key(clerk_id)
|
|
115
|
+
cached_org = cache.get(cache_key)
|
|
116
|
+
|
|
117
|
+
if cached_org is not None:
|
|
118
|
+
# Cache hit - could be an Organization instance or False (cached "not found")
|
|
119
|
+
return cached_org if cached_org is not False else None
|
|
120
|
+
|
|
121
|
+
if not query_db:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
# Cache miss - query database
|
|
125
|
+
try:
|
|
126
|
+
from django_clerk_users.organizations.models import Organization
|
|
127
|
+
|
|
128
|
+
org = Organization.objects.get(clerk_id=clerk_id, is_active=True)
|
|
129
|
+
# Cache the organization instance
|
|
130
|
+
cache.set(cache_key, org, timeout=CLERK_ORG_CACHE_TIMEOUT)
|
|
131
|
+
return org
|
|
132
|
+
except Exception:
|
|
133
|
+
# Cache the "not found" result to prevent repeated DB queries
|
|
134
|
+
cache.set(cache_key, False, timeout=CLERK_ORG_CACHE_TIMEOUT)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def set_cached_organization(clerk_id: str, organization) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Cache an organization by Clerk ID.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
clerk_id: The Clerk organization ID.
|
|
144
|
+
organization: The organization to cache, or None to cache "not found".
|
|
145
|
+
"""
|
|
146
|
+
cache_key = get_org_cache_key(clerk_id)
|
|
147
|
+
# Cache False for "not found" to distinguish from "not cached"
|
|
148
|
+
value = organization if organization is not None else False
|
|
149
|
+
cache.set(cache_key, value, timeout=CLERK_ORG_CACHE_TIMEOUT)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def invalidate_organization_cache(clerk_id: str) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Invalidate the cache for an organization.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
clerk_id: The Clerk organization ID.
|
|
158
|
+
"""
|
|
159
|
+
cache_key = get_org_cache_key(clerk_id)
|
|
160
|
+
cache.delete(cache_key)
|
|
161
|
+
logger.debug(f"Invalidated organization cache: {clerk_id}")
|