Initial commit

This commit is contained in:
Pat McNeely
2026-03-20 15:09:41 -04:00
commit d1f7052a58
36 changed files with 3974 additions and 0 deletions

0
backend/api/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,63 @@
"""
Management command to create the OAuth2 Application record and a demo user.
Usage:
python manage.py setup_oauth
"""
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from oauth2_provider.models import Application
User = get_user_model()
CLIENT_ID = 'react-oauth-client'
REDIRECT_URI = 'http://localhost:5173/callback'
class Command(BaseCommand):
help = 'Create the OAuth2 Application and a demo superuser'
def handle(self, *args, **kwargs):
# ── Demo user ──────────────────────────────────────────────────────────
user, user_created = User.objects.get_or_create(
username='admin',
defaults={
'email': 'admin@example.com',
'is_staff': True,
'is_superuser': True,
},
)
if user_created:
user.set_password('admin123')
user.save()
self.stdout.write(self.style.SUCCESS('Created superuser: admin / admin123'))
else:
self.stdout.write('Superuser "admin" already exists — skipping.')
# ── OAuth2 Application ─────────────────────────────────────────────────
app, app_created = Application.objects.get_or_create(
client_id=CLIENT_ID,
defaults={
'name': 'React Frontend',
'user': user,
'client_type': Application.CLIENT_PUBLIC,
'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE,
'redirect_uris': REDIRECT_URI,
'skip_authorization': False,
},
)
if not app_created:
# Ensure redirect URI is current if re-running
if REDIRECT_URI not in app.redirect_uris.split():
app.redirect_uris = REDIRECT_URI
app.save()
self.stdout.write('OAuth2 Application already exists — skipping.')
else:
self.stdout.write(self.style.SUCCESS(f'Created OAuth2 Application: {app.name}'))
self.stdout.write('')
self.stdout.write(self.style.SUCCESS('── OAuth2 Application Details ──'))
self.stdout.write(f' Client ID : {app.client_id}')
self.stdout.write(f' Redirect URI : {app.redirect_uris}')
self.stdout.write(f' Grant type : {app.authorization_grant_type}')
self.stdout.write(f' PKCE required: True (enforced in settings)')

View File

@@ -0,0 +1,40 @@
# Generated by Django 4.2.11 on 2026-03-17 19:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WebAuthnCredential',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('credential_id', models.TextField(unique=True)),
('public_key', models.BinaryField()),
('sign_count', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webauthn_credentials', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='WebAuthnChallenge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('challenge', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webauthn_challenges', to=settings.AUTH_USER_MODEL)),
],
options={
'indexes': [models.Index(fields=['user', 'created_at'], name='api_webauth_user_id_ed8a52_idx')],
},
),
]

View File

24
backend/api/models.py Normal file
View File

@@ -0,0 +1,24 @@
from django.db import models
from django.conf import settings
class WebAuthnCredential(models.Model):
"""Stores a registered WebAuthn (passkey/biometric) credential for a user."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='webauthn_credentials')
credential_id = models.TextField(unique=True) # base64url-encoded
public_key = models.BinaryField() # COSE-encoded public key bytes
sign_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"WebAuthnCredential(user={self.user.username})"
class WebAuthnChallenge(models.Model):
"""Temporary storage for a pending WebAuthn challenge (expires after use)."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='webauthn_challenges')
challenge = models.TextField() # base64url-encoded random bytes
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [models.Index(fields=['user', 'created_at'])]

20
backend/api/urls.py Normal file
View File

@@ -0,0 +1,20 @@
from django.urls import path
from .views import (
LoginView, MeView, PingView,
WebAuthnRegisterBeginView, WebAuthnRegisterCompleteView,
WebAuthnAuthBeginView, WebAuthnAuthCompleteView,
)
urlpatterns = [
path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'),
path('ping/', PingView.as_view(), name='ping'),
# WebAuthn (Touch ID) — registration (requires OAuth token)
path('webauthn/register/begin/', WebAuthnRegisterBeginView.as_view(), name='webauthn-register-begin'),
path('webauthn/register/complete/', WebAuthnRegisterCompleteView.as_view(), name='webauthn-register-complete'),
# WebAuthn (Touch ID) — authentication (public)
path('webauthn/auth/begin/', WebAuthnAuthBeginView.as_view(), name='webauthn-auth-begin'),
path('webauthn/auth/complete/', WebAuthnAuthCompleteView.as_view(), name='webauthn-auth-complete'),
]

280
backend/api/views.py Normal file
View File

@@ -0,0 +1,280 @@
import json
from datetime import timedelta
from django.conf import settings
from django.contrib.auth import authenticate, get_user_model
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope
import webauthn
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
UserVerificationRequirement,
PublicKeyCredentialDescriptor,
RegistrationCredential,
AuthenticatorAttestationResponse,
AuthenticationCredential,
AuthenticatorAssertionResponse,
)
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
from webauthn.helpers.exceptions import InvalidCBORData, InvalidAuthenticatorDataStructure
from .models import WebAuthnCredential, WebAuthnChallenge
User = get_user_model()
RP_NAME = "OAuth2 Demo"
CHALLENGE_TTL = timedelta(minutes=5)
def _rp_id():
return settings.WEBAUTHN_RP_ID
def _origin():
return settings.WEBAUTHN_ORIGIN
class MeView(APIView):
"""Return the authenticated user's profile."""
permission_classes = [IsAuthenticated, TokenHasReadWriteScope]
required_scopes = ['read']
def get(self, request):
user = request.user
return Response({
'id': user.id,
'username': user.username,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
'date_joined': user.date_joined.isoformat(),
'has_touch_id': user.webauthn_credentials.exists(),
})
class LoginView(APIView):
"""Validate username/password credentials (no session or token issued)."""
permission_classes = []
authentication_classes = []
def post(self, request):
username = request.data.get('username', '').strip()
password = request.data.get('password', '')
if not username or not password:
return Response({'detail': 'Username and password required.'}, status=status.HTTP_400_BAD_REQUEST)
user = authenticate(request, username=username, password=password)
if user is None:
return Response({'detail': 'Invalid credentials.'}, status=status.HTTP_401_UNAUTHORIZED)
return Response({'username': user.username}, status=status.HTTP_200_OK)
class PingView(APIView):
"""Health check — no auth required."""
permission_classes = []
authentication_classes = []
def get(self, request):
return Response({'status': 'ok'})
# ── WebAuthn Registration ───────────────────────────────────────────────────
class WebAuthnRegisterBeginView(APIView):
"""Begin WebAuthn credential registration (requires existing OAuth token)."""
permission_classes = [IsAuthenticated, TokenHasReadWriteScope]
required_scopes = ['read']
def post(self, request):
user = request.user
# Remove stale challenges for this user
WebAuthnChallenge.objects.filter(user=user).delete()
existing_creds = WebAuthnCredential.objects.filter(user=user)
options = webauthn.generate_registration_options(
rp_id=_rp_id(),
rp_name=RP_NAME,
user_id=str(user.pk).encode(),
user_name=user.username,
user_display_name=user.get_full_name() or user.username,
authenticator_selection=AuthenticatorSelectionCriteria(
user_verification=UserVerificationRequirement.REQUIRED,
),
exclude_credentials=[
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c.credential_id))
for c in existing_creds
],
)
WebAuthnChallenge.objects.create(
user=user,
challenge=bytes_to_base64url(options.challenge),
)
return Response(json.loads(webauthn.options_to_json(options)))
class WebAuthnRegisterCompleteView(APIView):
"""Complete WebAuthn credential registration."""
permission_classes = [IsAuthenticated, TokenHasReadWriteScope]
required_scopes = ['read']
def post(self, request):
user = request.user
try:
challenge_obj = WebAuthnChallenge.objects.filter(user=user).latest('created_at')
except WebAuthnChallenge.DoesNotExist:
return Response({'detail': 'No pending challenge. Begin registration first.'}, status=status.HTTP_400_BAD_REQUEST)
if timezone.now() - challenge_obj.created_at > CHALLENGE_TTL:
challenge_obj.delete()
return Response({'detail': 'Challenge expired. Please try again.'}, status=status.HTTP_400_BAD_REQUEST)
data = request.data
try:
credential = RegistrationCredential(
id=data['id'],
raw_id=base64url_to_bytes(data['rawId']),
response=AuthenticatorAttestationResponse(
client_data_json=base64url_to_bytes(data['response']['clientDataJSON']),
attestation_object=base64url_to_bytes(data['response']['attestationObject']),
),
type=data['type'],
)
verified = webauthn.verify_registration_response(
credential=credential,
expected_challenge=base64url_to_bytes(challenge_obj.challenge),
expected_rp_id=_rp_id(),
expected_origin=_origin(),
require_user_verification=True,
)
except (KeyError, ValueError, InvalidCBORData, InvalidAuthenticatorDataStructure, Exception) as exc:
return Response({'detail': f'Verification failed: {exc}'}, status=status.HTTP_400_BAD_REQUEST)
finally:
challenge_obj.delete()
WebAuthnCredential.objects.update_or_create(
credential_id=bytes_to_base64url(verified.credential_id),
defaults={
'user': user,
'public_key': verified.credential_public_key,
'sign_count': verified.sign_count,
},
)
return Response({'detail': 'Touch ID registered successfully.'})
# ── WebAuthn Authentication ─────────────────────────────────────────────────
class WebAuthnAuthBeginView(APIView):
"""Begin WebAuthn authentication (no existing token required)."""
permission_classes = []
authentication_classes = []
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'detail': 'Username required.'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response({'detail': 'No Touch ID registered for this account.'}, status=status.HTTP_404_NOT_FOUND)
credentials = WebAuthnCredential.objects.filter(user=user)
if not credentials.exists():
return Response({'detail': 'No Touch ID registered for this account.'}, status=status.HTTP_404_NOT_FOUND)
# Remove stale challenges
WebAuthnChallenge.objects.filter(user=user).delete()
options = webauthn.generate_authentication_options(
rp_id=_rp_id(),
allow_credentials=[
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c.credential_id))
for c in credentials
],
user_verification=UserVerificationRequirement.REQUIRED,
)
WebAuthnChallenge.objects.create(
user=user,
challenge=bytes_to_base64url(options.challenge),
)
return Response(json.loads(webauthn.options_to_json(options)))
class WebAuthnAuthCompleteView(APIView):
"""Complete WebAuthn authentication — returns username on success (same as /api/login/)."""
permission_classes = []
authentication_classes = []
def post(self, request):
username = request.data.get('username', '').strip()
if not username:
return Response({'detail': 'Username required.'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND)
try:
challenge_obj = WebAuthnChallenge.objects.filter(user=user).latest('created_at')
except WebAuthnChallenge.DoesNotExist:
return Response({'detail': 'No pending challenge. Begin authentication first.'}, status=status.HTTP_400_BAD_REQUEST)
if timezone.now() - challenge_obj.created_at > CHALLENGE_TTL:
challenge_obj.delete()
return Response({'detail': 'Challenge expired. Please try again.'}, status=status.HTTP_400_BAD_REQUEST)
data = request.data
credential_id = data.get('id')
try:
stored_cred = WebAuthnCredential.objects.get(credential_id=credential_id, user=user)
except WebAuthnCredential.DoesNotExist:
return Response({'detail': 'Credential not found.'}, status=status.HTTP_400_BAD_REQUEST)
try:
credential = AuthenticationCredential(
id=data['id'],
raw_id=base64url_to_bytes(data['rawId']),
response=AuthenticatorAssertionResponse(
client_data_json=base64url_to_bytes(data['response']['clientDataJSON']),
authenticator_data=base64url_to_bytes(data['response']['authenticatorData']),
signature=base64url_to_bytes(data['response']['signature']),
user_handle=base64url_to_bytes(data['response']['userHandle']) if data['response'].get('userHandle') else None,
),
type=data['type'],
)
verified = webauthn.verify_authentication_response(
credential=credential,
expected_challenge=base64url_to_bytes(challenge_obj.challenge),
expected_rp_id=_rp_id(),
expected_origin=_origin(),
credential_public_key=bytes(stored_cred.public_key),
credential_current_sign_count=stored_cred.sign_count,
require_user_verification=True,
)
except Exception as exc:
return Response({'detail': f'Verification failed: {exc}'}, status=status.HTTP_401_UNAUTHORIZED)
finally:
challenge_obj.delete()
# Update replay-attack counter
stored_cred.sign_count = verified.new_sign_count
stored_cred.save(update_fields=['sign_count'])
return Response({'username': user.username}, status=status.HTTP_200_OK)

View File

177
backend/config/settings.py Normal file
View File

@@ -0,0 +1,177 @@
import os
from pathlib import Path
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
# Load a .env file if present (development convenience; production uses real env vars)
try:
from dotenv import load_dotenv
load_dotenv(BASE_DIR / '.env')
except ImportError:
pass
# ── Core ───────────────────────────────────────────────────────────────────────
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY',
'django-insecure-dev-secret-key-change-in-production-xyz123',
)
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
ALLOWED_HOSTS = [
h.strip()
for h in os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
if h.strip()
]
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'oauth2_provider',
'corsheaders',
'api',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'oauth2_provider.middleware.OAuth2TokenMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ── Auth redirects ─────────────────────────────────────────────────────────────
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/o/authorize/'
LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'http://localhost:5173/')
# ── Django REST Framework ──────────────────────────────────────────────────────
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
# ── Django OAuth Toolkit ───────────────────────────────────────────────────────
OAUTH2_PROVIDER = {
'PKCE_REQUIRED': True,
'ACCESS_TOKEN_EXPIRE_SECONDS': 3600,
'REFRESH_TOKEN_EXPIRE_SECONDS': 60 * 60 * 24 * 7,
'ROTATE_REFRESH_TOKEN': True,
'SCOPES': {
'read': 'Read access',
'write': 'Write access',
},
'DEFAULT_SCOPES': ['read'],
'ALLOWED_REDIRECT_URI_SCHEMES': ['http', 'https'],
}
AUTHENTICATION_BACKENDS = [
'oauth2_provider.backends.OAuth2Backend',
'django.contrib.auth.backends.ModelBackend',
]
# ── CORS ───────────────────────────────────────────────────────────────────────
# In production the frontend is co-located (same origin), so CORS isn't needed.
# Keep it configured here so the dev setup (Vite on :5173) still works.
CORS_ALLOWED_ORIGINS = [
o.strip()
for o in os.environ.get(
'CORS_ALLOWED_ORIGINS',
'http://localhost:5173,http://127.0.0.1:5173',
).split(',')
if o.strip()
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
]
# ── WebAuthn / Touch ID ────────────────────────────────────────────────────────
# RP_ID must be the effective domain of the origin (no port, no scheme).
# WEBAUTHN_ORIGIN must be the exact origin browsers will use to reach the app.
WEBAUTHN_RP_ID = os.environ.get('WEBAUTHN_RP_ID', 'localhost')
WEBAUTHN_ORIGIN = os.environ.get('WEBAUTHN_ORIGIN', 'http://localhost:5173')
# ── Production HTTPS hardening ─────────────────────────────────────────────────
# These only activate when DEBUG=False. Assumes nginx terminates TLS and sets
# the X-Forwarded-Proto header before proxying to Gunicorn/Django.
if not DEBUG:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

10
backend/config/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
# Django's built-in login/logout views — DOT redirects here when unauthenticated
path('accounts/', include('django.contrib.auth.urls')),
path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
path('api/', include('api.urls')),
]

6
backend/config/wsgi.py Normal file
View File

@@ -0,0 +1,6 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()

20
backend/manage.py Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

6
backend/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Django==4.2.11
djangorestframework==3.15.1
django-oauth-toolkit==2.4.0
django-cors-headers==4.3.1
webauthn>=2.0.0
python-dotenv>=1.0.0

View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}OAuth2 Demo{% endblock %}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.block-center {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,.1);
padding: 3rem 2.5rem;
width: 100%;
max-width: 420px;
}
.block-center-heading {
font-size: 1.4rem;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 1rem;
text-align: center;
}
p { color: #6b7280; font-size: .95rem; margin-bottom: .75rem; }
ul {
list-style: none;
margin-bottom: 1.5rem;
}
ul li {
padding: .5rem .75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: .375rem;
font-size: .9rem;
color: #374151;
}
ul li::before { content: "✓ "; color: #4f46e5; font-weight: 700; }
.controls {
display: flex;
gap: .75rem;
margin-top: 1.25rem;
}
.btn {
flex: 1;
padding: .75rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
transition: background .15s;
}
.btn:hover { background: #f9fafb; }
.btn-primary {
background: #4f46e5;
border-color: #4f46e5;
color: #fff;
}
.btn-primary:hover { background: #4338ca; border-color: #4338ca; }
.errorlist {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
border-radius: 8px;
padding: .75rem 1rem;
margin-bottom: 1rem;
font-size: .875rem;
list-style: none;
}
h2 { color: #dc2626; margin-bottom: .5rem; }
</style>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sign in OAuth2 Demo</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,.1);
padding: 3rem 2.5rem;
width: 100%;
max-width: 400px;
text-align: center;
}
.logo { font-size: 3rem; margin-bottom: 1rem; }
h1 {
font-size: 1.75rem;
font-weight: 700;
color: #1a1a2e;
margin-bottom: .5rem;
}
.subtitle {
color: #6b7280;
font-size: .95rem;
margin-bottom: 2rem;
}
.errors {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
border-radius: 8px;
padding: .75rem 1rem;
margin-bottom: 1.25rem;
font-size: .875rem;
text-align: left;
}
.field { margin-bottom: 1rem; text-align: left; }
label {
display: block;
font-size: .875rem;
font-weight: 500;
color: #374151;
margin-bottom: .375rem;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: .65rem .875rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
color: #1a1a2e;
outline: none;
transition: border-color .15s;
}
input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,.15); }
button[type="submit"] {
width: 100%;
padding: .85rem;
background: #4f46e5;
color: #fff;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: .5rem;
transition: background .2s;
}
button[type="submit"]:hover { background: #4338ca; }
.hint { margin-top: 1.5rem; font-size: .85rem; color: #9ca3af; }
.hint strong { color: #6b7280; }
</style>
</head>
<body>
<div class="card">
<div class="logo">🔐</div>
<h1>Sign in</h1>
<p class="subtitle">Enter your credentials to authorise the application</p>
{% if form.errors %}
<div class="errors">
Invalid username or password. Please try again.
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}" />
<div class="field">
<label for="id_username">Username</label>
<input type="text" id="id_username" name="username"
autofocus autocomplete="username" required />
</div>
<div class="field">
<label for="id_password">Password</label>
<input type="password" id="id_password" name="password"
autocomplete="current-password" required />
</div>
<button type="submit">Sign in</button>
</form>
<p class="hint">Demo: <strong>admin</strong> / <strong>admin123</strong></p>
</div>
</body>
</html>

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OAuth2 Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1729
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "oauth-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.3.1"
}
}

23
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,23 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import LoginPage from './pages/LoginPage'
import CallbackPage from './pages/CallbackPage'
import DashboardPage from './pages/DashboardPage'
import ProtectedRoute from './components/ProtectedRoute'
export default function App() {
return (
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/callback" element={<CallbackPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,12 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function ProtectedRoute({ children }) {
const { accessToken } = useAuth()
if (!accessToken) {
return <Navigate to="/" replace />
}
return children
}

17
frontend/src/config.js Normal file
View File

@@ -0,0 +1,17 @@
// OAuth2 / backend configuration
//
// AUTHORIZE_URL uses a relative path so it works in both environments:
// - Dev: Vite proxies /o → http://localhost:8000/o
// - Prod: nginx routes /o → Django directly (same origin, no proxy needed)
//
// REDIRECT_URI derives from the current page's origin at runtime, so it
// automatically resolves to the correct host in dev and production.
export const CLIENT_ID = 'react-oauth-client'
export const REDIRECT_URI = `${window.location.origin}/callback`
export const SCOPES = 'read'
export const AUTHORIZE_URL = '/o/authorize/'
export const TOKEN_URL = '/o/token/'
export const REVOKE_URL = '/o/revoke_token/'
export const API_BASE = '/api'

View File

@@ -0,0 +1,33 @@
import { createContext, useContext, useState, useCallback } from 'react'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [accessToken, setAccessToken] = useState(
() => sessionStorage.getItem('access_token') ?? null
)
const setToken = useCallback((token) => {
sessionStorage.setItem('access_token', token)
setAccessToken(token)
}, [])
const clearToken = useCallback(() => {
sessionStorage.removeItem('access_token')
sessionStorage.removeItem('pkce_verifier')
sessionStorage.removeItem('oauth_state')
setAccessToken(null)
}, [])
return (
<AuthContext.Provider value={{ accessToken, setToken, clearToken }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>')
return ctx
}

19
frontend/src/index.css Normal file
View File

@@ -0,0 +1,19 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background: #f0f2f5;
color: #1a1a2e;
min-height: 100vh;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}

16
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './context/AuthContext'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { CLIENT_ID, REDIRECT_URI, TOKEN_URL } from '../config'
import styles from './CallbackPage.module.css'
export default function CallbackPage() {
const { setToken } = useAuth()
const navigate = useNavigate()
const called = useRef(false) // prevent double-invocation in StrictMode
useEffect(() => {
if (called.current) return
called.current = true
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const returnedState = params.get('state')
const error = params.get('error')
const errorDesc = params.get('error_description')
if (error) {
console.error('OAuth error:', error, errorDesc)
navigate(`/?error=${encodeURIComponent(errorDesc ?? error)}`, { replace: true })
return
}
if (!code) {
navigate('/?error=missing_code', { replace: true })
return
}
const savedState = sessionStorage.getItem('oauth_state')
if (returnedState !== savedState) {
console.error('State mismatch — possible CSRF attack')
navigate('/?error=state_mismatch', { replace: true })
return
}
const verifier = sessionStorage.getItem('pkce_verifier')
if (!verifier) {
navigate('/?error=missing_verifier', { replace: true })
return
}
exchangeCode(code, verifier)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
async function exchangeCode(code, verifier) {
try {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
})
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error_description ?? err.error ?? 'Token exchange failed')
}
const data = await res.json()
setToken(data.access_token)
// Clean up PKCE state
sessionStorage.removeItem('pkce_verifier')
sessionStorage.removeItem('oauth_state')
navigate('/dashboard', { replace: true })
} catch (err) {
console.error('Token exchange error:', err)
navigate(`/?error=${encodeURIComponent(err.message)}`, { replace: true })
}
}
return (
<div className={styles.container}>
<div className={styles.spinner} />
<p className={styles.message}>Completing sign-in</p>
</div>
)
}

View File

@@ -0,0 +1,26 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 1.5rem;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e5e7eb;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.message {
color: #6b7280;
font-size: 1rem;
}

View File

@@ -0,0 +1,242 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { QRCodeSVG } from 'qrcode.react'
import { useAuth } from '../context/AuthContext'
import { API_BASE, REVOKE_URL, CLIENT_ID, REDIRECT_URI, SCOPES, AUTHORIZE_URL } from '../config'
import { generateCodeVerifier, generateCodeChallenge, generateState } from '../utils/pkce'
import {
isWebAuthnSupported,
prepareRegistrationOptions,
serializeRegistrationCredential,
} from '../utils/webauthn'
import styles from './DashboardPage.module.css'
export default function DashboardPage() {
const { accessToken, clearToken } = useAuth()
const navigate = useNavigate()
const [user, setUser] = useState(null)
const [error, setError] = useState(null)
const [loggingOut, setLoggingOut] = useState(false)
const [authUrl, setAuthUrl] = useState(null)
const [touchIdStatus, setTouchIdStatus] = useState(null) // 'registering' | 'success' | 'error'
const [touchIdMessage, setTouchIdMessage] = useState('')
const webAuthnAvailable = isWebAuthnSupported()
useEffect(() => {
async function buildAuthUrl() {
const verifier = generateCodeVerifier()
const challenge = await generateCodeChallenge(verifier)
const state = generateState()
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPES,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
})
setAuthUrl(`${AUTHORIZE_URL}?${params}`)
}
buildAuthUrl()
}, [])
useEffect(() => {
fetchUser()
}, [accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
async function fetchUser() {
try {
const res = await fetch(`${API_BASE}/me/`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setUser(data)
} catch (err) {
setError(err.message)
}
}
async function handleLogout() {
setLoggingOut(true)
try {
await fetch(REVOKE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token: accessToken, client_id: CLIENT_ID }),
})
} catch {
// Ignore revocation errors — clear locally regardless
} finally {
clearToken()
navigate('/', { replace: true })
}
}
async function handleRegisterTouchId() {
setTouchIdStatus('registering')
setTouchIdMessage('')
try {
// 1. Get registration options from server
const beginRes = await fetch(`${API_BASE}/webauthn/register/begin/`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!beginRes.ok) {
const data = await beginRes.json()
throw new Error(data.detail || 'Failed to start registration.')
}
const options = await beginRes.json()
// 2. Ask browser / authenticator (Touch ID prompt)
const credential = await navigator.credentials.create({
publicKey: prepareRegistrationOptions(options),
})
// 3. Send attestation to server
const completeRes = await fetch(`${API_BASE}/webauthn/register/complete/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(serializeRegistrationCredential(credential)),
})
const completeData = await completeRes.json()
if (!completeRes.ok) throw new Error(completeData.detail || 'Registration failed.')
setTouchIdStatus('success')
setTouchIdMessage('Touch ID registered! You can now sign in with Touch ID.')
// Refresh user to update has_touch_id flag
fetchUser()
} catch (err) {
if (err.name === 'NotAllowedError') {
setTouchIdStatus('error')
setTouchIdMessage('Biometric prompt was dismissed.')
} else {
setTouchIdStatus('error')
setTouchIdMessage(err.message || 'Registration failed.')
}
}
}
return (
<div className={styles.page}>
<header className={styles.header}>
<div className={styles.headerInner}>
<span className={styles.headerLogo}>🔐 OAuth2 Demo</span>
<button
className={styles.logoutBtn}
onClick={handleLogout}
disabled={loggingOut}
>
{loggingOut ? 'Signing out…' : 'Sign out'}
</button>
</div>
</header>
<main className={styles.main}>
<h1 className={styles.title}>Dashboard</h1>
{error && (
<div className={styles.errorBanner}>
Failed to load user info: {error}
</div>
)}
{!user && !error && (
<div className={styles.loading}>
<div className={styles.spinner} />
</div>
)}
{user && (
<div className={styles.card}>
<div className={styles.avatar}>
{user.username?.[0]?.toUpperCase() ?? '?'}
</div>
<h2 className={styles.username}>{user.username}</h2>
{user.email && <p className={styles.email}>{user.email}</p>}
<dl className={styles.details}>
<div className={styles.detailRow}>
<dt>User ID</dt>
<dd>{user.id}</dd>
</div>
<div className={styles.detailRow}>
<dt>First name</dt>
<dd>{user.first_name || '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Last name</dt>
<dd>{user.last_name || '—'}</dd>
</div>
<div className={styles.detailRow}>
<dt>Member since</dt>
<dd>{new Date(user.date_joined).toLocaleDateString()}</dd>
</div>
</dl>
<div className={styles.tokenInfo}>
<p className={styles.tokenLabel}>Access Token (truncated)</p>
<code className={styles.tokenValue}>
{accessToken.slice(0, 20)}
</code>
</div>
{/* ── Touch ID section ─────────────────────────────────── */}
{webAuthnAvailable && (
<div className={styles.touchIdSection}>
<p className={styles.touchIdLabel}>Touch ID / Biometric Login</p>
{user.has_touch_id ? (
<p className={styles.touchIdRegistered}>
Touch ID is registered for this account.
</p>
) : null}
<button
className={styles.touchIdBtn}
onClick={handleRegisterTouchId}
disabled={touchIdStatus === 'registering'}
>
{touchIdStatus === 'registering'
? 'Waiting for biometric…'
: user.has_touch_id
? 'Register another Touch ID'
: 'Register Touch ID'}
</button>
{touchIdStatus === 'success' && (
<p className={styles.touchIdSuccess}>{touchIdMessage}</p>
)}
{touchIdStatus === 'error' && (
<p className={styles.touchIdError}>{touchIdMessage}</p>
)}
</div>
)}
{authUrl && (
<div className={styles.qrSection}>
<p className={styles.qrLabel}>Authorize on another device</p>
<div className={styles.qrWrapper}>
<QRCodeSVG
value={authUrl}
size={160}
bgColor="#ffffff"
fgColor="#1a1a2e"
level="M"
/>
</div>
<p className={styles.qrHint}>
Scan to open the authorization server
</p>
</div>
)}
</div>
)}
</main>
</div>
)
}

View File

@@ -0,0 +1,275 @@
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.header {
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
padding: 0 1.5rem;
}
.headerInner {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 800px;
margin: 0 auto;
height: 60px;
}
.headerLogo {
font-size: 1rem;
font-weight: 700;
color: #1a1a2e;
}
.logoutBtn {
padding: 0.4rem 1rem;
background: transparent;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
color: #374151;
transition: background 0.15s, border-color 0.15s;
}
.logoutBtn:hover {
background: #f9fafb;
border-color: #9ca3af;
}
.logoutBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Main ─────────────────────────────────────────────────────────────────── */
.main {
flex: 1;
padding: 2.5rem 1.5rem;
max-width: 800px;
width: 100%;
margin: 0 auto;
}
.title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: #1a1a2e;
}
/* ── Card ─────────────────────────────────────────────────────────────────── */
.card {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.07);
padding: 2rem;
text-align: center;
}
.avatar {
width: 72px;
height: 72px;
border-radius: 50%;
background: #4f46e5;
color: #ffffff;
font-size: 2rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
}
.username {
font-size: 1.25rem;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 0.25rem;
}
.email {
color: #6b7280;
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
/* ── Details table ────────────────────────────────────────────────────────── */
.details {
text-align: left;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.detailRow {
display: flex;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
}
.detailRow:last-child {
border-bottom: none;
}
.detailRow dt {
width: 130px;
font-size: 0.85rem;
color: #6b7280;
font-weight: 500;
flex-shrink: 0;
}
.detailRow dd {
font-size: 0.9rem;
color: #1a1a2e;
}
/* ── Token info ───────────────────────────────────────────────────────────── */
.tokenInfo {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.75rem 1rem;
text-align: left;
}
.tokenLabel {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-bottom: 0.25rem;
}
.tokenValue {
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 0.8rem;
color: #4f46e5;
word-break: break-all;
}
/* ── Touch ID ─────────────────────────────────────────────────────────────── */
.touchIdSection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
text-align: center;
}
.touchIdLabel {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-bottom: 0.75rem;
}
.touchIdRegistered {
font-size: 0.85rem;
color: #16a34a;
margin-bottom: 0.75rem;
}
.touchIdBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.25rem;
background: #ffffff;
color: #1a1a2e;
font-size: 0.9rem;
font-weight: 600;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.touchIdBtn:hover {
border-color: #4f46e5;
background: #f5f4ff;
}
.touchIdBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.touchIdSuccess {
margin-top: 0.75rem;
font-size: 0.85rem;
color: #16a34a;
}
.touchIdError {
margin-top: 0.75rem;
font-size: 0.85rem;
color: #dc2626;
}
/* ── QR Code ──────────────────────────────────────────────────────────────── */
.qrSection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
text-align: center;
}
.qrLabel {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-bottom: 1rem;
}
.qrWrapper {
display: inline-flex;
padding: 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 0.75rem;
}
.qrHint {
font-size: 0.8rem;
color: #9ca3af;
}
/* ── States ───────────────────────────────────────────────────────────────── */
.loading {
display: flex;
justify-content: center;
padding: 4rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.errorBanner {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { generateCodeVerifier, generateCodeChallenge, generateState } from '../utils/pkce'
import {
isWebAuthnSupported,
prepareAuthenticationOptions,
serializeAuthenticationCredential,
} from '../utils/webauthn'
import { CLIENT_ID, REDIRECT_URI, SCOPES, AUTHORIZE_URL } from '../config'
import styles from './LoginPage.module.css'
export default function LoginPage() {
const { accessToken } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
const [touchIdUsername, setTouchIdUsername] = useState('')
const [touchIdLoading, setTouchIdLoading] = useState(false)
const [touchIdError, setTouchIdError] = useState(null)
const webAuthnAvailable = isWebAuthnSupported()
useEffect(() => {
if (accessToken) navigate('/dashboard', { replace: true })
}, [accessToken, navigate])
/** Shared helper: once credentials are confirmed, redirect into the OAuth PKCE flow. */
async function redirectToOAuth() {
const verifier = generateCodeVerifier()
const challenge = await generateCodeChallenge(verifier)
const state = generateState()
sessionStorage.setItem('pkce_verifier', verifier)
sessionStorage.setItem('oauth_state', state)
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPES,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
})
window.location.href = `${AUTHORIZE_URL}?${params}`
}
async function handleLogin(e) {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await fetch('/api/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const data = await res.json()
setError(data.detail || 'Login failed.')
return
}
await redirectToOAuth()
} finally {
setLoading(false)
}
}
async function handleTouchIdLogin(e) {
e.preventDefault()
setTouchIdError(null)
setTouchIdLoading(true)
try {
// 1. Get authentication options from server
const beginRes = await fetch('/api/webauthn/auth/begin/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: touchIdUsername }),
})
if (!beginRes.ok) {
const data = await beginRes.json()
throw new Error(data.detail || 'Touch ID not available for this account.')
}
const options = await beginRes.json()
// 2. Trigger biometric prompt
const credential = await navigator.credentials.get({
publicKey: prepareAuthenticationOptions(options),
})
// 3. Send assertion to server
const completeRes = await fetch('/api/webauthn/auth/complete/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: touchIdUsername,
...serializeAuthenticationCredential(credential),
}),
})
if (!completeRes.ok) {
const data = await completeRes.json()
throw new Error(data.detail || 'Touch ID verification failed.')
}
// 4. Identity confirmed — proceed to OAuth PKCE flow
await redirectToOAuth()
} catch (err) {
if (err.name === 'NotAllowedError') {
setTouchIdError('Biometric prompt was dismissed or timed out.')
} else {
setTouchIdError(err.message || 'Touch ID sign-in failed.')
}
} finally {
setTouchIdLoading(false)
}
}
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.logo}>🔐</div>
<h1 className={styles.title}>OAuth2 Demo</h1>
<p className={styles.subtitle}>
Sign in with your credentials to authorize access.
</p>
{/* ── Password login ─────────────────────────────────────── */}
<form onSubmit={handleLogin} className={styles.form}>
<input
className={styles.input}
type="text"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
required
autoComplete="username"
/>
<input
className={styles.input}
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
{error && <p className={styles.error}>{error}</p>}
<button className={styles.loginBtn} type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
{/* ── Touch ID login (shown only when WebAuthn is supported) ─ */}
{webAuthnAvailable && (
<>
<div className={styles.divider}>
<span>or</span>
</div>
<form onSubmit={handleTouchIdLogin} className={styles.form}>
<input
className={styles.input}
type="text"
placeholder="Username"
value={touchIdUsername}
onChange={e => setTouchIdUsername(e.target.value)}
required
autoComplete="username webauthn"
/>
{touchIdError && <p className={styles.error}>{touchIdError}</p>}
<button
className={styles.touchIdBtn}
type="submit"
disabled={touchIdLoading}
>
{touchIdLoading ? 'Waiting for biometric…' : 'Sign in with Touch ID'}
</button>
</form>
</>
)}
<p className={styles.hint}>
Demo credentials: <strong>admin</strong> / <strong>admin123</strong>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,193 @@
.container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.card {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.1);
padding: 3rem 2.5rem;
width: 100%;
max-width: 400px;
text-align: center;
}
.logo {
font-size: 3rem;
margin-bottom: 1rem;
}
.title {
font-size: 1.75rem;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 0.5rem;
}
.subtitle {
color: #6b7280;
font-size: 0.95rem;
margin-bottom: 2rem;
line-height: 1.5;
}
.form {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s;
}
.input:focus {
border-color: #4f46e5;
}
.error {
font-size: 0.875rem;
color: #dc2626;
margin: 0;
}
.loginBtn {
display: block;
width: 100%;
padding: 0.85rem 1.5rem;
background: #4f46e5;
color: #ffffff;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
text-align: center;
text-decoration: none;
box-sizing: border-box;
}
.loginBtn:hover {
background: #4338ca;
transform: translateY(-1px);
}
.loginBtn:active {
transform: translateY(0);
}
.loginBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.hint {
font-size: 0.85rem;
color: #9ca3af;
}
.hint strong {
color: #6b7280;
}
.divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.25rem 0 0.5rem;
color: #d1d5db;
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: #e5e7eb;
}
.touchIdBtn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.85rem 1.5rem;
background: #ffffff;
color: #1a1a2e;
font-size: 1rem;
font-weight: 600;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s, transform 0.1s;
box-sizing: border-box;
}
.touchIdBtn::before {
content: '';
display: inline-block;
width: 1.1em;
height: 1.1em;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234f46e5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
.touchIdBtn:hover {
border-color: #4f46e5;
background: #f5f4ff;
transform: translateY(-1px);
}
.touchIdBtn:active {
transform: translateY(0);
}
.touchIdBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.qrWrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: #f9fafb;
border-radius: 12px;
margin-bottom: 0.75rem;
min-height: 212px;
}
.qrPlaceholder {
width: 180px;
height: 180px;
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
font-size: 0.9rem;
}
.qrLabel {
font-size: 0.8rem;
color: #9ca3af;
margin-bottom: 1.5rem;
}

View File

@@ -0,0 +1,43 @@
/**
* PKCE (Proof Key for Code Exchange) utilities.
* Uses the Web Crypto API — no external dependencies needed.
*/
/**
* Generate a cryptographically random code verifier (43-128 chars, base64url).
*/
export function generateCodeVerifier() {
const array = new Uint8Array(48) // 48 bytes → 64-char base64url string
crypto.getRandomValues(array)
return base64urlEncode(array)
}
/**
* Derive the code challenge from the verifier (S256 method).
* @param {string} verifier
* @returns {Promise<string>}
*/
export async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return base64urlEncode(new Uint8Array(digest))
}
/**
* Generate a random state parameter to prevent CSRF.
*/
export function generateState() {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
return base64urlEncode(array)
}
// ── Internal helpers ──────────────────────────────────────────────────────────
function base64urlEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}

View File

@@ -0,0 +1,94 @@
/** Returns true when the browser supports the WebAuthn API. */
export function isWebAuthnSupported() {
return (
typeof window !== 'undefined' &&
typeof window.PublicKeyCredential === 'function'
)
}
/** Decode a base64url string to an ArrayBuffer. */
export function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, '=')
const binary = atob(padded)
const buffer = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) buffer[i] = binary.charCodeAt(i)
return buffer.buffer
}
/** Encode an ArrayBuffer (or Uint8Array) as a base64url string. */
export function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
/**
* Convert server-provided registration options so the browser API can consume them.
* The server sends base64url strings; the browser expects ArrayBuffers.
*/
export function prepareRegistrationOptions(options) {
return {
...options,
challenge: base64urlToBuffer(options.challenge),
user: {
...options.user,
id: base64urlToBuffer(options.user.id),
},
excludeCredentials: (options.excludeCredentials ?? []).map(c => ({
...c,
id: base64urlToBuffer(c.id),
})),
}
}
/**
* Convert server-provided authentication options so the browser API can consume them.
*/
export function prepareAuthenticationOptions(options) {
return {
...options,
challenge: base64urlToBuffer(options.challenge),
allowCredentials: (options.allowCredentials ?? []).map(c => ({
...c,
id: base64urlToBuffer(c.id),
})),
}
}
/**
* Serialize the PublicKeyCredential returned by navigator.credentials.create()
* into a plain JSON object the server can parse.
*/
export function serializeRegistrationCredential(credential) {
return {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
attestationObject: bufferToBase64url(credential.response.attestationObject),
},
type: credential.type,
}
}
/**
* Serialize the PublicKeyCredential returned by navigator.credentials.get()
* into a plain JSON object the server can parse.
*/
export function serializeAuthenticationCredential(credential) {
return {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
authenticatorData: bufferToBase64url(credential.response.authenticatorData),
signature: bufferToBase64url(credential.response.signature),
userHandle: credential.response.userHandle
? bufferToBase64url(credential.response.userHandle)
: null,
},
type: credential.type,
}
}

21
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
// Proxy /api calls to Django — avoids CORS issues for API requests
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
// Proxy /o (OAuth endpoints) so token exchange POST avoids CORS preflight issues
'/o': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

28
setup.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -e
echo "── Setting up Django backend ──────────────────────────────────────────────"
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py setup_oauth
echo ""
echo "── Setting up React frontend ──────────────────────────────────────────────"
cd ../frontend
npm install
echo ""
echo "── Done! ──────────────────────────────────────────────────────────────────"
echo ""
echo "Start the backend:"
echo " cd backend && source .venv/bin/activate && python manage.py runserver"
echo ""
echo "Start the frontend (new terminal):"
echo " cd frontend && npm run dev"
echo ""
echo "Open http://localhost:5173"
echo "Credentials: admin / admin123"