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.
- django_auth_headless/__init__.py +1 -0
- django_auth_headless/adapters.py +18 -0
- django_auth_headless/apps.py +9 -0
- django_auth_headless/conf.py +180 -0
- django_auth_headless/management/__init__.py +0 -0
- django_auth_headless/management/commands/__init__.py +0 -0
- django_auth_headless/management/commands/setup_oauth.py +96 -0
- django_auth_headless/management/commands/verify_oauth.py +122 -0
- django_auth_headless/site_helpers.py +26 -0
- django_auth_headless/urls.py +11 -0
- django_auth_headless/utils.py +67 -0
- django_auth_headless/views.py +36 -0
- django_auth_headless-0.1.0.dist-info/METADATA +191 -0
- django_auth_headless-0.1.0.dist-info/RECORD +16 -0
- django_auth_headless-0.1.0.dist-info/WHEEL +4 -0
- django_auth_headless-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,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.
|