django-auth-headless 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.
@@ -0,0 +1 @@
1
+ """Reusable Django authentication configuration."""
@@ -0,0 +1,18 @@
1
+ """Custom django-allauth hooks for keeping local user data in sync."""
2
+
3
+ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
4
+ from allauth.socialaccount.models import SocialLogin
5
+
6
+
7
+ class AuthenticationSocialAccountAdapter(DefaultSocialAccountAdapter):
8
+ """Fill missing local email addresses from the authenticated provider."""
9
+
10
+ def pre_social_login(self, request, sociallogin: SocialLogin) -> None:
11
+ user = sociallogin.user
12
+ provider_email = user.email or sociallogin.account.extra_data.get("email", "")
13
+
14
+ if not user.email and isinstance(provider_email, str) and provider_email:
15
+ user.email = provider_email
16
+ user.save(update_fields=["email"])
17
+
18
+ super().pre_social_login(request, sociallogin)
@@ -0,0 +1,9 @@
1
+ """Django app configuration for the authentication module."""
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class AuthenticationConfig(AppConfig):
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+ name = __package__
9
+ verbose_name = "Authentication"
@@ -0,0 +1,180 @@
1
+ """Authentication configuration loaded from environment variables.
2
+
3
+ This is the ONLY file in the authentication package that reads environment
4
+ variables or loads .env files. All other modules import finished values from
5
+ here or from the pure build() function.
6
+
7
+ The build() function accepts an explicit mapping so tests can pass fake
8
+ environments without mutating os.environ.
9
+ """
10
+
11
+ import os
12
+ from typing import Any, Mapping
13
+
14
+ from .utils import (
15
+ env_bool,
16
+ env_int,
17
+ env_list,
18
+ env_str,
19
+ load_env_file,
20
+ )
21
+
22
+ AUTHENTICATION_PACKAGE = __package__
23
+
24
+
25
+ # Provider configurations - defines required env vars per provider
26
+ PROVIDER_CONFIGS = {
27
+ "google": {
28
+ "client_id_env": "DJANGO_GOOGLE_CLIENT_ID",
29
+ "client_secret_env": "DJANGO_GOOGLE_CLIENT_SECRET",
30
+ "scope": ["profile", "email"],
31
+ "auth_params": {"access_type": "online"},
32
+ },
33
+ "github": {
34
+ "client_id_env": "DJANGO_GITHUB_CLIENT_ID",
35
+ "client_secret_env": "DJANGO_GITHUB_CLIENT_SECRET",
36
+ "scope": ["user:email", "read:user"],
37
+ "auth_params": {},
38
+ },
39
+ "gitlab": {
40
+ "client_id_env": "DJANGO_GITLAB_CLIENT_ID",
41
+ "client_secret_env": "DJANGO_GITLAB_CLIENT_SECRET",
42
+ "scope": ["read_user", "email"],
43
+ "auth_params": {},
44
+ },
45
+ "microsoft": {
46
+ "client_id_env": "DJANGO_MICROSOFT_CLIENT_ID",
47
+ "client_secret_env": "DJANGO_MICROSOFT_CLIENT_SECRET",
48
+ "scope": ["User.Read", "email"],
49
+ "auth_params": {},
50
+ },
51
+ }
52
+
53
+
54
+ def build(env: Mapping[str, str] | None = None) -> dict[str, Any]:
55
+ """Build all authentication settings from an environment mapping.
56
+
57
+ Args:
58
+ env: Optional environment mapping. If None, uses os.environ after
59
+ loading the project's .env file.
60
+
61
+ Returns:
62
+ Dictionary of Django settings ready to use.
63
+ """
64
+ if env is None:
65
+ load_env_file()
66
+ env = dict(os.environ)
67
+
68
+ frontend_url = env_str("DJANGO_FRONTEND_URL", "", env).rstrip("/")
69
+
70
+ # Parse enabled providers from DJANGO_AUTH_PROVIDERS (comma-separated, default "google")
71
+ providers_str = env_str("DJANGO_AUTH_PROVIDERS", "google", env)
72
+ enabled_providers = [p.strip().lower() for p in providers_str.split(",") if p.strip()]
73
+
74
+ # Build SOCIALACCOUNT_PROVIDERS dynamically
75
+ socialaccount_providers = {}
76
+ for provider in enabled_providers:
77
+ if provider not in PROVIDER_CONFIGS:
78
+ continue
79
+ config = PROVIDER_CONFIGS[provider]
80
+ socialaccount_providers[provider] = {
81
+ "SCOPE": config["scope"],
82
+ "AUTH_PARAMS": config["auth_params"],
83
+ }
84
+
85
+ return {
86
+ "INSTALLED_APPS": [
87
+ "django.contrib.admin",
88
+ "django.contrib.auth",
89
+ "django.contrib.contenttypes",
90
+ "django.contrib.sessions",
91
+ "django.contrib.messages",
92
+ "django.contrib.staticfiles",
93
+ "django.contrib.sites",
94
+ "allauth",
95
+ "allauth.account",
96
+ "allauth.socialaccount",
97
+ "allauth.socialaccount.providers.google",
98
+ "allauth.socialaccount.providers.github",
99
+ "allauth.socialaccount.providers.gitlab",
100
+ "allauth.socialaccount.providers.microsoft",
101
+ "allauth.headless",
102
+ "corsheaders",
103
+ AUTHENTICATION_PACKAGE,
104
+ ],
105
+ "MIDDLEWARE": [
106
+ "corsheaders.middleware.CorsMiddleware",
107
+ "django.middleware.security.SecurityMiddleware",
108
+ "django.contrib.sessions.middleware.SessionMiddleware",
109
+ "django.middleware.common.CommonMiddleware",
110
+ "django.middleware.csrf.CsrfViewMiddleware",
111
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
112
+ "allauth.account.middleware.AccountMiddleware",
113
+ "django.contrib.messages.middleware.MessageMiddleware",
114
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
115
+ ],
116
+ "AUTHENTICATION_BACKENDS": [
117
+ "django.contrib.auth.backends.ModelBackend",
118
+ "allauth.account.auth_backends.AuthenticationBackend",
119
+ ],
120
+ "SOCIALACCOUNT_ADAPTER": (
121
+ f"{AUTHENTICATION_PACKAGE}.adapters.AuthenticationSocialAccountAdapter"
122
+ ),
123
+ "SITE_ID": 1,
124
+ "HEADLESS_ONLY": True,
125
+ "HEADLESS_FRONTEND_URLS": {
126
+ "account_confirm_email": f"{frontend_url}/account/confirm-email/{{key}}",
127
+ "account_reset_password": f"{frontend_url}/account/password/reset",
128
+ "account_reset_password_from_key": (
129
+ f"{frontend_url}/account/password/reset/key/{{key}}"
130
+ ),
131
+ "account_signup": f"{frontend_url}/account/signup",
132
+ "socialaccount_login_error": f"{frontend_url}/account/social/login/error",
133
+ },
134
+ "CORS_ALLOWED_ORIGINS": env_list("DJANGO_CORS_ALLOWED_ORIGINS", env=env),
135
+ "CORS_ALLOW_CREDENTIALS": True,
136
+ "CSRF_TRUSTED_ORIGINS": env_list("DJANGO_CSRF_TRUSTED_ORIGINS", env=env),
137
+ "SESSION_COOKIE_SECURE": env_bool("DJANGO_SESSION_COOKIE_SECURE", env=env),
138
+ "CSRF_COOKIE_SECURE": env_bool("DJANGO_CSRF_COOKIE_SECURE", env=env),
139
+ "SESSION_COOKIE_HTTPONLY": True,
140
+ "CSRF_COOKIE_HTTPONLY": False,
141
+ "SESSION_COOKIE_SAMESITE": "Lax",
142
+ "CSRF_COOKIE_SAMESITE": "Lax",
143
+ "SECURE_CONTENT_TYPE_NOSNIFF": True,
144
+ "SECURE_SSL_REDIRECT": env_bool("DJANGO_SECURE_SSL_REDIRECT", env=env),
145
+ "SECURE_HSTS_SECONDS": env_int("DJANGO_SECURE_HSTS_SECONDS", env=env),
146
+ "SECURE_HSTS_INCLUDE_SUBDOMAINS": env_bool(
147
+ "DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", env=env
148
+ ),
149
+ "SECURE_HSTS_PRELOAD": env_bool("DJANGO_SECURE_HSTS_PRELOAD", env=env),
150
+ "SOCIALACCOUNT_PROVIDERS": socialaccount_providers,
151
+ }
152
+
153
+
154
+ # Load .env and build the actual exported settings
155
+ load_env_file()
156
+ _settings = build()
157
+
158
+ # Export everything so `from .conf import *` works
159
+ INSTALLED_APPS = _settings["INSTALLED_APPS"]
160
+ MIDDLEWARE = _settings["MIDDLEWARE"]
161
+ AUTHENTICATION_BACKENDS = _settings["AUTHENTICATION_BACKENDS"]
162
+ SOCIALACCOUNT_ADAPTER = _settings["SOCIALACCOUNT_ADAPTER"]
163
+ SITE_ID = _settings["SITE_ID"]
164
+ HEADLESS_ONLY = _settings["HEADLESS_ONLY"]
165
+ HEADLESS_FRONTEND_URLS = _settings["HEADLESS_FRONTEND_URLS"]
166
+ CORS_ALLOWED_ORIGINS = _settings["CORS_ALLOWED_ORIGINS"]
167
+ CORS_ALLOW_CREDENTIALS = _settings["CORS_ALLOW_CREDENTIALS"]
168
+ CSRF_TRUSTED_ORIGINS = _settings["CSRF_TRUSTED_ORIGINS"]
169
+ SESSION_COOKIE_SECURE = _settings["SESSION_COOKIE_SECURE"]
170
+ CSRF_COOKIE_SECURE = _settings["CSRF_COOKIE_SECURE"]
171
+ SESSION_COOKIE_HTTPONLY = _settings["SESSION_COOKIE_HTTPONLY"]
172
+ CSRF_COOKIE_HTTPONLY = _settings["CSRF_COOKIE_HTTPONLY"]
173
+ SESSION_COOKIE_SAMESITE = _settings["SESSION_COOKIE_SAMESITE"]
174
+ CSRF_COOKIE_SAMESITE = _settings["CSRF_COOKIE_SAMESITE"]
175
+ SECURE_CONTENT_TYPE_NOSNIFF = _settings["SECURE_CONTENT_TYPE_NOSNIFF"]
176
+ SECURE_SSL_REDIRECT = _settings["SECURE_SSL_REDIRECT"]
177
+ SECURE_HSTS_SECONDS = _settings["SECURE_HSTS_SECONDS"]
178
+ SECURE_HSTS_INCLUDE_SUBDOMAINS = _settings["SECURE_HSTS_INCLUDE_SUBDOMAINS"]
179
+ SECURE_HSTS_PRELOAD = _settings["SECURE_HSTS_PRELOAD"]
180
+ SOCIALACCOUNT_PROVIDERS = _settings["SOCIALACCOUNT_PROVIDERS"]
File without changes
File without changes
@@ -0,0 +1,96 @@
1
+ """Management command to configure OAuth provider credentials."""
2
+
3
+ from django.core.management.base import BaseCommand, CommandError
4
+ from django.contrib.sites.models import Site
5
+ from allauth.socialaccount.models import SocialApp
6
+ from getpass import getpass
7
+
8
+ from ...site_helpers import ensure_site
9
+
10
+
11
+ class Command(BaseCommand):
12
+ help = "Configure OAuth provider: prompts for credentials, updates .env, creates SocialApp"
13
+
14
+ def add_arguments(self, parser):
15
+ parser.add_argument(
16
+ "--provider",
17
+ default="google",
18
+ choices=["google", "github", "gitlab", "microsoft"],
19
+ help="OAuth provider to configure (default: google)",
20
+ )
21
+ parser.add_argument(
22
+ "--client-id",
23
+ help="OAuth client ID (skip interactive prompt)",
24
+ )
25
+ parser.add_argument(
26
+ "--client-secret",
27
+ help="OAuth client secret (skip interactive prompt)",
28
+ )
29
+ parser.add_argument(
30
+ "--domain",
31
+ default="localhost:8000",
32
+ help="Site domain for the OAuth callback (default: localhost:8000)",
33
+ )
34
+ parser.add_argument(
35
+ "--name",
36
+ help="Site display name (default: provider title + domain)",
37
+ )
38
+ parser.add_argument(
39
+ "--env-file",
40
+ help="Path to .env file to update (default: backend/.env)",
41
+ )
42
+
43
+ def handle(self, *args, **options):
44
+ provider = options["provider"]
45
+ client_id = options["client_id"] or input(f"{provider.title()} Client ID: ").strip()
46
+ client_secret = options["client_secret"] or getpass(f"{provider.title()} Client Secret: ")
47
+ domain = options["domain"]
48
+ name = options["name"] or f"{provider.title()} Auth ({domain})"
49
+ env_file = options["env_file"]
50
+
51
+ if not client_id or not client_secret:
52
+ raise CommandError("Client ID and secret are required")
53
+
54
+ # Ensure Site
55
+ site = ensure_site(domain, name)
56
+
57
+ # Create/update SocialApp
58
+ app, created = SocialApp.objects.update_or_create(
59
+ provider=provider,
60
+ defaults={
61
+ "name": f"{provider.title()} OAuth",
62
+ "client_id": client_id,
63
+ "secret": client_secret,
64
+ },
65
+ )
66
+ app.sites.add(site)
67
+
68
+ self.stdout.write(self.style.SUCCESS(f"Created {provider} SocialApp for site {domain}"))
69
+ self.stdout.write("Add these to your .env:")
70
+ self.stdout.write(f" DJANGO_{provider.upper()}_CLIENT_ID={client_id}")
71
+ self.stdout.write(f" DJANGO_{provider.upper()}_CLIENT_SECRET={client_secret}")
72
+
73
+ # Optionally update .env file
74
+ if env_file:
75
+ self._update_env_file(env_file, provider, client_id, client_secret)
76
+ self.stdout.write(self.style.SUCCESS(f"Updated {env_file}"))
77
+
78
+ def _update_env_file(self, env_file: str, provider: str, client_id: str, client_secret: str) -> None:
79
+ """Update .env file with provider credentials."""
80
+ from pathlib import Path
81
+
82
+ path = Path(env_file)
83
+ lines = []
84
+ if path.exists():
85
+ lines = path.read_text().splitlines()
86
+
87
+ # Remove existing provider credentials
88
+ client_id_key = f"DJANGO_{provider.upper()}_CLIENT_ID"
89
+ client_secret_key = f"DJANGO_{provider.upper()}_CLIENT_SECRET"
90
+ lines = [l for l in lines if not l.startswith(f"{client_id_key}=") and not l.startswith(f"{client_secret_key}=")]
91
+
92
+ # Add new credentials
93
+ lines.append(f"{client_id_key}={client_id}")
94
+ lines.append(f"{client_secret_key}={client_secret}")
95
+
96
+ path.write_text("\n".join(lines) + "\n")
@@ -0,0 +1,122 @@
1
+ """Management command to verify OAuth configuration."""
2
+
3
+ from django.core.management.base import BaseCommand
4
+ from django.conf import settings
5
+ from django.contrib.sites.models import Site
6
+ from allauth.socialaccount.models import SocialApp
7
+
8
+ from ...conf import PROVIDER_CONFIGS, build
9
+ from ...site_helpers import get_site
10
+
11
+
12
+ class Command(BaseCommand):
13
+ help = "Verify OAuth configuration: Site, SocialApp, providers, env vars"
14
+
15
+ def handle(self, *args, **options):
16
+ self.stdout.write(self.style.NOTICE("=== OAuth Configuration Verification ===\n"))
17
+
18
+ all_ok = True
19
+
20
+ # 1. Check Site
21
+ all_ok &= self._check_site()
22
+
23
+ # 2. Check SocialApp(s)
24
+ all_ok &= self._check_social_apps()
25
+
26
+ # 3. Check env vars
27
+ all_ok &= self._check_env_vars()
28
+
29
+ # 4. Check allauth config endpoint
30
+ all_ok &= self._check_allauth_config()
31
+
32
+ # Summary
33
+ self.stdout.write("")
34
+ if all_ok:
35
+ self.stdout.write(self.style.SUCCESS("✓ All checks passed"))
36
+ else:
37
+ self.stdout.write(self.style.ERROR("✗ Some checks failed"))
38
+
39
+ return ""
40
+
41
+ def _check_site(self) -> bool:
42
+ self.stdout.write("1. Site Configuration:")
43
+ try:
44
+ site = get_site()
45
+ self.stdout.write(f" ✓ Site ID {site.pk}: {site.domain} ({site.name})")
46
+ return True
47
+ except Site.DoesNotExist:
48
+ self.stdout.write(self.style.ERROR(f" ✗ Site with ID {settings.SITE_ID} does not exist"))
49
+ return False
50
+ except Exception as e:
51
+ self.stdout.write(self.style.ERROR(f" ✗ Error: {e}"))
52
+ return False
53
+
54
+ def _check_social_apps(self) -> bool:
55
+ self.stdout.write("\n2. Social Applications:")
56
+ providers = build()["SOCIALACCOUNT_PROVIDERS"]
57
+ if not providers:
58
+ self.stdout.write(self.style.WARNING(" ⚠ No providers configured in env"))
59
+ return True
60
+
61
+ all_ok = True
62
+ for provider_id, config in providers.items():
63
+ try:
64
+ app = SocialApp.objects.get(provider=provider_id)
65
+ sites = list(app.sites.values_list("domain", flat=True))
66
+ self.stdout.write(f" ✓ {provider_id}: {app.name} (sites: {', '.join(sites) or 'none'})")
67
+ except SocialApp.DoesNotExist:
68
+ self.stdout.write(self.style.ERROR(f" ✗ {provider_id}: SocialApp not found in database"))
69
+ all_ok = False
70
+ except Exception as e:
71
+ self.stdout.write(self.style.ERROR(f" ✗ {provider_id}: Error - {e}"))
72
+ all_ok = False
73
+ return all_ok
74
+
75
+ def _check_env_vars(self) -> bool:
76
+ self.stdout.write("\n3. Environment Variables:")
77
+ all_ok = True
78
+ for provider_id, config in PROVIDER_CONFIGS.items():
79
+ client_id = config["client_id_env"]
80
+ client_secret = config["client_secret_env"]
81
+ has_id = bool(settings.__dict__.get(client_id) or getattr(settings, client_id, None))
82
+ has_secret = bool(settings.__dict__.get(client_secret) or getattr(settings, client_secret, None))
83
+
84
+ # Check actual env
85
+ import os
86
+ has_id = bool(os.getenv(client_id))
87
+ has_secret = bool(os.getenv(client_secret))
88
+
89
+ if has_id and has_secret:
90
+ self.stdout.write(f" ✓ {provider_id}: {client_id}, {client_secret} present")
91
+ elif has_id or has_secret:
92
+ self.stdout.write(self.style.WARNING(f" ⚠ {provider_id}: Only one of {client_id}/{client_secret} set"))
93
+ all_ok = False
94
+ else:
95
+ self.stdout.write(f" ○ {provider_id}: Not configured (optional)")
96
+ return all_ok
97
+
98
+ def _check_allauth_config(self) -> bool:
99
+ self.stdout.write("\n4. Allauth Headless Config Endpoint:")
100
+ try:
101
+ from django.test import Client
102
+ client = Client(HTTP_HOST="localhost:8000")
103
+ response = client.get("/_allauth/browser/v1/config", HTTP_ORIGIN="http://localhost:5173")
104
+
105
+ if response.status_code != 200:
106
+ self.stdout.write(self.style.ERROR(f" ✗ Config endpoint returned {response.status_code}"))
107
+ return False
108
+
109
+ data = response.json()
110
+ providers = data.get("data", {}).get("socialaccount", {}).get("providers", [])
111
+
112
+ if not providers:
113
+ self.stdout.write(self.style.WARNING(" ⚠ No providers in allauth config"))
114
+ return True
115
+
116
+ self.stdout.write(f" ✓ Config endpoint accessible, {len(providers)} provider(s) found")
117
+ for p in providers:
118
+ self.stdout.write(f" - {p['id']}: {p['name']} (flows: {', '.join(p['flows'])})")
119
+ return True
120
+ except Exception as e:
121
+ self.stdout.write(self.style.ERROR(f" ✗ Config endpoint error: {e}"))
122
+ return False
@@ -0,0 +1,26 @@
1
+ """Site configuration helpers for django-allauth."""
2
+
3
+ from django.conf import settings
4
+ from django.contrib.sites.models import Site
5
+
6
+
7
+ def get_site() -> Site:
8
+ """Get the current Site record for SITE_ID."""
9
+ return Site.objects.get(pk=settings.SITE_ID)
10
+
11
+
12
+ def ensure_site(domain: str, name: str) -> Site:
13
+ """Create or update the Site record for SITE_ID.
14
+
15
+ Args:
16
+ domain: The domain for the site (e.g., "localhost:8000")
17
+ name: The display name for the site
18
+
19
+ Returns:
20
+ The created or updated Site instance
21
+ """
22
+ site, _ = Site.objects.update_or_create(
23
+ pk=settings.SITE_ID,
24
+ defaults={"domain": domain, "name": name},
25
+ )
26
+ return site
@@ -0,0 +1,11 @@
1
+ """URL patterns for django-allauth headless and social authentication."""
2
+
3
+ from django.urls import include, path
4
+
5
+ from .views import current_user
6
+
7
+ urlpatterns = [
8
+ path("accounts/", include("allauth.urls")),
9
+ path("_allauth/", include("allauth.headless.urls")),
10
+ path("api/me", current_user, name="current_user"),
11
+ ]
@@ -0,0 +1,67 @@
1
+ """Utility functions for environment variable parsing."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Mapping
6
+
7
+
8
+ def load_env_file(env_file: Path | None = None, env: Mapping[str, str] | None = None) -> None:
9
+ """Load .env file if it exists. Safe to call multiple times.
10
+
11
+ If `env` is provided, loads into that mapping instead of os.environ.
12
+ """
13
+ target = env if env is not None else os.environ
14
+
15
+ if env_file is None:
16
+ env_file = Path.cwd() / ".env"
17
+ if env_file.exists():
18
+ # Simple .env parser - no external dependency
19
+ for line in env_file.read_text().splitlines():
20
+ line = line.strip()
21
+ if not line or line.startswith("#") or "=" not in line:
22
+ continue
23
+ key, value = line.split("=", 1)
24
+ key = key.strip()
25
+ value = value.strip().strip('"').strip("'")
26
+ if key not in target:
27
+ target[key] = value
28
+
29
+
30
+ def _get_env(name: str, env: Mapping[str, str] | None) -> str | None:
31
+ """Get value from env mapping or os.environ."""
32
+ if env is not None:
33
+ return env.get(name)
34
+ return os.getenv(name)
35
+
36
+
37
+ def env_bool(name: str, default: bool = False, env: Mapping[str, str] | None = None) -> bool:
38
+ """Parse environment variable as boolean."""
39
+ value = _get_env(name, env)
40
+ if value is None:
41
+ return default
42
+ return value.strip().lower() in ("1", "true", "yes", "on")
43
+
44
+
45
+ def env_list(name: str, default: list[str] | None = None, env: Mapping[str, str] | None = None) -> list[str]:
46
+ """Parse comma-separated environment variable as list."""
47
+ value = _get_env(name, env)
48
+ if not value:
49
+ return default or []
50
+ return [item.strip() for item in value.split(",") if item.strip()]
51
+
52
+
53
+ def env_int(name: str, default: int = 0, env: Mapping[str, str] | None = None) -> int:
54
+ """Parse environment variable as integer."""
55
+ value = _get_env(name, env)
56
+ if value is None:
57
+ return default
58
+ try:
59
+ return int(value.strip())
60
+ except ValueError:
61
+ return default
62
+
63
+
64
+ def env_str(name: str, default: str = "", env: Mapping[str, str] | None = None) -> str:
65
+ """Get environment variable as string."""
66
+ value = _get_env(name, env)
67
+ return value if value is not None else default
@@ -0,0 +1,36 @@
1
+ from django.http import JsonResponse
2
+ from django.views.decorators.http import require_GET
3
+
4
+ from allauth.socialaccount.models import SocialAccount
5
+
6
+
7
+ @require_GET
8
+ def current_user(request):
9
+ if not request.user.is_authenticated:
10
+ return JsonResponse(
11
+ {"detail": "Authentication credentials were not provided."},
12
+ status=401,
13
+ )
14
+
15
+ email = request.user.email
16
+ if not email:
17
+ provider_account = (
18
+ SocialAccount.objects.filter(user=request.user)
19
+ .exclude(extra_data__email="")
20
+ .first()
21
+ )
22
+ if provider_account:
23
+ email = provider_account.extra_data.get("email", "")
24
+
25
+ return JsonResponse(
26
+ {
27
+ "status": 200,
28
+ "data": {
29
+ "user": {
30
+ "id": request.user.pk,
31
+ "username": request.user.get_username(),
32
+ "email": email,
33
+ }
34
+ },
35
+ }
36
+ )
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-auth-headless
3
+ Version: 0.1.0
4
+ Summary: Reusable Django authentication with django-allauth Headless and OAuth providers.
5
+ Author-email: Abel Mekonen <abelmekonen247@gmail.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: django-allauth[headless,socialaccount]>=65.20.1
10
+ Requires-Dist: django-cors-headers>=4.9.0
11
+ Requires-Dist: django<7.0,>=6.1.2
12
+ Requires-Dist: python-dotenv>=1.2.4
13
+ Description-Content-Type: text/markdown
14
+
15
+ # django-auth-headless
16
+
17
+ Reusable Django authentication built on django-allauth Headless.
18
+
19
+ The first release focuses on Google OAuth, session authentication, CSRF/CORS
20
+ configuration, and setup and verification commands. The package is designed
21
+ for Django projects using any browser frontend. It does not include a frontend
22
+ or example application.
23
+
24
+ The original learning project, including its React frontend, is available at
25
+ https://github.com/bella-247/DjangoOAuth.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install django-auth-headless
31
+ # or
32
+ uv add django-auth-headless
33
+ ```
34
+
35
+ The package requires Django 6.1, django-allauth with the `headless` and
36
+ `socialaccount` extras, django-cors-headers, and python-dotenv.
37
+
38
+ ## Configure Django
39
+
40
+ In `settings.py`, import the package settings from `django_auth_headless.conf` and
41
+ assign them to the corresponding Django settings:
42
+
43
+ ```python
44
+ from django_auth_headless.conf import *
45
+ ```
46
+
47
+ Keep project-specific `SECRET_KEY`, `DEBUG`, `DATABASES`, `TEMPLATES`,
48
+ `ROOT_URLCONF`, `WSGI_APPLICATION`, `STATIC_URL`, `ALLOWED_HOSTS`, and email
49
+ settings in your project.
50
+
51
+ The exported `INSTALLED_APPS` already includes `django_auth_headless`, so Django
52
+ discovers the package and its management commands. Do not add it a second
53
+ time.
54
+
55
+ Add the package URLs to your project URL configuration:
56
+
57
+ ```python
58
+ from django_auth_headless.urls import urlpatterns as auth_urlpatterns
59
+
60
+ urlpatterns = [
61
+ *auth_urlpatterns,
62
+ ]
63
+ ```
64
+
65
+ Run migrations after installing and configuring the package:
66
+
67
+ ```bash
68
+ python manage.py migrate
69
+ ```
70
+
71
+ ## Environment
72
+
73
+ Create a `.env` file beside `manage.py`:
74
+
75
+ ```dotenv
76
+ DJANGO_FRONTEND_URL=http://localhost:5173
77
+ DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:5173
78
+ DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:5173
79
+ DJANGO_AUTH_PROVIDERS=google
80
+ DJANGO_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
81
+ DJANGO_GOOGLE_CLIENT_SECRET=your-client-secret
82
+ ```
83
+
84
+ Security settings are also environment-driven:
85
+
86
+ ```dotenv
87
+ DJANGO_SECURE_SSL_REDIRECT=False
88
+ DJANGO_SECURE_HSTS_SECONDS=0
89
+ DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=False
90
+ DJANGO_SECURE_HSTS_PRELOAD=False
91
+ DJANGO_SESSION_COOKIE_SECURE=False
92
+ DJANGO_CSRF_COOKIE_SECURE=False
93
+ DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
94
+ ```
95
+
96
+ Use secure cookies, HTTPS redirect, HSTS, and a production email backend in
97
+ production.
98
+
99
+ ## Management commands
100
+
101
+ The commands are available after `django_auth_headless` is included through the
102
+ exported `INSTALLED_APPS` list and migrations have been applied.
103
+
104
+ ### Configure a provider
105
+
106
+ `setup_oauth` creates or updates the database-backed `SocialApp`, associates it
107
+ with the configured Site, and can write credentials to an env file.
108
+
109
+ ```bash
110
+ # Interactive
111
+ python manage.py setup_oauth
112
+
113
+ # Non-interactive
114
+ python manage.py setup_oauth \
115
+ --provider google \
116
+ --client-id "$DJANGO_GOOGLE_CLIENT_ID" \
117
+ --client-secret "$DJANGO_GOOGLE_CLIENT_SECRET" \
118
+ --domain localhost:8000
119
+
120
+ # Also update an env file
121
+ python manage.py setup_oauth --env-file .env
122
+ ```
123
+
124
+ Options: `--provider`, `--client-id`, `--client-secret`, `--domain`, `--name`,
125
+ and `--env-file`.
126
+
127
+ ### Verify configuration
128
+
129
+ ```bash
130
+ python manage.py verify_oauth
131
+ ```
132
+
133
+ This checks the Site, database `SocialApp` records, provider environment
134
+ variables, and the allauth Headless configuration endpoint.
135
+
136
+ Provider credentials have one source of truth: the database `SocialApp`.
137
+ Environment variables supply credentials to `setup_oauth`; they are not also
138
+ registered as settings-backed provider applications.
139
+
140
+ ## Endpoints
141
+
142
+ The package includes:
143
+
144
+ - `/_allauth/browser/v1/` — django-allauth Headless endpoints.
145
+ - `/accounts/` — provider callback routes.
146
+ - `/api/me` — authenticated current-user response.
147
+
148
+ `GET /api/me` returns:
149
+
150
+ ```json
151
+ {
152
+ "status": 200,
153
+ "data": {
154
+ "user": {
155
+ "id": 1,
156
+ "username": "example",
157
+ "email": "example@example.com"
158
+ }
159
+ }
160
+ }
161
+ ```
162
+
163
+ Unauthenticated requests return HTTP 401.
164
+
165
+ ## Development
166
+
167
+ ```bash
168
+ uv sync --group dev
169
+ uv run python manage.py test
170
+ uv build
171
+ ```
172
+
173
+ ## Publishing
174
+
175
+ Releases are published to PyPI by the `Publish package` GitHub Actions
176
+ workflow when a GitHub Release is published. Configure PyPI Trusted Publishing
177
+ for the `bella-247/django-auth-headless` repository with:
178
+
179
+ - Workflow: `.github/workflows/publish.yml`
180
+ - Environment: `pypi`
181
+ - PyPI project: `django-auth-headless`
182
+
183
+ Update the version in `pyproject.toml`, push a matching tag, and publish a
184
+ GitHub Release for that tag. PyPI versions are immutable, so each release must
185
+ use a new version number.
186
+
187
+ ## Scope
188
+
189
+ This is a Django-only package extracted from a Google OAuth learning project.
190
+ Provider configuration is extensible, but Google OAuth is the documented and
191
+ tested browser flow. The learning project is kept separate as an example.
@@ -0,0 +1,16 @@
1
+ django_auth_headless/__init__.py,sha256=6krsN0G89hBy1bcPP50ouT1tMppeLhOv5WvnWCSCMIg,52
2
+ django_auth_headless/adapters.py,sha256=Fb6IdPWnl0Qpf6JrvBoi9vWb9zCYBe0tn4szU04NMmg,767
3
+ django_auth_headless/apps.py,sha256=vJKY45i3BdHRdpqLiJG-bcUmosGtq9hT02T_qmP6N24,253
4
+ django_auth_headless/conf.py,sha256=v5qqmCNJjb2nAJhwSpRvrEozBGlZoJudAr2zTILGX8c,7178
5
+ django_auth_headless/site_helpers.py,sha256=5FTZtJXhYotxnHo42vHf_yTjbp-Z_OjO6EIh-cmFwIs,695
6
+ django_auth_headless/urls.py,sha256=x19pZxrmMn2D2FnpYAKDi_5lgOhLDCqZNyt-xDEd5VU,324
7
+ django_auth_headless/utils.py,sha256=OMNOznFUFNKud8clkXFr252z0CpYF7i9atMtT23svWs,2277
8
+ django_auth_headless/views.py,sha256=204Ho-Y51Zc91PuRHHIQNBT7FjnfykkRvsFRCAnl5bs,973
9
+ django_auth_headless/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ django_auth_headless/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ django_auth_headless/management/commands/setup_oauth.py,sha256=XIE3cA-DrBPTxZq840yspCDFAIyfpWD2EAaXnrQxPHI,3692
12
+ django_auth_headless/management/commands/verify_oauth.py,sha256=lBRfcgcgza0-S3PNk29BG7suuOh4A6kqaY9bWDJnQWU,4969
13
+ django_auth_headless-0.1.0.dist-info/METADATA,sha256=jklYx2wpzssLUovi_VofiYUOjvH3fdCWOYeD3Vq2QeQ,5218
14
+ django_auth_headless-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
15
+ django_auth_headless-0.1.0.dist-info/licenses/LICENSE,sha256=LYJxC1x3a7xCAmNEpohrWFMJ6i8hqKtIBFGkGxuDejw,1069
16
+ django_auth_headless-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abel Mekonen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.