diff --git a/hypha/apply/users/forms.py b/hypha/apply/users/forms.py
index 9a5a97031b..8c30835fbc 100644
--- a/hypha/apply/users/forms.py
+++ b/hypha/apply/users/forms.py
@@ -36,6 +36,8 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user_settings = AuthSettings.load(request_or_site=self.request)
self.extra_text = self.user_settings.extra_text
+ # Enable passkey autofill (conditional mediation) on the username field
+ self.fields["username"].widget.attrs["autocomplete"] = "username webauthn"
if self.user_settings.consent_show:
self.fields["consent"] = forms.BooleanField(
label=self.user_settings.consent_text,
@@ -55,7 +57,9 @@ class PasswordlessAuthForm(forms.Form):
label=_("Email address"),
required=True,
max_length=254,
- widget=forms.EmailInput(attrs={"autofocus": True, "autocomplete": "email"}),
+ widget=forms.EmailInput(
+ attrs={"autofocus": True, "autocomplete": "username webauthn"}
+ ),
)
if settings.SESSION_COOKIE_AGE <= settings.SESSION_COOKIE_AGE_LONG:
diff --git a/hypha/apply/users/middleware.py b/hypha/apply/users/middleware.py
index 07414f48fb..e632226ce4 100644
--- a/hypha/apply/users/middleware.py
+++ b/hypha/apply/users/middleware.py
@@ -103,7 +103,11 @@ def __call__(self, request):
# code to execute before the view
user = request.user
if user.is_authenticated:
- if user.social_auth.exists() or user.is_verified():
+ if (
+ user.social_auth.exists()
+ or user.is_verified()
+ or request.session.get("passkey_authenticated")
+ ):
return self._accept(request)
# Allow rounds and lab detail pages
diff --git a/hypha/apply/users/migrations/0030_passkeys.py b/hypha/apply/users/migrations/0030_passkeys.py
new file mode 100644
index 0000000000..703ff3eff6
--- /dev/null
+++ b/hypha/apply/users/migrations/0030_passkeys.py
@@ -0,0 +1,46 @@
+# Generated by Django 5.2.12 on 2026-03-23 21:24
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0029_alter_confirmaccesstoken_options_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Passkey",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(blank=True, max_length=255)),
+ ("credential_id", models.CharField(max_length=2048, unique=True)),
+ ("public_key", models.CharField(max_length=2048)),
+ ("sign_count", models.PositiveBigIntegerField(default=0)),
+ ("transports", models.JSONField(blank=True, default=list)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("last_used_at", models.DateTimeField(blank=True, null=True)),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="passkeys",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-created_at"],
+ },
+ ),
+ ]
diff --git a/hypha/apply/users/models.py b/hypha/apply/users/models.py
index 2653b1ea94..a61ffc3269 100644
--- a/hypha/apply/users/models.py
+++ b/hypha/apply/users/models.py
@@ -470,3 +470,32 @@ class Meta:
ordering = ("modified",)
verbose_name = _("Confirm Access Token")
verbose_name_plural = _("Confirm Access Tokens")
+
+
+class Passkey(models.Model):
+ """Stores a WebAuthn passkey credential for a user.
+
+ credential_id and public_key are stored as base64url-encoded strings,
+ matching the convention used by django-two-factor-auth's WebAuthn plugin.
+ """
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="passkeys",
+ )
+ name = models.CharField(max_length=255, blank=True)
+ # base64url-encoded credential id (unique per authenticator)
+ credential_id = models.CharField(max_length=2048, unique=True)
+ # base64url-encoded public key
+ public_key = models.CharField(max_length=2048)
+ sign_count = models.PositiveBigIntegerField(default=0)
+ transports = models.JSONField(default=list, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ last_used_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+
+ def __str__(self):
+ return self.name or f"Passkey {self.pk}"
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
new file mode 100644
index 0000000000..fb6facca73
--- /dev/null
+++ b/hypha/apply/users/passkey_views.py
@@ -0,0 +1,338 @@
+import base64
+import json
+import logging
+
+from django.conf import settings
+from django.contrib.auth import login
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.http import JsonResponse
+from django.shortcuts import get_object_or_404, render, resolve_url
+from django.utils import timezone
+from django.utils.http import url_has_allowed_host_and_scheme
+from django.utils.translation import gettext_lazy as _
+from django.views.decorators.http import require_GET, require_POST
+from django_ratelimit.decorators import ratelimit
+from webauthn import (
+ generate_authentication_options,
+ generate_registration_options,
+ options_to_json,
+ verify_authentication_response,
+ verify_registration_response,
+)
+from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
+from webauthn.helpers.exceptions import InvalidAuthenticationResponse
+from webauthn.helpers.structs import (
+ AuthenticationCredential,
+ AuthenticatorAssertionResponse,
+ AuthenticatorAttestationResponse,
+ AuthenticatorSelectionCriteria,
+ PublicKeyCredentialDescriptor,
+ RegistrationCredential,
+ ResidentKeyRequirement,
+ UserVerificationRequirement,
+)
+
+from .models import Passkey
+
+logger = logging.getLogger(__name__)
+
+SESSION_CHALLENGE_KEY_REGISTER = "webauthn_challenge_register"
+SESSION_CHALLENGE_KEY_AUTH = "webauthn_challenge_auth"
+
+
+def _get_rp_id(request):
+ rp_id = getattr(settings, "WEBAUTHN_RP_ID", None)
+ if rp_id:
+ return rp_id
+ return request.get_host().split(":")[0]
+
+
+def _get_rp_name():
+ return getattr(settings, "WEBAUTHN_RP_NAME", None) or settings.ORG_LONG_NAME
+
+
+def _get_origin(request):
+ origin = getattr(settings, "WEBAUTHN_ORIGIN", None)
+ if origin:
+ return origin
+ scheme = "https" if request.is_secure() else "http"
+ return f"{scheme}://{request.get_host()}"
+
+
+def _store_challenge(request, challenge: bytes, key: str):
+ request.session[key] = base64.b64encode(challenge).decode()
+
+
+def _load_challenge(request, key: str) -> bytes:
+ encoded = request.session.pop(key, None)
+ if not encoded:
+ raise PermissionDenied("No active WebAuthn challenge.")
+ return base64.b64decode(encoded)
+
+
+# ---------------------------------------------------------------------------
+# Registration — requires an authenticated user
+# ---------------------------------------------------------------------------
+
+
+MAX_PASSKEYS_PER_USER = 10
+
+
+@login_required
+@require_POST
+@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
+def passkey_register_begin(request):
+ user = request.user
+ existing_passkeys = list(user.passkeys.all())
+ if len(existing_passkeys) >= MAX_PASSKEYS_PER_USER:
+ return JsonResponse(
+ {
+ "error": _("Maximum of {max} passkeys allowed").format(
+ max=MAX_PASSKEYS_PER_USER
+ )
+ },
+ status=400,
+ )
+ existing = [
+ PublicKeyCredentialDescriptor(
+ id=base64url_to_bytes(pk.credential_id),
+ )
+ for pk in existing_passkeys
+ ]
+ options = generate_registration_options(
+ rp_id=_get_rp_id(request),
+ rp_name=_get_rp_name(),
+ user_id=str(user.pk).encode(),
+ user_name=user.email,
+ user_display_name=user.get_full_name() or user.email,
+ authenticator_selection=AuthenticatorSelectionCriteria(
+ resident_key=ResidentKeyRequirement.REQUIRED,
+ user_verification=UserVerificationRequirement.REQUIRED,
+ ),
+ exclude_credentials=existing,
+ )
+ _store_challenge(request, options.challenge, SESSION_CHALLENGE_KEY_REGISTER)
+ return JsonResponse(json.loads(options_to_json(options)))
+
+
+@login_required
+@require_POST
+@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
+def passkey_register_complete(request):
+ try:
+ data = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({"error": _("Invalid JSON")}, status=400)
+
+ try:
+ challenge = _load_challenge(request, SESSION_CHALLENGE_KEY_REGISTER)
+ except PermissionDenied:
+ return JsonResponse({"error": _("No active WebAuthn challenge")}, status=400)
+
+ 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"]
+ ),
+ transports=data["response"].get("transports", []),
+ ),
+ )
+ verification = verify_registration_response(
+ credential=credential,
+ expected_challenge=challenge,
+ expected_rp_id=_get_rp_id(request),
+ expected_origin=_get_origin(request),
+ require_user_verification=True,
+ )
+ except Exception:
+ logger.warning(
+ "Passkey registration verification failed for user %s",
+ request.user.pk,
+ exc_info=True,
+ )
+ return JsonResponse({"error": _("Verification failed")}, status=400)
+
+ name = (data.get("name") or "").strip()[:128] or timezone.now().strftime(
+ "Passkey %Y-%m-%d"
+ )
+ try:
+ Passkey.objects.create(
+ user=request.user,
+ name=name,
+ credential_id=bytes_to_base64url(verification.credential_id),
+ public_key=bytes_to_base64url(verification.credential_public_key),
+ sign_count=verification.sign_count,
+ transports=data["response"].get("transports", []),
+ )
+ except Exception:
+ logger.warning(
+ "Failed to save passkey for user %s", request.user.pk, exc_info=True
+ )
+ return JsonResponse({"error": _("Could not save passkey")}, status=500)
+ logger.info("Passkey registered for user %s (name=%r)", request.user.pk, name)
+ return JsonResponse({"status": "ok"})
+
+
+# ---------------------------------------------------------------------------
+# Authentication — public (no session required)
+# ---------------------------------------------------------------------------
+
+
+@require_POST
+@ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
+def passkey_auth_begin(request):
+ options = generate_authentication_options(
+ rp_id=_get_rp_id(request),
+ user_verification=UserVerificationRequirement.REQUIRED,
+ )
+ _store_challenge(request, options.challenge, SESSION_CHALLENGE_KEY_AUTH)
+ return JsonResponse(json.loads(options_to_json(options)))
+
+
+@require_POST
+@ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
+def passkey_auth_complete(request):
+ try:
+ data = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({"error": _("Invalid JSON")}, status=400)
+
+ try:
+ challenge = _load_challenge(request, SESSION_CHALLENGE_KEY_AUTH)
+ except PermissionDenied:
+ return JsonResponse({"error": _("No active WebAuthn challenge")}, status=400)
+
+ try:
+ credential_id_b64 = bytes_to_base64url(base64url_to_bytes(data["rawId"]))
+ raw_user_handle = data["response"].get("userHandle")
+ user_handle_bytes = (
+ base64url_to_bytes(raw_user_handle) if raw_user_handle else None
+ )
+ except Exception:
+ return JsonResponse({"error": _("Invalid credential")}, status=400)
+
+ try:
+ with transaction.atomic():
+ passkey = (
+ Passkey.objects.select_related("user")
+ .select_for_update()
+ .get(credential_id=credential_id_b64)
+ )
+
+ if user_handle_bytes is not None:
+ if user_handle_bytes != str(passkey.user.pk).encode():
+ raise InvalidAuthenticationResponse("User handle mismatch")
+
+ 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=user_handle_bytes,
+ ),
+ )
+ verification = verify_authentication_response(
+ credential=credential,
+ expected_challenge=challenge,
+ expected_rp_id=_get_rp_id(request),
+ expected_origin=_get_origin(request),
+ credential_public_key=base64url_to_bytes(passkey.public_key),
+ credential_current_sign_count=passkey.sign_count,
+ require_user_verification=True,
+ )
+
+ passkey.sign_count = verification.new_sign_count
+ passkey.last_used_at = timezone.now()
+ passkey.save(update_fields=["sign_count", "last_used_at"])
+
+ user = passkey.user
+ except Passkey.DoesNotExist:
+ return JsonResponse({"error": _("Unknown credential")}, status=400)
+ except InvalidAuthenticationResponse as exc:
+ if "sign count" in str(exc).lower():
+ logger.error(
+ "Passkey sign count regression — possible cloned authenticator"
+ " (credential=%s): %s",
+ credential_id_b64,
+ exc,
+ )
+ else:
+ logger.warning(
+ "Passkey authentication verification failed for credential %s: %s",
+ credential_id_b64,
+ exc,
+ )
+ return JsonResponse({"error": _("Verification failed")}, status=400)
+ except Exception:
+ logger.warning(
+ "Passkey authentication verification failed for credential %s",
+ credential_id_b64,
+ exc_info=True,
+ )
+ return JsonResponse({"error": _("Verification failed")}, status=400)
+ user.backend = settings.CUSTOM_AUTH_BACKEND
+ login(request, user)
+ request.session["passkey_authenticated"] = True
+
+ if data.get("remember_me"):
+ request.session.set_expiry(settings.SESSION_COOKIE_AGE_LONG)
+
+ next_url = data.get("next") or resolve_url(settings.LOGIN_REDIRECT_URL)
+ if not url_has_allowed_host_and_scheme(
+ next_url,
+ allowed_hosts={request.get_host()},
+ require_https=request.is_secure(),
+ ):
+ next_url = resolve_url(settings.LOGIN_REDIRECT_URL)
+ return JsonResponse({"status": "ok", "redirect_url": next_url})
+
+
+# ---------------------------------------------------------------------------
+# Passkey management — account page
+# ---------------------------------------------------------------------------
+
+
+@login_required
+@require_GET
+def passkey_list(request):
+ passkeys = request.user.passkeys.all()
+ return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})
+
+
+@login_required
+@require_POST
+def passkey_delete(request, pk):
+ passkey = get_object_or_404(Passkey, pk=pk, user=request.user)
+ logger.info(
+ "Passkey deleted by user %s (passkey=%s, name=%r)",
+ request.user.pk,
+ pk,
+ passkey.name,
+ )
+ passkey.delete()
+ passkeys = request.user.passkeys.all()
+ return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})
+
+
+@login_required
+@require_POST
+def passkey_rename(request, pk):
+ passkey = get_object_or_404(Passkey, pk=pk, user=request.user)
+ name = request.POST.get("name", "").strip()[:128]
+ if name:
+ passkey.name = name
+ passkey.save(update_fields=["name"])
+ passkeys = request.user.passkeys.all()
+ return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})
diff --git a/hypha/apply/users/templates/users/account.html b/hypha/apply/users/templates/users/account.html
index b3561c7feb..cd7e1e18d6 100644
--- a/hypha/apply/users/templates/users/account.html
+++ b/hypha/apply/users/templates/users/account.html
@@ -1,5 +1,5 @@
{% extends 'base-apply.html' %}
-{% load i18n users_tags wagtailcore_tags heroicons %}
+{% load i18n users_tags wagtailcore_tags heroicons static %}
{% block title %}{% trans "Account" %}{% endblock %}
@@ -86,13 +86,26 @@
{% if default_device %}
- {% trans "Backup codes" %}
- {% trans "Disable 2FA" %}
+ {% trans "Backup codes" %}
+ {% trans "Disable 2FA" %}
{% else %}
- {% trans "Enable 2FA" %}
+ {% trans "Enable 2FA" %}
{% endif %}
+ {% trans "Passkeys" %}
+
+ {% trans "With passkeys you can use your fingerprint, face, or screen lock to login securely without a password." %}
+
+
+
+
{# Remove the comment block tags below when such need arises. e.g. adding new providers #}
{% comment %}
{% can_use_oauth as show_oauth_link %}
@@ -108,3 +121,7 @@ {% trans "Manage OAuth" %}
{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/hypha/templates/includes/org_login_button.html b/hypha/apply/users/templates/users/includes/org_login_button.html
similarity index 100%
rename from hypha/templates/includes/org_login_button.html
rename to hypha/apply/users/templates/users/includes/org_login_button.html
diff --git a/hypha/apply/users/templates/users/includes/passkey_login_button.html b/hypha/apply/users/templates/users/includes/passkey_login_button.html
new file mode 100644
index 0000000000..2efeada156
--- /dev/null
+++ b/hypha/apply/users/templates/users/includes/passkey_login_button.html
@@ -0,0 +1,20 @@
+{% load i18n heroicons %}
+
+ {% heroicon_mini "key" size=18 class="opacity-80" aria_hidden=true %}
+ {% trans "Log in with passkey" %}
+
+
diff --git a/hypha/templates/includes/password_login_button.html b/hypha/apply/users/templates/users/includes/password_login_button.html
similarity index 100%
rename from hypha/templates/includes/password_login_button.html
rename to hypha/apply/users/templates/users/includes/password_login_button.html
diff --git a/hypha/templates/includes/passwordless_login_button.html b/hypha/apply/users/templates/users/includes/passwordless_login_button.html
similarity index 100%
rename from hypha/templates/includes/passwordless_login_button.html
rename to hypha/apply/users/templates/users/includes/passwordless_login_button.html
diff --git a/hypha/templates/includes/user_menu.html b/hypha/apply/users/templates/users/includes/user_menu.html
similarity index 97%
rename from hypha/templates/includes/user_menu.html
rename to hypha/apply/users/templates/users/includes/user_menu.html
index 6e1f049280..462fd66d76 100644
--- a/hypha/templates/includes/user_menu.html
+++ b/hypha/apply/users/templates/users/includes/user_menu.html
@@ -95,7 +95,7 @@
{% else %}
{% heroicon_micro "user" class="inline align-text-bottom size-4 me-1" aria_hidden=true %}
{% trans "Log in" %} {% if ENABLE_PUBLIC_SIGNUP %} {% trans " or Sign up" %} {% endif %}
diff --git a/hypha/apply/users/templates/users/login.html b/hypha/apply/users/templates/users/login.html
index 0bab347af5..868e983ce6 100644
--- a/hypha/apply/users/templates/users/login.html
+++ b/hypha/apply/users/templates/users/login.html
@@ -1,10 +1,10 @@
{% extends "base-apply.html" %}
-{% load i18n wagtailcore_tags heroicons %}
+{% load i18n wagtailcore_tags heroicons static %}
{% block title %}{% trans "Log in" %}{% endblock %}
{% block content %}
-
+
{% if wizard.steps.current == 'token' %}
{% if device.method == 'call' %}
@@ -90,10 +90,11 @@
{% translate "OR" %}
+ {% include "users/includes/passwordless_login_button.html" %}
+ {% include "users/includes/passkey_login_button.html" %}
{% if GOOGLE_OAUTH2 %}
- {% include "includes/org_login_button.html" %}
+ {% include "users/includes/org_login_button.html" %}
{% endif %}
- {% include "includes/passwordless_login_button.html" %}
{% else %}
@@ -142,6 +143,12 @@
{% block extra_js %}
{{ block.super }}
+
+
{# Fix copy of dynamic fields label #}
+
+{% endblock %}
diff --git a/hypha/apply/users/tests/test_passkey_views.py b/hypha/apply/users/tests/test_passkey_views.py
new file mode 100644
index 0000000000..09901a0e11
--- /dev/null
+++ b/hypha/apply/users/tests/test_passkey_views.py
@@ -0,0 +1,627 @@
+"""Tests for WebAuthn passkey views (passkey_views.py)."""
+
+import base64
+import json
+from unittest.mock import MagicMock, patch
+
+from django.conf import settings
+from django.test import TestCase, override_settings
+from django.urls import reverse
+from webauthn.helpers import bytes_to_base64url
+from webauthn.helpers.exceptions import InvalidAuthenticationResponse
+
+from ..models import Passkey
+from ..passkey_views import (
+ MAX_PASSKEYS_PER_USER,
+ SESSION_CHALLENGE_KEY_AUTH,
+ SESSION_CHALLENGE_KEY_REGISTER,
+)
+from .factories import UserFactory
+
+AUTH_BEGIN_URL = reverse("users:passkey_auth_begin")
+AUTH_COMPLETE_URL = reverse("users:passkey_auth_complete")
+REGISTER_BEGIN_URL = reverse("users:passkey_register_begin")
+REGISTER_COMPLETE_URL = reverse("users:passkey_register_complete")
+PASSKEY_LIST_URL = reverse("users:passkey_list")
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def make_passkey(
+ user, credential_id=None, public_key=None, name="Test Passkey", **kwargs
+):
+ """Create a Passkey for a user with sensible defaults."""
+ kwargs.setdefault("sign_count", 0)
+ kwargs.setdefault("transports", ["internal"])
+ return Passkey.objects.create(
+ user=user,
+ name=name,
+ # base64url of b"test-cred-id" and b"test-pubkey"
+ credential_id=credential_id or bytes_to_base64url(b"test-cred-id"),
+ public_key=public_key or bytes_to_base64url(b"test-pubkey"),
+ **kwargs,
+ )
+
+
+def set_challenge(client, key, challenge_bytes=b"test-challenge"):
+ """Store a base64-encoded WebAuthn challenge in the test client session."""
+ session = client.session
+ session[key] = base64.b64encode(challenge_bytes).decode()
+ session.save()
+
+
+# ---------------------------------------------------------------------------
+# Registration begin
+# ---------------------------------------------------------------------------
+
+
+@override_settings(RATELIMIT_ENABLE=False)
+class TestPasskeyRegisterBegin(TestCase):
+ def setUp(self):
+ self.user = UserFactory()
+ self.client.force_login(self.user)
+
+ def test_requires_login(self):
+ self.client.logout()
+ response = self.client.post(REGISTER_BEGIN_URL)
+ self.assertEqual(response.status_code, 302)
+
+ def test_requires_post(self):
+ response = self.client.get(REGISTER_BEGIN_URL)
+ self.assertEqual(response.status_code, 405)
+
+ def test_returns_registration_options(self):
+ response = self.client.post(REGISTER_BEGIN_URL)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertIn("challenge", data)
+ self.assertIn("rp", data)
+ self.assertIn("user", data)
+
+ def test_stores_challenge_in_session(self):
+ self.client.post(REGISTER_BEGIN_URL)
+ self.assertIn(SESSION_CHALLENGE_KEY_REGISTER, self.client.session)
+
+ def test_returns_400_when_max_passkeys_reached(self):
+ for i in range(MAX_PASSKEYS_PER_USER):
+ make_passkey(
+ self.user, credential_id=bytes_to_base64url(f"cred{i}".encode())
+ )
+ response = self.client.post(REGISTER_BEGIN_URL)
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json())
+
+
+# ---------------------------------------------------------------------------
+# Registration complete
+# ---------------------------------------------------------------------------
+
+
+@override_settings(RATELIMIT_ENABLE=False)
+class TestPasskeyRegisterComplete(TestCase):
+ CHALLENGE = b"test-challenge-register"
+
+ def setUp(self):
+ self.user = UserFactory()
+ self.client.force_login(self.user)
+
+ def _set_challenge(self):
+ set_challenge(self.client, SESSION_CHALLENGE_KEY_REGISTER, self.CHALLENGE)
+
+ def _payload(self, name="My Key"):
+ return {
+ "id": bytes_to_base64url(b"new-cred"),
+ "rawId": bytes_to_base64url(b"new-cred"),
+ "name": name,
+ "response": {
+ "clientDataJSON": bytes_to_base64url(b"clientdata"),
+ "attestationObject": bytes_to_base64url(b"attestation"),
+ "transports": ["internal"],
+ },
+ "type": "public-key",
+ }
+
+ def test_requires_login(self):
+ self.client.logout()
+ self._set_challenge()
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 302)
+
+ def test_invalid_json_returns_400(self):
+ self._set_challenge()
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data="not-valid-json",
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json())
+
+ def test_missing_challenge_returns_400(self):
+ # No challenge placed in session
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json())
+
+ @patch("hypha.apply.users.passkey_views.verify_registration_response")
+ def test_successful_registration_saves_passkey(self, mock_verify):
+ mock_verify.return_value = MagicMock(
+ credential_id=b"saved-cred-id",
+ credential_public_key=b"saved-pubkey",
+ sign_count=0,
+ )
+ self._set_challenge()
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(self._payload(name="My Key")),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["status"], "ok")
+ self.assertEqual(self.user.passkeys.count(), 1)
+ self.assertEqual(self.user.passkeys.first().name, "My Key")
+
+ @patch("hypha.apply.users.passkey_views.verify_registration_response")
+ def test_name_is_truncated_to_128_chars(self, mock_verify):
+ mock_verify.return_value = MagicMock(
+ credential_id=b"cred",
+ credential_public_key=b"pubkey",
+ sign_count=0,
+ )
+ self._set_challenge()
+ long_name = "x" * 200
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(self._payload(name=long_name)),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(self.user.passkeys.first().name, "x" * 128)
+
+ @patch("hypha.apply.users.passkey_views.verify_registration_response")
+ def test_empty_name_gets_date_default(self, mock_verify):
+ mock_verify.return_value = MagicMock(
+ credential_id=b"cred",
+ credential_public_key=b"pubkey",
+ sign_count=0,
+ )
+ self._set_challenge()
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(self._payload(name="")),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ passkey = self.user.passkeys.first()
+ self.assertTrue(passkey.name.startswith("Passkey "))
+
+ @patch("hypha.apply.users.passkey_views.verify_registration_response")
+ def test_verification_failure_returns_400_and_saves_nothing(self, mock_verify):
+ mock_verify.side_effect = Exception("crypto error")
+ self._set_challenge()
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json())
+ self.assertEqual(self.user.passkeys.count(), 0)
+
+ def test_challenge_is_consumed_and_cannot_be_replayed(self):
+ """The registration challenge must be single-use."""
+ self._set_challenge()
+ # First call consumes the challenge (will fail verification, that's OK)
+ self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ # Second call with the same payload must fail with "no challenge" error
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("challenge", response.json()["error"].lower())
+
+
+# ---------------------------------------------------------------------------
+# Authentication begin
+# ---------------------------------------------------------------------------
+
+
+@override_settings(RATELIMIT_ENABLE=False)
+class TestPasskeyAuthBegin(TestCase):
+ def test_requires_post(self):
+ response = self.client.get(AUTH_BEGIN_URL)
+ self.assertEqual(response.status_code, 405)
+
+ def test_returns_authentication_options(self):
+ response = self.client.post(AUTH_BEGIN_URL)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertIn("challenge", data)
+ self.assertIn("rpId", data)
+
+ def test_stores_challenge_in_session(self):
+ self.client.post(AUTH_BEGIN_URL)
+ self.assertIn(SESSION_CHALLENGE_KEY_AUTH, self.client.session)
+
+ def test_accessible_without_login(self):
+ response = self.client.post(AUTH_BEGIN_URL)
+ self.assertEqual(response.status_code, 200)
+
+
+# ---------------------------------------------------------------------------
+# Authentication complete
+# ---------------------------------------------------------------------------
+
+CRED_ID = bytes_to_base64url(b"auth-cred-id")
+PUBKEY = bytes_to_base64url(b"auth-pubkey")
+
+
+@override_settings(RATELIMIT_ENABLE=False)
+class TestPasskeyAuthComplete(TestCase):
+ CHALLENGE = b"test-challenge-auth"
+
+ def setUp(self):
+ self.user = UserFactory()
+ self.passkey = make_passkey(
+ self.user,
+ credential_id=CRED_ID,
+ public_key=PUBKEY,
+ sign_count=0,
+ )
+
+ def _set_challenge(self):
+ set_challenge(self.client, SESSION_CHALLENGE_KEY_AUTH, self.CHALLENGE)
+
+ def _payload(self, **overrides):
+ payload = {
+ "id": CRED_ID,
+ "rawId": CRED_ID,
+ "response": {
+ "clientDataJSON": bytes_to_base64url(b"clientdata"),
+ "authenticatorData": bytes_to_base64url(b"authdata"),
+ "signature": bytes_to_base64url(b"sig"),
+ },
+ "type": "public-key",
+ }
+ payload.update(overrides)
+ return payload
+
+ def test_requires_post(self):
+ response = self.client.get(AUTH_COMPLETE_URL)
+ self.assertEqual(response.status_code, 405)
+
+ def test_invalid_json_returns_400(self):
+ self._set_challenge()
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data="not-json",
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json())
+
+ def test_missing_challenge_returns_400(self):
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json())
+
+ def test_unknown_credential_returns_400(self):
+ self._set_challenge()
+ unknown_id = bytes_to_base64url(b"no-such-cred")
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(self._payload(id=unknown_id, rawId=unknown_id)),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json())
+
+ @patch("hypha.apply.users.passkey_views.verify_authentication_response")
+ def test_successful_auth_logs_in_user(self, mock_verify):
+ mock_verify.return_value = MagicMock(new_sign_count=1)
+ self._set_challenge()
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["status"], "ok")
+ self.assertIn("redirect_url", response.json())
+ self.assertEqual(int(self.client.session["_auth_user_id"]), self.user.pk)
+
+ @patch("hypha.apply.users.passkey_views.verify_authentication_response")
+ def test_successful_auth_sets_passkey_session_flag(self, mock_verify):
+ mock_verify.return_value = MagicMock(new_sign_count=1)
+ self._set_challenge()
+ self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.assertTrue(self.client.session.get("passkey_authenticated"))
+
+ @patch("hypha.apply.users.passkey_views.verify_authentication_response")
+ def test_successful_auth_updates_sign_count(self, mock_verify):
+ mock_verify.return_value = MagicMock(new_sign_count=42)
+ self._set_challenge()
+ self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.passkey.refresh_from_db()
+ self.assertEqual(self.passkey.sign_count, 42)
+
+ @patch("hypha.apply.users.passkey_views.verify_authentication_response")
+ def test_successful_auth_updates_last_used_at(self, mock_verify):
+ mock_verify.return_value = MagicMock(new_sign_count=1)
+ self._set_challenge()
+ self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.passkey.refresh_from_db()
+ self.assertIsNotNone(self.passkey.last_used_at)
+
+ @patch("hypha.apply.users.passkey_views.verify_authentication_response")
+ def test_open_redirect_falls_back_to_login_redirect(self, mock_verify):
+ mock_verify.return_value = MagicMock(new_sign_count=1)
+ self._set_challenge()
+ payload = self._payload()
+ payload["next"] = "https://evil.example.com/steal"
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ redirect_url = response.json()["redirect_url"]
+ self.assertNotIn("evil.example.com", redirect_url)
+
+ @patch("hypha.apply.users.passkey_views.verify_authentication_response")
+ def test_valid_next_url_is_passed_through(self, mock_verify):
+ mock_verify.return_value = MagicMock(new_sign_count=1)
+ self._set_challenge()
+ payload = self._payload()
+ payload["next"] = "/apply/"
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["redirect_url"], "/apply/")
+
+ def test_user_handle_mismatch_returns_400(self):
+ """A userHandle that doesn't match the passkey owner must be rejected."""
+ other_user = UserFactory()
+ # Encode other_user's pk as the handle — view decodes and compares against
+ # str(passkey.user.pk).encode(), which is this user's pk.
+ wrong_handle = bytes_to_base64url(str(other_user.pk).encode())
+ self._set_challenge()
+ payload = self._payload()
+ payload["response"]["userHandle"] = wrong_handle
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json())
+
+ @patch("hypha.apply.users.passkey_views.verify_authentication_response")
+ def test_verification_failure_returns_400(self, mock_verify):
+ mock_verify.side_effect = InvalidAuthenticationResponse("bad signature")
+ self._set_challenge()
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json())
+
+ def test_challenge_is_consumed_and_cannot_be_replayed(self):
+ """The auth challenge must be single-use (popped from session)."""
+ self._set_challenge()
+ # First call pops the challenge
+ self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ # Second call must fail because challenge is gone
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps(self._payload()),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("challenge", response.json()["error"].lower())
+
+
+# ---------------------------------------------------------------------------
+# Passkey list
+# ---------------------------------------------------------------------------
+
+
+class TestPasskeyList(TestCase):
+ def setUp(self):
+ self.user = UserFactory()
+ self.client.force_login(self.user)
+
+ def test_requires_login(self):
+ self.client.logout()
+ response = self.client.get(PASSKEY_LIST_URL)
+ self.assertEqual(response.status_code, 302)
+
+ def test_requires_get(self):
+ response = self.client.post(PASSKEY_LIST_URL)
+ self.assertEqual(response.status_code, 405)
+
+ def test_returns_200_with_no_passkeys(self):
+ response = self.client.get(PASSKEY_LIST_URL)
+ self.assertEqual(response.status_code, 200)
+
+ def test_shows_own_passkeys(self):
+ make_passkey(self.user, name="My MacBook")
+ response = self.client.get(PASSKEY_LIST_URL)
+ self.assertContains(response, "My MacBook")
+
+ def test_does_not_show_other_users_passkeys(self):
+ other = UserFactory()
+ make_passkey(other, name="Someone Elses Key")
+ response = self.client.get(PASSKEY_LIST_URL)
+ self.assertNotContains(response, "Someone Elses Key")
+
+
+# ---------------------------------------------------------------------------
+# Passkey delete
+# ---------------------------------------------------------------------------
+
+
+class TestPasskeyDelete(TestCase):
+ def setUp(self):
+ self.user = UserFactory()
+ self.client.force_login(self.user)
+
+ def test_requires_login(self):
+ self.client.logout()
+ passkey = make_passkey(self.user)
+ url = reverse("users:passkey_delete", args=[passkey.pk])
+ response = self.client.post(url)
+ self.assertEqual(response.status_code, 302)
+
+ def test_deletes_own_passkey(self):
+ passkey = make_passkey(self.user)
+ url = reverse("users:passkey_delete", args=[passkey.pk])
+ response = self.client.post(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertFalse(Passkey.objects.filter(pk=passkey.pk).exists())
+
+ def test_cannot_delete_other_users_passkey(self):
+ other = UserFactory()
+ passkey = make_passkey(other)
+ url = reverse("users:passkey_delete", args=[passkey.pk])
+ response = self.client.post(url)
+ self.assertEqual(response.status_code, 404)
+ self.assertTrue(Passkey.objects.filter(pk=passkey.pk).exists())
+
+ def test_returns_updated_passkey_list_partial(self):
+ passkey = make_passkey(self.user)
+ url = reverse("users:passkey_delete", args=[passkey.pk])
+ response = self.client.post(url)
+ self.assertTemplateUsed(response, "users/partials/passkey-list.html")
+
+
+# ---------------------------------------------------------------------------
+# Passkey rename
+# ---------------------------------------------------------------------------
+
+
+class TestPasskeyRename(TestCase):
+ def setUp(self):
+ self.user = UserFactory()
+ self.client.force_login(self.user)
+
+ def test_requires_login(self):
+ self.client.logout()
+ passkey = make_passkey(self.user)
+ url = reverse("users:passkey_rename", args=[passkey.pk])
+ response = self.client.post(url, {"name": "New Name"})
+ self.assertEqual(response.status_code, 302)
+
+ def test_renames_own_passkey(self):
+ passkey = make_passkey(self.user, name="Old Name")
+ url = reverse("users:passkey_rename", args=[passkey.pk])
+ response = self.client.post(url, {"name": "New Name"})
+ self.assertEqual(response.status_code, 200)
+ passkey.refresh_from_db()
+ self.assertEqual(passkey.name, "New Name")
+
+ def test_cannot_rename_other_users_passkey(self):
+ other = UserFactory()
+ passkey = make_passkey(other, name="Original")
+ url = reverse("users:passkey_rename", args=[passkey.pk])
+ response = self.client.post(url, {"name": "Hacked Name"})
+ self.assertEqual(response.status_code, 404)
+ passkey.refresh_from_db()
+ self.assertEqual(passkey.name, "Original")
+
+ def test_whitespace_only_name_is_ignored(self):
+ passkey = make_passkey(self.user, name="Original")
+ url = reverse("users:passkey_rename", args=[passkey.pk])
+ response = self.client.post(url, {"name": " "})
+ self.assertEqual(response.status_code, 200)
+ passkey.refresh_from_db()
+ self.assertEqual(passkey.name, "Original")
+
+ def test_name_is_trimmed(self):
+ passkey = make_passkey(self.user)
+ url = reverse("users:passkey_rename", args=[passkey.pk])
+ self.client.post(url, {"name": " Trimmed "})
+ passkey.refresh_from_db()
+ self.assertEqual(passkey.name, "Trimmed")
+
+ def test_name_is_truncated_to_128_chars(self):
+ passkey = make_passkey(self.user)
+ url = reverse("users:passkey_rename", args=[passkey.pk])
+ self.client.post(url, {"name": "x" * 200})
+ passkey.refresh_from_db()
+ self.assertEqual(passkey.name, "x" * 128)
+
+ def test_returns_updated_passkey_list_partial(self):
+ passkey = make_passkey(self.user)
+ url = reverse("users:passkey_rename", args=[passkey.pk])
+ response = self.client.post(url, {"name": "Updated"})
+ self.assertTemplateUsed(response, "users/partials/passkey-list.html")
+
+
+# ---------------------------------------------------------------------------
+# Middleware — passkey_authenticated session flag bypasses ENFORCE_TWO_FACTOR
+# ---------------------------------------------------------------------------
+
+
+@override_settings(ENFORCE_TWO_FACTOR=True)
+class TestPasskeyMiddlewareBypass(TestCase):
+ def test_passkey_session_flag_bypasses_2fa_enforcement(self):
+ user = UserFactory()
+ self.client.force_login(user)
+ session = self.client.session
+ session["passkey_authenticated"] = True
+ session.save()
+
+ # The middleware should let the request through — account page is always accessible.
+ response = self.client.get(reverse("users:account"), follow=True)
+ self.assertNotContains(response, "Permission Denied")
+
+ def test_without_passkey_flag_unverified_user_is_blocked(self):
+ user = UserFactory()
+ self.client.force_login(user)
+ # No passkey_authenticated flag and no OTP device
+
+ response = self.client.get(settings.LOGIN_REDIRECT_URL, follow=True)
+ self.assertContains(response, "Permission Denied")
diff --git a/hypha/apply/users/urls.py b/hypha/apply/users/urls.py
index 23d51f75f2..47c0907a65 100644
--- a/hypha/apply/users/urls.py
+++ b/hypha/apply/users/urls.py
@@ -6,6 +6,15 @@
from hypha.elevate.views import elevate as elevate_view
+from .passkey_views import (
+ passkey_auth_begin,
+ passkey_auth_complete,
+ passkey_delete,
+ passkey_list,
+ passkey_register_begin,
+ passkey_register_complete,
+ passkey_rename,
+)
from .views import (
AccountView,
ActivationView,
@@ -39,6 +48,17 @@
),
path("login/", LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(next_page="/"), name="logout"),
+ # Passkey authentication — public (no login required)
+ path(
+ "passkeys/auth/begin/",
+ passkey_auth_begin,
+ name="passkey_auth_begin",
+ ),
+ path(
+ "passkeys/auth/complete/",
+ passkey_auth_complete,
+ name="passkey_auth_complete",
+ ),
]
account_urls = [
@@ -115,6 +135,28 @@
name="activate",
),
path("hijack/", hijack_view, name="hijack"),
+ # Passkey management
+ path("passkeys/", passkey_list, name="passkey_list"),
+ path(
+ "passkeys/register/begin/",
+ passkey_register_begin,
+ name="passkey_register_begin",
+ ),
+ path(
+ "passkeys/register/complete/",
+ passkey_register_complete,
+ name="passkey_register_complete",
+ ),
+ path(
+ "passkeys//delete/",
+ passkey_delete,
+ name="passkey_delete",
+ ),
+ path(
+ "passkeys//rename/",
+ passkey_rename,
+ name="passkey_rename",
+ ),
path("activate/", create_password, name="activate_password"),
path("oauth", oauth, name="oauth"),
# 2FA
diff --git a/hypha/settings/base.py b/hypha/settings/base.py
index 161a6dfbaa..8eda2bb0ba 100644
--- a/hypha/settings/base.py
+++ b/hypha/settings/base.py
@@ -30,9 +30,21 @@
# DEFAULT_RATE_LIMIT is used by login, password, 2FA, etc
DEFAULT_RATE_LIMIT = env.str("DEFAULT_RATE_LIMIT", "5/m")
-# IF Hypha should enforce 2FA for all users.
+# If Hypha should enforce 2FA for all users.
+# Users that login with passkeys are excluded since that is even more secure.
ENFORCE_TWO_FACTOR = env.bool("ENFORCE_TWO_FACTOR", False)
+# WebAuthn / Passkey settings.
+# WEBAUTHN_RP_ID: the relying party domain, e.g. "example.com" (no port, no scheme).
+# Defaults to the request host if not set. OBS! Do not use default in production!
+# WEBAUTHN_ORIGIN: the full origin, e.g. "https://example.com".
+# Defaults to the request origin if not set.
+# WEBAUTHN_RP_NAME: display name shown in the browser passkey UI.
+# Defaults to ORG_LONG_NAME.
+WEBAUTHN_RP_ID = env.str("WEBAUTHN_RP_ID", None)
+WEBAUTHN_RP_NAME = env.str("WEBAUTHN_RP_NAME", None)
+WEBAUTHN_ORIGIN = env.str("WEBAUTHN_ORIGIN", None)
+
# Set the allowed file extension for all uploads fields.
FILE_ALLOWED_EXTENSIONS = [
"doc",
diff --git a/hypha/static_src/javascript/passkeys.js b/hypha/static_src/javascript/passkeys.js
new file mode 100644
index 0000000000..be99503a2e
--- /dev/null
+++ b/hypha/static_src/javascript/passkeys.js
@@ -0,0 +1,246 @@
+/**
+ * WebAuthn passkey support using native browser APIs.
+ *
+ * Uses the stable native APIs available in all major browsers since March 2025:
+ * - PublicKeyCredential.parseCreationOptionsFromJSON()
+ * - PublicKeyCredential.parseRequestOptionsFromJSON()
+ * - PublicKeyCredential.prototype.toJSON()
+ *
+ * Availability is checked via window.PublicKeyCredential && navigator.credentials,
+ * which covers platform authenticators (Touch ID, Windows Hello, Face ID) as well
+ * as roaming authenticators (security keys) and cross-device auth (QR code).
+ */
+
+window.hypha = window.hypha || {};
+
+window.hypha.passkeys = (function () {
+ let _conditionalAbortController = null;
+ function getCsrfToken() {
+ const hxheaders = document.body.getAttribute("hx-headers") || "{}";
+ const headers = JSON.parse(hxheaders);
+ return headers["X-CSRFToken"] || "";
+ }
+
+ function getRememberMe() {
+ const el =
+ document.getElementById("id_auth-remember_me") ||
+ document.getElementById("id_remember_me");
+ return el ? el.checked : false;
+ }
+
+ function jsonPost(url, body) {
+ return fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRFToken": getCsrfToken(),
+ },
+ body: JSON.stringify(body),
+ });
+ }
+
+ /**
+ * Show passkey-related UI elements only when the platform supports them.
+ * Call this on DOMContentLoaded — elements with data-passkey-ui are hidden
+ * by default and revealed here.
+ */
+ async function initUI() {
+ const webAuthnAvailable = !!(
+ window.PublicKeyCredential && navigator.credentials
+ );
+
+ const conditionalOk = await (
+ window.PublicKeyCredential?.isConditionalMediationAvailable?.() ??
+ Promise.resolve(false)
+ ).catch(() => false);
+
+ if (webAuthnAvailable) {
+ document
+ .querySelectorAll("[data-passkey-ui]")
+ .forEach((el) => el.removeAttribute("hidden"));
+ }
+
+ if (conditionalOk) {
+ _startConditionalMediation();
+ }
+ }
+
+ /**
+ * Register a new passkey for the currently authenticated user.
+ * Called from the account page "Add passkey" form (onsubmit).
+ * @param {HTMLFormElement} formEl The