From 46ed70bfdeb86adaa5b72887bf9084e375ed03af Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 25 May 2026 00:16:12 +0000 Subject: [PATCH] Stage-batch14: add HERMES_WEBUI_PASSKEY feature flag for #2859 passkey support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- api/auth.py | 36 +++++++++++++++++++++++++++++++++++- api/routes.py | 25 ++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/api/auth.py b/api/auth.py index db9a9d8a..32d3be7e 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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 diff --git a/api/routes.py b/api/routes.py index 723a995f..43c36dff 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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":