From d1f7052a58b4500f172b409f25ba2f477938ce42 Mon Sep 17 00:00:00 2001 From: Pat McNeely Date: Fri, 20 Mar 2026 15:09:41 -0400 Subject: [PATCH] Initial commit --- backend/api/__init__.py | 0 backend/api/management/__init__.py | 0 backend/api/management/commands/__init__.py | 0 .../api/management/commands/setup_oauth.py | 63 + backend/api/migrations/0001_initial.py | 40 + backend/api/migrations/__init__.py | 0 backend/api/models.py | 24 + backend/api/urls.py | 20 + backend/api/views.py | 280 +++ backend/config/__init__.py | 0 backend/config/settings.py | 177 ++ backend/config/urls.py | 10 + backend/config/wsgi.py | 6 + backend/manage.py | 20 + backend/requirements.txt | 6 + backend/templates/oauth2_provider/base.html | 102 + backend/templates/registration/login.html | 133 ++ frontend/index.html | 12 + frontend/package-lock.json | 1729 +++++++++++++++++ frontend/package.json | 21 + frontend/src/App.jsx | 23 + frontend/src/components/ProtectedRoute.jsx | 12 + frontend/src/config.js | 17 + frontend/src/context/AuthContext.jsx | 33 + frontend/src/index.css | 19 + frontend/src/main.jsx | 16 + frontend/src/pages/CallbackPage.jsx | 90 + frontend/src/pages/CallbackPage.module.css | 26 + frontend/src/pages/DashboardPage.jsx | 242 +++ frontend/src/pages/DashboardPage.module.css | 275 +++ frontend/src/pages/LoginPage.jsx | 199 ++ frontend/src/pages/LoginPage.module.css | 193 ++ frontend/src/utils/pkce.js | 43 + frontend/src/utils/webauthn.js | 94 + frontend/vite.config.js | 21 + setup.sh | 28 + 36 files changed, 3974 insertions(+) create mode 100644 backend/api/__init__.py create mode 100644 backend/api/management/__init__.py create mode 100644 backend/api/management/commands/__init__.py create mode 100644 backend/api/management/commands/setup_oauth.py create mode 100644 backend/api/migrations/0001_initial.py create mode 100644 backend/api/migrations/__init__.py create mode 100644 backend/api/models.py create mode 100644 backend/api/urls.py create mode 100644 backend/api/views.py create mode 100644 backend/config/__init__.py create mode 100644 backend/config/settings.py create mode 100644 backend/config/urls.py create mode 100644 backend/config/wsgi.py create mode 100644 backend/manage.py create mode 100644 backend/requirements.txt create mode 100644 backend/templates/oauth2_provider/base.html create mode 100644 backend/templates/registration/login.html create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/ProtectedRoute.jsx create mode 100644 frontend/src/config.js create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/CallbackPage.jsx create mode 100644 frontend/src/pages/CallbackPage.module.css create mode 100644 frontend/src/pages/DashboardPage.jsx create mode 100644 frontend/src/pages/DashboardPage.module.css create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/LoginPage.module.css create mode 100644 frontend/src/utils/pkce.js create mode 100644 frontend/src/utils/webauthn.js create mode 100644 frontend/vite.config.js create mode 100755 setup.sh diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/management/__init__.py b/backend/api/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/management/commands/__init__.py b/backend/api/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/management/commands/setup_oauth.py b/backend/api/management/commands/setup_oauth.py new file mode 100644 index 0000000..ec6652d --- /dev/null +++ b/backend/api/management/commands/setup_oauth.py @@ -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)') diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py new file mode 100644 index 0000000..d35499e --- /dev/null +++ b/backend/api/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/backend/api/migrations/__init__.py b/backend/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/models.py b/backend/api/models.py new file mode 100644 index 0000000..1c79a74 --- /dev/null +++ b/backend/api/models.py @@ -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'])] diff --git a/backend/api/urls.py b/backend/api/urls.py new file mode 100644 index 0000000..c69ebdb --- /dev/null +++ b/backend/api/urls.py @@ -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'), +] diff --git a/backend/api/views.py b/backend/api/views.py new file mode 100644 index 0000000..7da4d21 --- /dev/null +++ b/backend/api/views.py @@ -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) diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..d1f723b --- /dev/null +++ b/backend/config/settings.py @@ -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 diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..cd1d88a --- /dev/null +++ b/backend/config/urls.py @@ -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')), +] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..9b8e4be --- /dev/null +++ b/backend/config/wsgi.py @@ -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() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..4524f90 --- /dev/null +++ b/backend/manage.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..b4bf46d --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/templates/oauth2_provider/base.html b/backend/templates/oauth2_provider/base.html new file mode 100644 index 0000000..d251880 --- /dev/null +++ b/backend/templates/oauth2_provider/base.html @@ -0,0 +1,102 @@ + + + + + + {% block title %}OAuth2 Demo{% endblock %} + + + + {% block content %}{% endblock %} + + diff --git a/backend/templates/registration/login.html b/backend/templates/registration/login.html new file mode 100644 index 0000000..bf3f83b --- /dev/null +++ b/backend/templates/registration/login.html @@ -0,0 +1,133 @@ + + + + + + Sign in – OAuth2 Demo + + + +
+ +

Sign in

+

Enter your credentials to authorise the application

+ + {% if form.errors %} +
+ Invalid username or password. Please try again. +
+ {% endif %} + +
+ {% csrf_token %} + + +
+ + +
+ +
+ + +
+ + +
+ +

Demo: admin / admin123

+
+ + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2b0aea4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + OAuth2 Demo + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..537f28b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1729 @@ +{ + "name": "oauth-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "oauth-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e5e869a --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..db589ea --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( + + } /> + } /> + + + + } + /> + } /> + + ) +} diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..3ec014d --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -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 + } + + return children +} diff --git a/frontend/src/config.js b/frontend/src/config.js new file mode 100644 index 0000000..d9a4805 --- /dev/null +++ b/frontend/src/config.js @@ -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' diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..b35bfce --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( + + {children} + + ) +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within ') + return ctx +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..3336b4d --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..7ed9bf3 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + + + + + +) diff --git a/frontend/src/pages/CallbackPage.jsx b/frontend/src/pages/CallbackPage.jsx new file mode 100644 index 0000000..7367574 --- /dev/null +++ b/frontend/src/pages/CallbackPage.jsx @@ -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 ( +
+
+

Completing sign-in…

+
+ ) +} diff --git a/frontend/src/pages/CallbackPage.module.css b/frontend/src/pages/CallbackPage.module.css new file mode 100644 index 0000000..e15943d --- /dev/null +++ b/frontend/src/pages/CallbackPage.module.css @@ -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; +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..d2897c1 --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -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 ( +
+
+
+ 🔐 OAuth2 Demo + +
+
+ +
+

Dashboard

+ + {error && ( +
+ Failed to load user info: {error} +
+ )} + + {!user && !error && ( +
+
+
+ )} + + {user && ( +
+
+ {user.username?.[0]?.toUpperCase() ?? '?'} +
+

{user.username}

+ {user.email &&

{user.email}

} + +
+
+
User ID
+
{user.id}
+
+
+
First name
+
{user.first_name || '—'}
+
+
+
Last name
+
{user.last_name || '—'}
+
+
+
Member since
+
{new Date(user.date_joined).toLocaleDateString()}
+
+
+ +
+

Access Token (truncated)

+ + {accessToken.slice(0, 20)}… + +
+ + {/* ── Touch ID section ─────────────────────────────────── */} + {webAuthnAvailable && ( +
+

Touch ID / Biometric Login

+ {user.has_touch_id ? ( +

+ Touch ID is registered for this account. +

+ ) : null} + + {touchIdStatus === 'success' && ( +

{touchIdMessage}

+ )} + {touchIdStatus === 'error' && ( +

{touchIdMessage}

+ )} +
+ )} + + {authUrl && ( +
+

Authorize on another device

+
+ +
+

+ Scan to open the authorization server +

+
+ )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/DashboardPage.module.css b/frontend/src/pages/DashboardPage.module.css new file mode 100644 index 0000000..3c6e8df --- /dev/null +++ b/frontend/src/pages/DashboardPage.module.css @@ -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; +} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..2054342 --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -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 ( +
+
+
🔐
+

OAuth2 Demo

+

+ Sign in with your credentials to authorize access. +

+ + {/* ── Password login ─────────────────────────────────────── */} +
+ setUsername(e.target.value)} + required + autoComplete="username" + /> + setPassword(e.target.value)} + required + autoComplete="current-password" + /> + {error &&

{error}

} + +
+ + {/* ── Touch ID login (shown only when WebAuthn is supported) ─ */} + {webAuthnAvailable && ( + <> +
+ or +
+ +
+ setTouchIdUsername(e.target.value)} + required + autoComplete="username webauthn" + /> + {touchIdError &&

{touchIdError}

} + +
+ + )} + +

+ Demo credentials: admin / admin123 +

+
+
+ ) +} diff --git a/frontend/src/pages/LoginPage.module.css b/frontend/src/pages/LoginPage.module.css new file mode 100644 index 0000000..a2a8e70 --- /dev/null +++ b/frontend/src/pages/LoginPage.module.css @@ -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; +} diff --git a/frontend/src/utils/pkce.js b/frontend/src/utils/pkce.js new file mode 100644 index 0000000..0b08502 --- /dev/null +++ b/frontend/src/utils/pkce.js @@ -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} + */ +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(/=+$/, '') +} diff --git a/frontend/src/utils/webauthn.js b/frontend/src/utils/webauthn.js new file mode 100644 index 0000000..59dc8e5 --- /dev/null +++ b/frontend/src/utils/webauthn.js @@ -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, + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..7362626 --- /dev/null +++ b/frontend/vite.config.js @@ -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, + }, + }, + }, +}) diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..8c66476 --- /dev/null +++ b/setup.sh @@ -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"