mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
Stage-batch14: add HERMES_WEBUI_PASSKEY feature flag for #2859 passkey support
Per the stage-batch14 ship plan, passkey/WebAuthn support is shipped opt-in default-off behind an explicit feature flag so deployments can disable the entire surface (UI + endpoints + credential storage) without needing to delete code. Enable via either: - HERMES_WEBUI_PASSKEY=1 environment variable, OR - webui_passkey_enabled: true in config.yaml With the flag off: - are_passkeys_enabled() returns False even if credentials exist - is_auth_enabled() falls back to password-only checking - /login renders password-only (no passkey button) - All 6 /api/auth/passkey/* endpoints return 404 with a clear message - Settings → System → Passkeys section is hidden Mirrors the #2527 notes-drawer flag shape (env-or-config, truthy parse). Auth is high-stakes; opt-in lets us land the code while keeping default deployments on the well-tested password-only path. Touches: api/auth.py (new _passkey_feature_flag_enabled helper, gated are_passkeys_enabled), api/routes.py (6 endpoint guards).
This commit is contained in:
+35
-1
@@ -296,8 +296,42 @@ def is_password_auth_enabled() -> bool:
|
||||
return get_password_hash() is not None
|
||||
|
||||
|
||||
def _passkey_feature_flag_enabled() -> bool:
|
||||
"""Return True if the passkey/WebAuthn surface is enabled for this deployment.
|
||||
|
||||
Passkey support is opt-in default-off behind a feature flag so deployments
|
||||
that don't want the WebAuthn surface (or whose RP-ID setup isn't ready for
|
||||
non-localhost hosts) can disable it entirely with no UI surface, no
|
||||
endpoints, no credential storage. To enable:
|
||||
|
||||
- Set ``HERMES_WEBUI_PASSKEY=1`` in the environment, OR
|
||||
- Set ``webui_passkey_enabled: true`` in the per-profile config.yaml
|
||||
|
||||
With the flag off, ``are_passkeys_enabled()`` always returns False even if
|
||||
credentials were registered in the past, and ``/login`` shows password-only.
|
||||
"""
|
||||
env_value = os.getenv("HERMES_WEBUI_PASSKEY", "")
|
||||
if env_value:
|
||||
return env_value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
try:
|
||||
from api.config import get_config
|
||||
|
||||
cfg = get_config()
|
||||
if isinstance(cfg, dict):
|
||||
raw = cfg.get("webui_passkey_enabled")
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def are_passkeys_enabled() -> bool:
|
||||
"""True if at least one local passkey credential is registered."""
|
||||
"""True if the passkey feature flag is on AND at least one local passkey credential is registered."""
|
||||
if not _passkey_feature_flag_enabled():
|
||||
return False
|
||||
try:
|
||||
from api.passkeys import passkeys_available
|
||||
|
||||
|
||||
+22
-3
@@ -6440,9 +6440,11 @@ def handle_post(handler, parsed) -> bool:
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/auth/passkey/options":
|
||||
from api.auth import is_auth_enabled
|
||||
from api.auth import _passkey_feature_flag_enabled, is_auth_enabled
|
||||
from api.passkeys import PasskeyError, authentication_options
|
||||
|
||||
if not _passkey_feature_flag_enabled():
|
||||
return j(handler, {"error": "Passkey support is disabled. Set HERMES_WEBUI_PASSKEY=1 or webui_passkey_enabled: true to enable."}, status=404)
|
||||
if not is_auth_enabled():
|
||||
return j(handler, {"error": "Auth not enabled"}, status=400)
|
||||
try:
|
||||
@@ -6451,10 +6453,12 @@ def handle_post(handler, parsed) -> bool:
|
||||
return bad(handler, str(e), status=400)
|
||||
|
||||
if parsed.path == "/api/auth/passkey/login":
|
||||
from api.auth import create_session, is_auth_enabled, set_auth_cookie
|
||||
from api.auth import _passkey_feature_flag_enabled, create_session, is_auth_enabled, set_auth_cookie
|
||||
from api.auth import _check_login_rate, _record_login_attempt
|
||||
from api.passkeys import PasskeyError, finish_login
|
||||
|
||||
if not _passkey_feature_flag_enabled():
|
||||
return j(handler, {"error": "Passkey support is disabled."}, status=404)
|
||||
if not is_auth_enabled():
|
||||
return j(handler, {"error": "Auth not enabled"}, status=400)
|
||||
client_ip = handler.client_address[0]
|
||||
@@ -6476,11 +6480,19 @@ def handle_post(handler, parsed) -> bool:
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/auth/passkey/register/options":
|
||||
from api.auth import _passkey_feature_flag_enabled
|
||||
from api.passkeys import registration_options
|
||||
|
||||
if not _passkey_feature_flag_enabled():
|
||||
return j(handler, {"error": "Passkey support is disabled."}, status=404)
|
||||
return j(handler, {"ok": True, "publicKey": registration_options(handler)})
|
||||
|
||||
if parsed.path == "/api/auth/passkey/register":
|
||||
from api.auth import _passkey_feature_flag_enabled
|
||||
from api.passkeys import PasskeyError, finish_registration, registered_credentials
|
||||
|
||||
if not _passkey_feature_flag_enabled():
|
||||
return j(handler, {"error": "Passkey support is disabled."}, status=404)
|
||||
try:
|
||||
result = finish_registration(body, handler)
|
||||
result["credentials"] = registered_credentials()
|
||||
@@ -6489,8 +6501,11 @@ def handle_post(handler, parsed) -> bool:
|
||||
return bad(handler, str(e), status=400)
|
||||
|
||||
if parsed.path == "/api/auth/passkey/delete":
|
||||
from api.auth import get_password_hash
|
||||
from api.auth import _passkey_feature_flag_enabled, get_password_hash
|
||||
from api.passkeys import PasskeyError, delete_credential, registered_credentials
|
||||
|
||||
if not _passkey_feature_flag_enabled():
|
||||
return j(handler, {"error": "Passkey support is disabled."}, status=404)
|
||||
try:
|
||||
credential_id = str(body.get("id") or "")
|
||||
creds = registered_credentials()
|
||||
@@ -6501,7 +6516,11 @@ def handle_post(handler, parsed) -> bool:
|
||||
return bad(handler, str(e), status=404)
|
||||
|
||||
if parsed.path == "/api/auth/passkeys":
|
||||
from api.auth import _passkey_feature_flag_enabled
|
||||
from api.passkeys import registered_credentials
|
||||
|
||||
if not _passkey_feature_flag_enabled():
|
||||
return j(handler, {"credentials": [], "disabled": True})
|
||||
return j(handler, {"credentials": registered_credentials()})
|
||||
|
||||
if parsed.path == "/api/auth/logout":
|
||||
|
||||
Reference in New Issue
Block a user