Date: Fri, 1 May 2026 21:56:15 +0200
Subject: [PATCH 35/37] Fix passkey login button so it mimic password login
button.
---
.../users/templates/users/includes/passkey_login_button.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/hypha/apply/users/templates/users/includes/passkey_login_button.html b/hypha/apply/users/templates/users/includes/passkey_login_button.html
index 1a8bccb22b..2efeada156 100644
--- a/hypha/apply/users/templates/users/includes/passkey_login_button.html
+++ b/hypha/apply/users/templates/users/includes/passkey_login_button.html
@@ -10,8 +10,8 @@
hidden
onclick="hypha.passkeys.authenticate()"
>
- {% heroicon_micro "key" class="inline size-4" aria_hidden=true %}
- {% trans "Sign in with passkey" %}
+ {% heroicon_mini "key" size=18 class="opacity-80" aria_hidden=true %}
+ {% trans "Log in with passkey" %}
Date: Tue, 19 May 2026 14:22:39 +0200
Subject: [PATCH 36/37] Only store valid transports values.
---
hypha/apply/users/passkey_views.py | 15 ++++++--
hypha/apply/users/tests/test_passkey_views.py | 36 +++++++++++++++++++
2 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
index fb6facca73..a08a387737 100644
--- a/hypha/apply/users/passkey_views.py
+++ b/hypha/apply/users/passkey_views.py
@@ -28,6 +28,7 @@
AuthenticatorAssertionResponse,
AuthenticatorAttestationResponse,
AuthenticatorSelectionCriteria,
+ AuthenticatorTransport,
PublicKeyCredentialDescriptor,
RegistrationCredential,
ResidentKeyRequirement,
@@ -72,6 +73,15 @@ def _load_challenge(request, key: str) -> bytes:
return base64.b64decode(encoded)
+_VALID_TRANSPORTS = {t.value for t in AuthenticatorTransport}
+
+
+def _clean_transports(raw) -> list[str]:
+ if not isinstance(raw, list):
+ return []
+ return [t for t in raw if isinstance(t, str) and t in _VALID_TRANSPORTS]
+
+
# ---------------------------------------------------------------------------
# Registration — requires an authenticated user
# ---------------------------------------------------------------------------
@@ -131,6 +141,7 @@ def passkey_register_complete(request):
except PermissionDenied:
return JsonResponse({"error": _("No active WebAuthn challenge")}, status=400)
+ transports = _clean_transports(data.get("response", {}).get("transports"))
try:
credential = RegistrationCredential(
id=data["id"],
@@ -140,7 +151,7 @@ def passkey_register_complete(request):
attestation_object=base64url_to_bytes(
data["response"]["attestationObject"]
),
- transports=data["response"].get("transports", []),
+ transports=transports,
),
)
verification = verify_registration_response(
@@ -168,7 +179,7 @@ def passkey_register_complete(request):
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", []),
+ transports=transports,
)
except Exception:
logger.warning(
diff --git a/hypha/apply/users/tests/test_passkey_views.py b/hypha/apply/users/tests/test_passkey_views.py
index 09901a0e11..b710c11098 100644
--- a/hypha/apply/users/tests/test_passkey_views.py
+++ b/hypha/apply/users/tests/test_passkey_views.py
@@ -206,6 +206,42 @@ def test_empty_name_gets_date_default(self, mock_verify):
passkey = self.user.passkeys.first()
self.assertTrue(passkey.name.startswith("Passkey "))
+ @patch("hypha.apply.users.passkey_views.verify_registration_response")
+ def test_unknown_transports_are_filtered_out(self, mock_verify):
+ mock_verify.return_value = MagicMock(
+ credential_id=b"cred",
+ credential_public_key=b"pubkey",
+ sign_count=0,
+ )
+ self._set_challenge()
+ payload = self._payload()
+ payload["response"]["transports"] = ["internal", "junk-value", "usb", 123]
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(self.user.passkeys.first().transports, ["internal", "usb"])
+
+ @patch("hypha.apply.users.passkey_views.verify_registration_response")
+ def test_non_list_transports_is_stored_as_empty_list(self, mock_verify):
+ mock_verify.return_value = MagicMock(
+ credential_id=b"cred",
+ credential_public_key=b"pubkey",
+ sign_count=0,
+ )
+ self._set_challenge()
+ payload = self._payload()
+ payload["response"]["transports"] = "internal" # not a list
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps(payload),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(self.user.passkeys.first().transports, [])
+
@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")
From 338b946b2522b805e7ae3ef67124f562c57f304c Mon Sep 17 00:00:00 2001
From: Fredrik Jonsson
Date: Tue, 19 May 2026 15:11:46 +0200
Subject: [PATCH 37/37] Disable passkeys in production unless WEBAUTHN_RP_ID is
set.
---
.../deployment/production/stand-alone.md | 5 ++
hypha/apply/users/passkey_views.py | 27 ++++++-
.../apply/users/templates/users/account.html | 28 ++++---
hypha/apply/users/templates/users/login.html | 18 +++--
.../users/passwordless_login_signup.html | 18 +++--
hypha/apply/users/tests/test_passkey_views.py | 80 +++++++++++++++++++
hypha/core/context_processors.py | 2 +
hypha/settings/base.py | 7 +-
hypha/settings/test.py | 3 +
9 files changed, 159 insertions(+), 29 deletions(-)
diff --git a/docs/setup/deployment/production/stand-alone.md b/docs/setup/deployment/production/stand-alone.md
index 2707cc147c..c1785a28fb 100644
--- a/docs/setup/deployment/production/stand-alone.md
+++ b/docs/setup/deployment/production/stand-alone.md
@@ -185,6 +185,11 @@ SERVER_EMAIL: app@example.org
SEND_MESSAGES: true
```
+**Passkeys:**
+
+To activate passkeys in production you must set at least `WEBAUTHN_RP_ID` to the relying party domain, e.g. "example.com" (no port, no scheme).
+
+
**Turn on Hypha features that are off by default:**
```text
diff --git a/hypha/apply/users/passkey_views.py b/hypha/apply/users/passkey_views.py
index a08a387737..e226a1ec0b 100644
--- a/hypha/apply/users/passkey_views.py
+++ b/hypha/apply/users/passkey_views.py
@@ -1,13 +1,14 @@
import base64
import json
import logging
+from functools import wraps
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.http import Http404, 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
@@ -43,6 +44,23 @@
SESSION_CHALLENGE_KEY_AUTH = "webauthn_challenge_auth"
+def passkeys_enabled() -> bool:
+ """Passkeys require WEBAUTHN_RP_ID in production. In DEBUG (local/dev)
+ we fall back to the request host so the feature can be exercised locally.
+ """
+ return bool(getattr(settings, "WEBAUTHN_RP_ID", None)) or settings.DEBUG
+
+
+def passkeys_required(view_func):
+ @wraps(view_func)
+ def _wrapped(request, *args, **kwargs):
+ if not passkeys_enabled():
+ raise Http404
+ return view_func(request, *args, **kwargs)
+
+ return _wrapped
+
+
def _get_rp_id(request):
rp_id = getattr(settings, "WEBAUTHN_RP_ID", None)
if rp_id:
@@ -90,6 +108,7 @@ def _clean_transports(raw) -> list[str]:
MAX_PASSKEYS_PER_USER = 10
+@passkeys_required
@login_required
@require_POST
@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
@@ -127,6 +146,7 @@ def passkey_register_begin(request):
return JsonResponse(json.loads(options_to_json(options)))
+@passkeys_required
@login_required
@require_POST
@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
@@ -195,6 +215,7 @@ def passkey_register_complete(request):
# ---------------------------------------------------------------------------
+@passkeys_required
@require_POST
@ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
def passkey_auth_begin(request):
@@ -206,6 +227,7 @@ def passkey_auth_begin(request):
return JsonResponse(json.loads(options_to_json(options)))
+@passkeys_required
@require_POST
@ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
def passkey_auth_complete(request):
@@ -315,6 +337,7 @@ def passkey_auth_complete(request):
# ---------------------------------------------------------------------------
+@passkeys_required
@login_required
@require_GET
def passkey_list(request):
@@ -322,6 +345,7 @@ def passkey_list(request):
return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})
+@passkeys_required
@login_required
@require_POST
def passkey_delete(request, pk):
@@ -337,6 +361,7 @@ def passkey_delete(request, pk):
return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})
+@passkeys_required
@login_required
@require_POST
def passkey_rename(request, pk):
diff --git a/hypha/apply/users/templates/users/account.html b/hypha/apply/users/templates/users/account.html
index cd7e1e18d6..2ab33536fe 100644
--- a/hypha/apply/users/templates/users/account.html
+++ b/hypha/apply/users/templates/users/account.html
@@ -93,18 +93,20 @@
{% endif %}
- {% trans "Passkeys" %}
-
- {% trans "With passkeys you can use your fingerprint, face, or screen lock to login securely without a password." %}
-
+ {% if PASSKEYS_ENABLED %}
+ {% trans "Passkeys" %}
+
+ {% trans "With passkeys you can use your fingerprint, face, or screen lock to login securely without a password." %}
+
-
+
+ {% endif %}
{# Remove the comment block tags below when such need arises. e.g. adding new providers #}
{% comment %}
@@ -123,5 +125,7 @@ {% trans "Manage OAuth" %}
{% endblock %}
{% block extra_js %}
-
+ {% if PASSKEYS_ENABLED %}
+
+ {% endif %}
{% endblock %}
diff --git a/hypha/apply/users/templates/users/login.html b/hypha/apply/users/templates/users/login.html
index 868e983ce6..50e38311cb 100644
--- a/hypha/apply/users/templates/users/login.html
+++ b/hypha/apply/users/templates/users/login.html
@@ -91,7 +91,9 @@
{% include "users/includes/passwordless_login_button.html" %}
- {% include "users/includes/passkey_login_button.html" %}
+ {% if PASSKEYS_ENABLED %}
+ {% include "users/includes/passkey_login_button.html" %}
+ {% endif %}
{% if GOOGLE_OAUTH2 %}
{% include "users/includes/org_login_button.html" %}
{% endif %}
@@ -143,12 +145,14 @@
{% block extra_js %}
{{ block.super }}
-
-
+ {% if PASSKEYS_ENABLED %}
+
+
+ {% endif %}
{# Fix copy of dynamic fields label #}
-
+ {% if PASSKEYS_ENABLED %}
+
+
+ {% endif %}
{% endblock %}
diff --git a/hypha/apply/users/tests/test_passkey_views.py b/hypha/apply/users/tests/test_passkey_views.py
index b710c11098..e54aa2bccf 100644
--- a/hypha/apply/users/tests/test_passkey_views.py
+++ b/hypha/apply/users/tests/test_passkey_views.py
@@ -661,3 +661,83 @@ def test_without_passkey_flag_unverified_user_is_blocked(self):
response = self.client.get(settings.LOGIN_REDIRECT_URL, follow=True)
self.assertContains(response, "Permission Denied")
+
+
+# ---------------------------------------------------------------------------
+# Production gate — passkeys disabled unless WEBAUTHN_RP_ID is set
+# ---------------------------------------------------------------------------
+
+
+@override_settings(DEBUG=False, WEBAUTHN_RP_ID=None, RATELIMIT_ENABLE=False)
+class TestPasskeysDisabledInProduction(TestCase):
+ """Without WEBAUTHN_RP_ID and DEBUG=False, all passkey views must 404."""
+
+ def setUp(self):
+ self.user = UserFactory()
+
+ def test_auth_begin_returns_404(self):
+ response = self.client.post(AUTH_BEGIN_URL)
+ self.assertEqual(response.status_code, 404)
+
+ def test_auth_complete_returns_404(self):
+ response = self.client.post(
+ AUTH_COMPLETE_URL,
+ data=json.dumps({}),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 404)
+
+ def test_register_begin_returns_404(self):
+ self.client.force_login(self.user)
+ response = self.client.post(REGISTER_BEGIN_URL)
+ self.assertEqual(response.status_code, 404)
+
+ def test_register_complete_returns_404(self):
+ self.client.force_login(self.user)
+ response = self.client.post(
+ REGISTER_COMPLETE_URL,
+ data=json.dumps({}),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, 404)
+
+ def test_passkey_list_returns_404(self):
+ self.client.force_login(self.user)
+ response = self.client.get(PASSKEY_LIST_URL)
+ self.assertEqual(response.status_code, 404)
+
+ def test_passkey_delete_returns_404(self):
+ passkey = make_passkey(self.user)
+ self.client.force_login(self.user)
+ response = self.client.post(reverse("users:passkey_delete", args=[passkey.pk]))
+ self.assertEqual(response.status_code, 404)
+ self.assertTrue(Passkey.objects.filter(pk=passkey.pk).exists())
+
+ def test_passkey_rename_returns_404(self):
+ passkey = make_passkey(self.user, name="Original")
+ self.client.force_login(self.user)
+ response = self.client.post(
+ reverse("users:passkey_rename", args=[passkey.pk]),
+ {"name": "New Name"},
+ )
+ self.assertEqual(response.status_code, 404)
+ passkey.refresh_from_db()
+ self.assertEqual(passkey.name, "Original")
+
+
+@override_settings(DEBUG=True, WEBAUTHN_RP_ID=None, RATELIMIT_ENABLE=False)
+class TestPasskeysEnabledInDev(TestCase):
+ """DEBUG=True falls back to the request host even without WEBAUTHN_RP_ID."""
+
+ def test_auth_begin_works_in_debug_without_rp_id(self):
+ response = self.client.post(AUTH_BEGIN_URL)
+ self.assertEqual(response.status_code, 200)
+
+
+@override_settings(DEBUG=False, WEBAUTHN_RP_ID="example.com", RATELIMIT_ENABLE=False)
+class TestPasskeysEnabledWhenRpIdSet(TestCase):
+ """DEBUG=False with WEBAUTHN_RP_ID set enables passkeys in production."""
+
+ def test_auth_begin_works_in_production_with_rp_id(self):
+ response = self.client.post(AUTH_BEGIN_URL)
+ self.assertEqual(response.status_code, 200)
diff --git a/hypha/core/context_processors.py b/hypha/core/context_processors.py
index e40ebb664c..ac836c4708 100644
--- a/hypha/core/context_processors.py
+++ b/hypha/core/context_processors.py
@@ -1,5 +1,6 @@
from django.conf import settings
+from hypha.apply.users.passkey_views import passkeys_enabled
from hypha.home.models import ApplyHomePage
@@ -15,6 +16,7 @@ def global_vars(request):
"HIDE_IDENTITY_FROM_REVIEWERS": settings.HIDE_IDENTITY_FROM_REVIEWERS,
"GOOGLE_OAUTH2": settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY,
"ENABLE_PUBLIC_SIGNUP": settings.ENABLE_PUBLIC_SIGNUP,
+ "PASSKEYS_ENABLED": passkeys_enabled(),
"SENTRY_TRACES_SAMPLE_RATE": settings.SENTRY_TRACES_SAMPLE_RATE,
"SENTRY_ENVIRONMENT": settings.SENTRY_ENVIRONMENT,
"SENTRY_DENY_URLS": settings.SENTRY_DENY_URLS,
diff --git a/hypha/settings/base.py b/hypha/settings/base.py
index d29b6a302c..150b28eadc 100644
--- a/hypha/settings/base.py
+++ b/hypha/settings/base.py
@@ -35,15 +35,18 @@
ENFORCE_TWO_FACTOR = env.bool("ENFORCE_TWO_FACTOR", False)
# WebAuthn / Passkey settings.
+# Passkeys are disabled in production unless WEBAUTHN_RP_ID is set. In local
+# development (DEBUG=True) they fall back to the request host so the feature
+# can be tried without extra configuration.
+#
# 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)
+WEBAUTHN_RP_NAME = env.str("WEBAUTHN_RP_NAME", None)
# Set the allowed file extension for all uploads fields.
FILE_ALLOWED_EXTENSIONS = [
diff --git a/hypha/settings/test.py b/hypha/settings/test.py
index 5ef5b5e01b..177686e237 100644
--- a/hypha/settings/test.py
+++ b/hypha/settings/test.py
@@ -35,6 +35,9 @@
ENFORCE_TWO_FACTOR = False
+# Enable passkeys in tests so feature views are exercisable without DEBUG.
+WEBAUTHN_RP_ID = "testserver"
+
SECURE_SSL_REDIRECT = False
# No async celery workers