django-supabase 1.0.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_supabase/__init__.py +4 -0
- django_supabase/apps.py +13 -0
- django_supabase/auth/__init__.py +1 -0
- django_supabase/auth/apps.py +8 -0
- django_supabase/auth/backends.py +121 -0
- django_supabase/auth/middleware.py +33 -0
- django_supabase/auth/migrations/0001_initial.py +63 -0
- django_supabase/auth/migrations/__init__.py +0 -0
- django_supabase/auth/models.py +36 -0
- django_supabase/auth/sync.py +228 -0
- django_supabase/auth/tokens.py +93 -0
- django_supabase/conf.py +70 -0
- django_supabase/db/__init__.py +0 -0
- django_supabase/db/client.py +79 -0
- django_supabase/db/engine.py +66 -0
- django_supabase/defaults.py +79 -0
- django_supabase/env.py +46 -0
- django_supabase/management/__init__.py +0 -0
- django_supabase/management/commands/__init__.py +0 -0
- django_supabase/management/commands/create_storage_buckets.py +30 -0
- django_supabase/management/commands/sync_users.py +72 -0
- django_supabase/storage/__init__.py +0 -0
- django_supabase/storage/backends.py +252 -0
- django_supabase/storage/utils.py +53 -0
- django_supabase/utils.py +40 -0
- django_supabase-1.0.0.dist-info/METADATA +184 -0
- django_supabase-1.0.0.dist-info/RECORD +28 -0
- django_supabase-1.0.0.dist-info/WHEEL +4 -0
django_supabase/apps.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DjangoSupabaseConfig(AppConfig):
|
|
5
|
+
default_auto_field = 'django.db.models.BigAutoField'
|
|
6
|
+
name = 'django_supabase'
|
|
7
|
+
verbose_name = 'Supabase Integration'
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
"""Initialize Supabase integration on Django startup"""
|
|
11
|
+
from .conf import supabase_settings
|
|
12
|
+
# Validate settings on startup
|
|
13
|
+
supabase_settings.configure()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
default_app_config = 'django_supabase.auth.apps.SupabaseAuthConfig'
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.contrib.auth import get_user_model
|
|
5
|
+
from django.contrib.auth.backends import BaseBackend
|
|
6
|
+
from supabase import create_client
|
|
7
|
+
|
|
8
|
+
from .sync import sync_user_from_claims, sync_user_from_supabase
|
|
9
|
+
from .tokens import (
|
|
10
|
+
SupabaseTokenError,
|
|
11
|
+
token_algorithm,
|
|
12
|
+
verify_token_with_jwks,
|
|
13
|
+
verify_token_with_secret,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SupabaseAuthBackend(BaseBackend):
|
|
20
|
+
"""
|
|
21
|
+
Authenticates against Supabase and syncs with Django User model
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.supabase = create_client(
|
|
26
|
+
settings.SUPABASE_URL,
|
|
27
|
+
settings.SUPABASE_KEY
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def authenticate(self, request, token=None, **credentials):
|
|
31
|
+
if token:
|
|
32
|
+
return self._authenticate_token(token)
|
|
33
|
+
|
|
34
|
+
# Email/password auth
|
|
35
|
+
email = credentials.get('email')
|
|
36
|
+
password = credentials.get('password')
|
|
37
|
+
|
|
38
|
+
if not email or not password:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
response = self.supabase.auth.sign_in_with_password({
|
|
43
|
+
"email": email,
|
|
44
|
+
"password": password
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if response.user:
|
|
48
|
+
user, created = self._sync_user(response.user)
|
|
49
|
+
return user
|
|
50
|
+
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
logger.debug('Supabase email/password authentication failed: %s', exc)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def _authenticate_token(self, token):
|
|
56
|
+
"""Validate JWT token from Supabase"""
|
|
57
|
+
verification_mode = getattr(
|
|
58
|
+
settings,
|
|
59
|
+
'SUPABASE_JWT_VERIFICATION',
|
|
60
|
+
'auth_server',
|
|
61
|
+
).lower()
|
|
62
|
+
|
|
63
|
+
if verification_mode == 'auth_server':
|
|
64
|
+
return self._authenticate_token_with_auth_server(token)
|
|
65
|
+
|
|
66
|
+
if verification_mode == 'jwks':
|
|
67
|
+
return self._authenticate_token_with_jwks(token)
|
|
68
|
+
|
|
69
|
+
if verification_mode == 'jwt_secret':
|
|
70
|
+
return self._authenticate_token_with_secret(token)
|
|
71
|
+
|
|
72
|
+
logger.error('Unsupported SUPABASE_JWT_VERIFICATION mode: %s', verification_mode)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def _authenticate_token_with_auth_server(self, token):
|
|
76
|
+
try:
|
|
77
|
+
response = self.supabase.auth.get_user(token)
|
|
78
|
+
supabase_user = getattr(response, 'user', None)
|
|
79
|
+
if not supabase_user:
|
|
80
|
+
return None
|
|
81
|
+
user, _ = sync_user_from_supabase(supabase_user)
|
|
82
|
+
return user
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
logger.debug('Supabase Auth server token validation failed: %s', exc)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def _authenticate_token_with_jwks(self, token):
|
|
88
|
+
try:
|
|
89
|
+
if token_algorithm(token) == 'HS256':
|
|
90
|
+
logger.warning(
|
|
91
|
+
'Received HS256 token while SUPABASE_JWT_VERIFICATION=jwks'
|
|
92
|
+
)
|
|
93
|
+
return None
|
|
94
|
+
payload = verify_token_with_jwks(token)
|
|
95
|
+
if getattr(settings, 'SUPABASE_SYNC_USER_FROM_AUTH_ON_TOKEN', False):
|
|
96
|
+
return self._authenticate_token_with_auth_server(token)
|
|
97
|
+
user, _ = sync_user_from_claims(payload)
|
|
98
|
+
return user
|
|
99
|
+
except SupabaseTokenError as exc:
|
|
100
|
+
logger.debug('Supabase JWKS token validation failed: %s', exc)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def _authenticate_token_with_secret(self, token):
|
|
104
|
+
try:
|
|
105
|
+
payload = verify_token_with_secret(token)
|
|
106
|
+
user, _ = sync_user_from_claims(payload)
|
|
107
|
+
return user
|
|
108
|
+
except SupabaseTokenError as exc:
|
|
109
|
+
logger.debug('Supabase JWT secret token validation failed: %s', exc)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def _sync_user(self, supabase_user):
|
|
113
|
+
"""Sync Supabase user with Django User model"""
|
|
114
|
+
return sync_user_from_supabase(supabase_user)
|
|
115
|
+
|
|
116
|
+
def get_user(self, user_id):
|
|
117
|
+
User = get_user_model()
|
|
118
|
+
try:
|
|
119
|
+
return User.objects.get(pk=user_id)
|
|
120
|
+
except User.DoesNotExist:
|
|
121
|
+
return None
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django.contrib.auth import authenticate
|
|
2
|
+
from django.contrib.auth.middleware import get_user
|
|
3
|
+
from django.utils.functional import SimpleLazyObject
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SupabaseAuthMiddleware:
|
|
7
|
+
"""
|
|
8
|
+
Middleware to authenticate requests using Supabase JWT tokens
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, get_response):
|
|
12
|
+
self.get_response = get_response
|
|
13
|
+
|
|
14
|
+
def __call__(self, request):
|
|
15
|
+
# Extract token from Authorization header
|
|
16
|
+
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
|
17
|
+
|
|
18
|
+
scheme, _, token = auth_header.partition(' ')
|
|
19
|
+
|
|
20
|
+
if scheme.lower() == 'bearer' and token:
|
|
21
|
+
request.user = SimpleLazyObject(
|
|
22
|
+
lambda: self._authenticate_token(request, token.strip())
|
|
23
|
+
)
|
|
24
|
+
else:
|
|
25
|
+
request.user = SimpleLazyObject(lambda: get_user(request))
|
|
26
|
+
|
|
27
|
+
response = self.get_response(request)
|
|
28
|
+
return response
|
|
29
|
+
|
|
30
|
+
def _authenticate_token(self, request, token):
|
|
31
|
+
"""Authenticate using token"""
|
|
32
|
+
user = authenticate(request, token=token)
|
|
33
|
+
return user or get_user(request)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Generated by Django 6.0.6 on 2026-06-11 12:57
|
|
2
|
+
|
|
3
|
+
import django.contrib.auth.models
|
|
4
|
+
import django.contrib.auth.validators
|
|
5
|
+
import django.utils.timezone
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
initial = True
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
('auth', '0012_alter_user_first_name_max_length'),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name='SupabaseUser',
|
|
20
|
+
fields=[
|
|
21
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
22
|
+
('password', models.CharField(max_length=128, verbose_name='password')),
|
|
23
|
+
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
24
|
+
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
25
|
+
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
|
26
|
+
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
|
27
|
+
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
|
28
|
+
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
|
29
|
+
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
|
30
|
+
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
|
31
|
+
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
|
32
|
+
('supabase_id', models.UUIDField(blank=True, db_index=True, null=True, unique=True)),
|
|
33
|
+
('phone', models.CharField(blank=True, max_length=32)),
|
|
34
|
+
('display_name', models.CharField(blank=True, max_length=150)),
|
|
35
|
+
('avatar_url', models.URLField(blank=True)),
|
|
36
|
+
('aud', models.CharField(blank=True, max_length=64)),
|
|
37
|
+
('role', models.CharField(blank=True, max_length=64)),
|
|
38
|
+
('provider', models.CharField(blank=True, max_length=64)),
|
|
39
|
+
('providers', models.JSONField(blank=True, default=list)),
|
|
40
|
+
('app_metadata', models.JSONField(blank=True, default=dict)),
|
|
41
|
+
('user_metadata', models.JSONField(blank=True, default=dict)),
|
|
42
|
+
('metadata', models.JSONField(blank=True, default=dict)),
|
|
43
|
+
('identities', models.JSONField(blank=True, default=list)),
|
|
44
|
+
('email_confirmed_at', models.DateTimeField(blank=True, null=True)),
|
|
45
|
+
('phone_confirmed_at', models.DateTimeField(blank=True, null=True)),
|
|
46
|
+
('last_sign_in_at', models.DateTimeField(blank=True, null=True)),
|
|
47
|
+
('supabase_created_at', models.DateTimeField(blank=True, null=True)),
|
|
48
|
+
('supabase_updated_at', models.DateTimeField(blank=True, null=True)),
|
|
49
|
+
('is_anonymous', models.BooleanField(default=False)),
|
|
50
|
+
('is_sso_user', models.BooleanField(default=False)),
|
|
51
|
+
('banned_until', models.DateTimeField(blank=True, null=True)),
|
|
52
|
+
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
|
53
|
+
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
|
54
|
+
],
|
|
55
|
+
options={
|
|
56
|
+
'db_table': 'auth_user',
|
|
57
|
+
'swappable': 'AUTH_USER_MODEL',
|
|
58
|
+
},
|
|
59
|
+
managers=[
|
|
60
|
+
('objects', django.contrib.auth.models.UserManager()),
|
|
61
|
+
],
|
|
62
|
+
),
|
|
63
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from django.contrib.auth.models import AbstractUser
|
|
2
|
+
from django.db import models
|
|
3
|
+
|
|
4
|
+
class SupabaseUser(AbstractUser):
|
|
5
|
+
"""
|
|
6
|
+
Extended user model to store Supabase-specific data
|
|
7
|
+
"""
|
|
8
|
+
supabase_id = models.UUIDField(unique=True, null=True, blank=True, db_index=True)
|
|
9
|
+
phone = models.CharField(max_length=32, blank=True)
|
|
10
|
+
display_name = models.CharField(max_length=150, blank=True)
|
|
11
|
+
avatar_url = models.URLField(blank=True)
|
|
12
|
+
aud = models.CharField(max_length=64, blank=True)
|
|
13
|
+
role = models.CharField(max_length=64, blank=True)
|
|
14
|
+
provider = models.CharField(max_length=64, blank=True)
|
|
15
|
+
providers = models.JSONField(default=list, blank=True)
|
|
16
|
+
app_metadata = models.JSONField(default=dict, blank=True)
|
|
17
|
+
user_metadata = models.JSONField(default=dict, blank=True)
|
|
18
|
+
metadata = models.JSONField(default=dict, blank=True)
|
|
19
|
+
identities = models.JSONField(default=list, blank=True)
|
|
20
|
+
email_confirmed_at = models.DateTimeField(null=True, blank=True)
|
|
21
|
+
phone_confirmed_at = models.DateTimeField(null=True, blank=True)
|
|
22
|
+
last_sign_in_at = models.DateTimeField(null=True, blank=True)
|
|
23
|
+
supabase_created_at = models.DateTimeField(null=True, blank=True)
|
|
24
|
+
supabase_updated_at = models.DateTimeField(null=True, blank=True)
|
|
25
|
+
is_anonymous = models.BooleanField(default=False)
|
|
26
|
+
is_sso_user = models.BooleanField(default=False)
|
|
27
|
+
banned_until = models.DateTimeField(null=True, blank=True)
|
|
28
|
+
|
|
29
|
+
class Meta:
|
|
30
|
+
db_table = 'auth_user' # Keep Django's default table name
|
|
31
|
+
swappable = 'AUTH_USER_MODEL'
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def name(self):
|
|
35
|
+
return self.display_name or self.get_full_name() or self.username
|
|
36
|
+
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from django.contrib.auth import get_user_model
|
|
7
|
+
from django.db import transaction
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
from django.utils.dateparse import parse_datetime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sync_user_from_supabase(supabase_user: Any):
|
|
13
|
+
"""Create or update a Django user from a Supabase Auth user object."""
|
|
14
|
+
app_metadata = _as_dict(_value(supabase_user, 'app_metadata', {}))
|
|
15
|
+
user_metadata = _as_dict(_value(supabase_user, 'user_metadata', {}))
|
|
16
|
+
|
|
17
|
+
return _sync_user(
|
|
18
|
+
supabase_id=_value(supabase_user, 'id'),
|
|
19
|
+
email=_value(supabase_user, 'email') or '',
|
|
20
|
+
phone=_value(supabase_user, 'phone') or '',
|
|
21
|
+
aud=_value(supabase_user, 'aud') or '',
|
|
22
|
+
role=_value(supabase_user, 'role') or '',
|
|
23
|
+
app_metadata=app_metadata,
|
|
24
|
+
user_metadata=user_metadata,
|
|
25
|
+
identities=_jsonable(_value(supabase_user, 'identities', [])) or [],
|
|
26
|
+
email_confirmed_at=_parse_datetime(_value(supabase_user, 'email_confirmed_at')),
|
|
27
|
+
phone_confirmed_at=_parse_datetime(_value(supabase_user, 'phone_confirmed_at')),
|
|
28
|
+
last_sign_in_at=_parse_datetime(_value(supabase_user, 'last_sign_in_at')),
|
|
29
|
+
supabase_created_at=_parse_datetime(_value(supabase_user, 'created_at')),
|
|
30
|
+
supabase_updated_at=_parse_datetime(_value(supabase_user, 'updated_at')),
|
|
31
|
+
is_anonymous=bool(_value(supabase_user, 'is_anonymous', False)),
|
|
32
|
+
is_sso_user=bool(_value(supabase_user, 'is_sso_user', False)),
|
|
33
|
+
banned_until=_parse_datetime(_value(supabase_user, 'banned_until')),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sync_user_from_claims(claims: dict[str, Any]):
|
|
38
|
+
"""Create or update a Django user from verified Supabase JWT claims."""
|
|
39
|
+
app_metadata = _as_dict(claims.get('app_metadata', {}))
|
|
40
|
+
user_metadata = _as_dict(claims.get('user_metadata', {}))
|
|
41
|
+
|
|
42
|
+
return _sync_user(
|
|
43
|
+
supabase_id=claims.get('sub'),
|
|
44
|
+
email=claims.get('email') or '',
|
|
45
|
+
phone=claims.get('phone') or '',
|
|
46
|
+
aud=_audience_to_string(claims.get('aud')),
|
|
47
|
+
role=claims.get('role') or '',
|
|
48
|
+
app_metadata=app_metadata,
|
|
49
|
+
user_metadata=user_metadata,
|
|
50
|
+
identities=[],
|
|
51
|
+
email_confirmed_at=None,
|
|
52
|
+
phone_confirmed_at=None,
|
|
53
|
+
last_sign_in_at=None,
|
|
54
|
+
supabase_created_at=None,
|
|
55
|
+
supabase_updated_at=None,
|
|
56
|
+
is_anonymous=bool(claims.get('is_anonymous', False)),
|
|
57
|
+
is_sso_user=False,
|
|
58
|
+
banned_until=None,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@transaction.atomic
|
|
63
|
+
def _sync_user(
|
|
64
|
+
*,
|
|
65
|
+
supabase_id: Any,
|
|
66
|
+
email: str,
|
|
67
|
+
phone: str,
|
|
68
|
+
aud: str,
|
|
69
|
+
role: str,
|
|
70
|
+
app_metadata: dict[str, Any],
|
|
71
|
+
user_metadata: dict[str, Any],
|
|
72
|
+
identities: list[Any],
|
|
73
|
+
email_confirmed_at: datetime | None,
|
|
74
|
+
phone_confirmed_at: datetime | None,
|
|
75
|
+
last_sign_in_at: datetime | None,
|
|
76
|
+
supabase_created_at: datetime | None,
|
|
77
|
+
supabase_updated_at: datetime | None,
|
|
78
|
+
is_anonymous: bool,
|
|
79
|
+
is_sso_user: bool,
|
|
80
|
+
banned_until: datetime | None,
|
|
81
|
+
):
|
|
82
|
+
User = get_user_model()
|
|
83
|
+
field_names = {field.name for field in User._meta.fields}
|
|
84
|
+
supabase_id = str(supabase_id) if supabase_id else None
|
|
85
|
+
|
|
86
|
+
user = _find_existing_user(User, field_names, supabase_id, email)
|
|
87
|
+
created = user is None
|
|
88
|
+
|
|
89
|
+
if user is None:
|
|
90
|
+
user = User()
|
|
91
|
+
if 'password' in field_names:
|
|
92
|
+
user.set_unusable_password()
|
|
93
|
+
|
|
94
|
+
display_name = _display_name(user_metadata)
|
|
95
|
+
first_name, last_name = _name_parts(user_metadata, display_name)
|
|
96
|
+
avatar_url = (
|
|
97
|
+
user_metadata.get('avatar_url')
|
|
98
|
+
or user_metadata.get('picture')
|
|
99
|
+
or user_metadata.get('avatar')
|
|
100
|
+
or ''
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
defaults = {
|
|
104
|
+
'supabase_id': supabase_id,
|
|
105
|
+
'email': email,
|
|
106
|
+
'username': email or (f'supabase_{supabase_id}' if supabase_id else ''),
|
|
107
|
+
'phone': phone,
|
|
108
|
+
'display_name': display_name,
|
|
109
|
+
'first_name': first_name,
|
|
110
|
+
'last_name': last_name,
|
|
111
|
+
'avatar_url': avatar_url,
|
|
112
|
+
'aud': aud,
|
|
113
|
+
'role': role,
|
|
114
|
+
'provider': app_metadata.get('provider') or '',
|
|
115
|
+
'providers': app_metadata.get('providers') or [],
|
|
116
|
+
'app_metadata': app_metadata,
|
|
117
|
+
'user_metadata': user_metadata,
|
|
118
|
+
'metadata': user_metadata,
|
|
119
|
+
'identities': identities,
|
|
120
|
+
'email_confirmed_at': email_confirmed_at,
|
|
121
|
+
'phone_confirmed_at': phone_confirmed_at,
|
|
122
|
+
'last_sign_in_at': last_sign_in_at,
|
|
123
|
+
'supabase_created_at': supabase_created_at,
|
|
124
|
+
'supabase_updated_at': supabase_updated_at,
|
|
125
|
+
'is_anonymous': is_anonymous,
|
|
126
|
+
'is_sso_user': is_sso_user,
|
|
127
|
+
'banned_until': banned_until,
|
|
128
|
+
'is_active': banned_until is None,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for field, value in defaults.items():
|
|
132
|
+
if field in field_names:
|
|
133
|
+
setattr(user, field, value)
|
|
134
|
+
|
|
135
|
+
user.save()
|
|
136
|
+
return user, created
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _find_existing_user(User, field_names: set[str], supabase_id: str | None, email: str):
|
|
140
|
+
if supabase_id and 'supabase_id' in field_names:
|
|
141
|
+
user = User.objects.filter(supabase_id=supabase_id).first()
|
|
142
|
+
if user:
|
|
143
|
+
return user
|
|
144
|
+
|
|
145
|
+
if email and 'email' in field_names:
|
|
146
|
+
return User.objects.filter(email__iexact=email).first()
|
|
147
|
+
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _value(obj: Any, field: str, default: Any = None):
|
|
152
|
+
if isinstance(obj, dict):
|
|
153
|
+
return obj.get(field, default)
|
|
154
|
+
return getattr(obj, field, default)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _as_dict(value: Any) -> dict[str, Any]:
|
|
158
|
+
if value is None:
|
|
159
|
+
return {}
|
|
160
|
+
if isinstance(value, dict):
|
|
161
|
+
return value
|
|
162
|
+
dumped = _jsonable(value)
|
|
163
|
+
return dumped if isinstance(dumped, dict) else {}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _jsonable(value: Any):
|
|
167
|
+
if value is None:
|
|
168
|
+
return None
|
|
169
|
+
if hasattr(value, 'model_dump'):
|
|
170
|
+
return value.model_dump(mode='json')
|
|
171
|
+
if isinstance(value, list):
|
|
172
|
+
return [_jsonable(item) for item in value]
|
|
173
|
+
if isinstance(value, tuple):
|
|
174
|
+
return [_jsonable(item) for item in value]
|
|
175
|
+
if isinstance(value, dict):
|
|
176
|
+
return {key: _jsonable(item) for key, item in value.items()}
|
|
177
|
+
if hasattr(value, '__dict__'):
|
|
178
|
+
return {
|
|
179
|
+
key: _jsonable(item)
|
|
180
|
+
for key, item in vars(value).items()
|
|
181
|
+
if not key.startswith('_')
|
|
182
|
+
}
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_datetime(value: Any) -> datetime | None:
|
|
187
|
+
if not value:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
if isinstance(value, datetime):
|
|
191
|
+
parsed = value
|
|
192
|
+
elif isinstance(value, str):
|
|
193
|
+
parsed = parse_datetime(value)
|
|
194
|
+
else:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
if parsed and timezone.is_naive(parsed):
|
|
198
|
+
return timezone.make_aware(parsed, timezone=UTC)
|
|
199
|
+
return parsed
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _display_name(user_metadata: dict[str, Any]) -> str:
|
|
203
|
+
return (
|
|
204
|
+
user_metadata.get('display_name')
|
|
205
|
+
or user_metadata.get('full_name')
|
|
206
|
+
or user_metadata.get('name')
|
|
207
|
+
or ''
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _name_parts(user_metadata: dict[str, Any], display_name: str) -> tuple[str, str]:
|
|
212
|
+
first_name = user_metadata.get('first_name') or user_metadata.get('given_name')
|
|
213
|
+
last_name = user_metadata.get('last_name') or user_metadata.get('family_name')
|
|
214
|
+
|
|
215
|
+
if first_name or last_name:
|
|
216
|
+
return first_name or '', last_name or ''
|
|
217
|
+
|
|
218
|
+
if display_name and ' ' in display_name:
|
|
219
|
+
first_name, last_name = display_name.split(' ', 1)
|
|
220
|
+
return first_name, last_name
|
|
221
|
+
|
|
222
|
+
return display_name, ''
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _audience_to_string(audience: Any) -> str:
|
|
226
|
+
if isinstance(audience, list):
|
|
227
|
+
return ','.join(str(item) for item in audience)
|
|
228
|
+
return str(audience or '')
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
9
|
+
from jwt import PyJWKClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SupabaseTokenError(Exception):
|
|
13
|
+
"""Raised when a Supabase access token cannot be verified."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_token_with_jwks(token: str) -> dict[str, Any]:
|
|
17
|
+
"""Verify a Supabase JWT using the project's JWKS endpoint."""
|
|
18
|
+
try:
|
|
19
|
+
signing_key = _jwks_client().get_signing_key_from_jwt(token)
|
|
20
|
+
return jwt.decode(
|
|
21
|
+
token,
|
|
22
|
+
signing_key.key,
|
|
23
|
+
algorithms=_jwt_algorithms(),
|
|
24
|
+
audience=_jwt_audience(),
|
|
25
|
+
issuer=_jwt_issuer(),
|
|
26
|
+
)
|
|
27
|
+
except jwt.PyJWTError as exc:
|
|
28
|
+
raise SupabaseTokenError(str(exc)) from exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def verify_token_with_secret(token: str) -> dict[str, Any]:
|
|
32
|
+
"""Verify a Supabase JWT using the legacy shared JWT secret."""
|
|
33
|
+
secret = getattr(settings, 'SUPABASE_JWT_SECRET', None)
|
|
34
|
+
if not secret:
|
|
35
|
+
raise ImproperlyConfigured(
|
|
36
|
+
'SUPABASE_JWT_SECRET is required when '
|
|
37
|
+
"SUPABASE_JWT_VERIFICATION='jwt_secret'"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
return jwt.decode(
|
|
42
|
+
token,
|
|
43
|
+
secret,
|
|
44
|
+
algorithms=['HS256'],
|
|
45
|
+
audience=_jwt_audience(),
|
|
46
|
+
issuer=_jwt_issuer(),
|
|
47
|
+
)
|
|
48
|
+
except jwt.PyJWTError as exc:
|
|
49
|
+
raise SupabaseTokenError(str(exc)) from exc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def token_algorithm(token: str) -> str:
|
|
53
|
+
"""Return the token header algorithm without trusting the payload."""
|
|
54
|
+
try:
|
|
55
|
+
return jwt.get_unverified_header(token).get('alg', '')
|
|
56
|
+
except jwt.PyJWTError as exc:
|
|
57
|
+
raise SupabaseTokenError(str(exc)) from exc
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@lru_cache(maxsize=1)
|
|
61
|
+
def _jwks_client() -> PyJWKClient:
|
|
62
|
+
return PyJWKClient(
|
|
63
|
+
_jwks_url(),
|
|
64
|
+
cache_keys=True,
|
|
65
|
+
cache_jwk_set=True,
|
|
66
|
+
lifespan=600,
|
|
67
|
+
timeout=getattr(settings, 'SUPABASE_TIMEOUT', 30),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _jwt_issuer() -> str:
|
|
72
|
+
configured = getattr(settings, 'SUPABASE_JWT_ISSUER', None)
|
|
73
|
+
if configured:
|
|
74
|
+
return configured
|
|
75
|
+
return f"{settings.SUPABASE_URL.rstrip('/')}/auth/v1"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _jwks_url() -> str:
|
|
79
|
+
configured = getattr(settings, 'SUPABASE_JWKS_URL', None)
|
|
80
|
+
if configured:
|
|
81
|
+
return configured
|
|
82
|
+
return f"{_jwt_issuer().rstrip('/')}/.well-known/jwks.json"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _jwt_audience() -> str:
|
|
86
|
+
return getattr(settings, 'SUPABASE_JWT_AUDIENCE', 'authenticated')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _jwt_algorithms() -> list[str]:
|
|
90
|
+
algorithms = getattr(settings, 'SUPABASE_JWT_ALGORITHMS', ['RS256', 'ES256'])
|
|
91
|
+
if isinstance(algorithms, str):
|
|
92
|
+
return [item.strip() for item in algorithms.split(',') if item.strip()]
|
|
93
|
+
return list(algorithms)
|
django_supabase/conf.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SupabaseSettings:
|
|
6
|
+
"""
|
|
7
|
+
Centralized settings management for Supabase integration
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
REQUIRED_SETTINGS = [
|
|
11
|
+
'SUPABASE_URL',
|
|
12
|
+
'SUPABASE_KEY',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
DEFAULTS = {
|
|
16
|
+
'SUPABASE_JWT_SECRET': None,
|
|
17
|
+
'SUPABASE_JWT_VERIFICATION': 'auth_server',
|
|
18
|
+
'SUPABASE_JWT_AUDIENCE': 'authenticated',
|
|
19
|
+
'SUPABASE_JWT_ALGORITHMS': ['RS256', 'ES256'],
|
|
20
|
+
'SUPABASE_JWT_ISSUER': None,
|
|
21
|
+
'SUPABASE_JWKS_URL': None,
|
|
22
|
+
'SUPABASE_SECRET_KEY': None,
|
|
23
|
+
'SUPABASE_SERVICE_KEY': None,
|
|
24
|
+
'SUPABASE_STORAGE_BUCKET': 'media',
|
|
25
|
+
'SUPABASE_MEDIA_BUCKET': 'media',
|
|
26
|
+
'SUPABASE_STATIC_BUCKET': 'static',
|
|
27
|
+
'SUPABASE_MEDIA_FOLDER': 'uploads',
|
|
28
|
+
'SUPABASE_STATIC_FOLDER': 'static',
|
|
29
|
+
'SUPABASE_STORAGE_PUBLIC': True,
|
|
30
|
+
'SUPABASE_STORAGE_AUTO_CREATE_BUCKETS': False,
|
|
31
|
+
'SUPABASE_AUTH_REQUIRED': False,
|
|
32
|
+
'SUPABASE_SYNC_USER_ON_LOGIN': True,
|
|
33
|
+
'SUPABASE_SYNC_USER_FROM_AUTH_ON_TOKEN': False,
|
|
34
|
+
'SUPABASE_CONNECTION_POOL_SIZE': 20,
|
|
35
|
+
'SUPABASE_TIMEOUT': 30,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self._configured = False
|
|
40
|
+
|
|
41
|
+
def configure(self):
|
|
42
|
+
"""Validate required settings and apply defaults once Django is ready."""
|
|
43
|
+
if self._configured:
|
|
44
|
+
return
|
|
45
|
+
self._validate_settings()
|
|
46
|
+
self._set_defaults()
|
|
47
|
+
self._configured = True
|
|
48
|
+
|
|
49
|
+
def _validate_settings(self):
|
|
50
|
+
"""Validate required settings are present"""
|
|
51
|
+
for setting in self.REQUIRED_SETTINGS:
|
|
52
|
+
if not getattr(settings, setting, None):
|
|
53
|
+
raise ImproperlyConfigured(
|
|
54
|
+
f"Setting {setting} is required for Supabase integration"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _set_defaults(self):
|
|
58
|
+
"""Set default values for optional settings"""
|
|
59
|
+
for key, value in self.DEFAULTS.items():
|
|
60
|
+
if not hasattr(settings, key):
|
|
61
|
+
setattr(settings, key, value)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def get(cls, key, default=None):
|
|
65
|
+
"""Get a setting value"""
|
|
66
|
+
return getattr(settings, key, default)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Initialize settings
|
|
70
|
+
supabase_settings = SupabaseSettings()
|
|
File without changes
|