From c53226dcc4944ee2103b023765fe696fd80633df Mon Sep 17 00:00:00 2001 From: happy5318 Date: Tue, 22 Sep 2026 16:37:02 +0800 Subject: [PATCH] fix(login): hide the password form when ``is_password_auth_enabled()`` is False (#7056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path With native OIDC configured and ``HERMES_WEBUI_PASSWORD`` unset, the ``/api/auth/status`` endpoint correctly reports ``password_auth_enabled: false``, but ``/login`` still rendered the password input, the "Sign in" button, and the "Continue with SSO" entry point — and silently 401-ed every submit. The reporter's wire evidence: ```json GET /api/auth/status -> { "auth_enabled": true, "oidc_enabled": true, "password_auth_enabled": false } GET /login -> still shows: "Enter your password to continue" + password + "Sign in" button + "Continue with SSO" link POST /api/auth/login -> 401 {"error":"Invalid password"} ``` The fix routes the password input / submit / passkey controls through a single ``{{PASSWORD_FORM_HTML}}`` placeholder in the template, which the handler populates with the live markup only when ``is_password_auth_enabled()`` is True. The OIDC SSO entry point stays the sole path when password auth is disabled. ## What Changed 1. ``api/routes.py`` template literal (``_LOGIN_PAGE_HTML``): the three inline elements (password input, submit button, passkey button) are replaced by a single ``{{PASSWORD_FORM_HTML}}`` placeholder. The ``{{OIDC_LOGIN_HTML}}`` placeholder is kept untouched. 2. ``api/routes.py`` login branch: the handler imports ``is_password_auth_enabled`` from ``api.auth``, computes the password form markup when the predicate returns True, and an empty string otherwise. The ``.replace("{{PASSWORD_FORM_HTML}}", _password_form_html)`` call slots the result into the template. The ``{{LOGIN_PLACEHOLDER}}``, ``{{LOGIN_BTN}}``, ``{{LOGIN_INVALID_PW}}``, ``{{LOGIN_CONN_FAILED}}`` placeholders are no longer referenced from the template (the password input is built with its own ``placeholder`` attribute), so the four corresponding ``.replace(...)`` calls are removed. ## Why It Matters A user with OIDC-only auth clicking "Sign in" on the password form gets a 401 with no UI signal — the OIDC entry point is right there, but the password prompt was so prominent it read like the primary path. Hiding the form when password auth is off makes OIDC the only visible option, so the user is funneled to the working path. ## Verification - ``tests/test_issue7056_login_hides_password_form.py`` (new, 6 tests): - **Source-shape (2 tests):** the template uses the ``{{PASSWORD_FORM_HTML}}`` placeholder AND does not carry an inline ````; a silent reversion to the pre-fix multi-line shape fails the suite. The login branch imports ``is_password_auth_enabled`` and populates the placeholder. - **Behavioural (3 tests):** the rendered ``/login`` HTML contains the password input / submit / passkey controls when ``is_password_auth_enabled()`` is True; does NOT contain them when False; and keeps the ```` SSO entry point when OIDC is enabled (so the user can still log in). - **Sanity (1 test):** with both password and OIDC disabled, the page renders neither control. - 51 tests pass across the related auth/login files (``test_issue7056_login_hides_password_form``, ``test_auth_settings_safety``, ``test_issue3825_oidc_auth``) — no pre-existing test is broken. ## Contract Change None. The ``/login`` HTML body changes shape only when ``is_password_auth_enabled()`` is False; the password input + submit + passkey controls are gone, but the OIDC SSO entry point stays present. The ``/api/auth/status`` contract is unchanged — it already reported the right ``password_auth_enabled`` flag; the fix is purely about the page matching the status. ## Risks - A consumer that scraped the ``/login`` HTML for the password input (e.g. a browser extension) will see the input disappear when ``HERMES_WEBUI_PASSWORD`` is unset. The OIDC entry point remains the only path, which is the intended behaviour when password auth is disabled. - The handler now imports ``is_password_auth_enabled`` at request time (same module as the other auth predicates already imported); if the auth module is unavailable the import fails fast and the ``/login`` route surfaces the underlying exception, matching the behaviour of the other auth imports in the same branch. ## Related - #7056 — original issue (segator, 2026-08-15) with the wire evidence and the expected-vs-actual contract. - The OIDC SSO entry point lives in ``_oidc_login_html`` (line 11160) and is unaffected by this PR — the fix only gates the password controls, not the SSO button. ## Model Used Hermes Agent (step-5-preview) with pytest, ruff. Tests use ``_FakeHandler`` with an ``io.BytesIO`` ``wfile`` (matching ``_safe_write``'s ``handler.wfile.write(body)`` expectation) and mock the OIDC predicate through ``_stub_oidc`` so the behavioural assertions don't depend on real OIDC env config. --- api/routes.py | 31 ++- ...est_issue7056_login_hides_password_form.py | 223 ++++++++++++++++++ 2 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 tests/test_issue7056_login_hides_password_form.py diff --git a/api/routes.py b/api/routes.py index 6afd81ad..1645084a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -11088,9 +11088,7 @@ button:hover{background:rgba(124,185,255,.25)}

{{BOT_NAME}}

{{LOGIN_SUBTITLE}}

- - - + {{PASSWORD_FORM_HTML}} {{OIDC_LOGIN_HTML}}
@@ -13633,6 +13631,24 @@ def handle_get(handler, parsed) -> bool: ] from urllib.parse import quote from api.updates import WEBUI_VERSION + # #7056: only render the password input / submit / passkey controls + # when password auth is actually enabled. With native OIDC configured + # and ``HERMES_WEBUI_PASSWORD`` unset, the form previously still + # displayed the password prompt and accepted — silently 401-ing at + # the server — every submit. The OIDC SSO entry point stays the + # sole path. ``is_password_auth_enabled`` is the same predicate + # ``/api/auth/status`` reports as ``password_auth_enabled``. + from api.auth import is_password_auth_enabled + if is_password_auth_enabled(): + _password_form_html = ( + f'' + f'' + '' + ) + else: + _password_form_html = "" version_token = quote(WEBUI_VERSION, safe="") _page = ( _LOGIN_PAGE_HTML.replace("{{BOT_NAME}}", _bn) @@ -13641,14 +13657,7 @@ def handle_get(handler, parsed) -> bool: .replace("{{LANG}}", _html.escape(_login_strings["lang"])) .replace("{{LOGIN_TITLE}}", _html.escape(_login_strings["title"])) .replace("{{LOGIN_SUBTITLE}}", _html.escape(_login_strings["subtitle"])) - .replace( - "{{LOGIN_PLACEHOLDER}}", _html.escape(_login_strings["placeholder"]) - ) - .replace("{{LOGIN_BTN}}", _html.escape(_login_strings["btn"])) - .replace("{{LOGIN_INVALID_PW}}", _html.escape(_login_strings["invalid_pw"])) - .replace( - "{{LOGIN_CONN_FAILED}}", _html.escape(_login_strings["conn_failed"]) - ) + .replace("{{PASSWORD_FORM_HTML}}", _password_form_html) .replace("{{OIDC_LOGIN_HTML}}", _oidc_login_html(parsed)) ) return t(handler, _page, content_type="text/html; charset=utf-8") diff --git a/tests/test_issue7056_login_hides_password_form.py b/tests/test_issue7056_login_hides_password_form.py new file mode 100644 index 00000000..dd4b4586 --- /dev/null +++ b/tests/test_issue7056_login_hides_password_form.py @@ -0,0 +1,223 @@ +"""Regression tests for issue #7056 — login page must hide the password form +when password auth is disabled (e.g. native OIDC configured and +``HERMES_WEBUI_PASSWORD`` unset). + +The original bug: ``/api/auth/status`` correctly reports +``password_auth_enabled: false`` when password auth is disabled, but +``/login`` still rendered the password input, the "Sign in" button, +and the "Continue with SSO" entry point — and silently 401-ed every +submit. The fix routes the password input / submit / passkey controls +through a single ``{{PASSWORD_FORM_HTML}}`` placeholder in the +template, which the handler populates with the live markup only when +``is_password_auth_enabled()`` is True. + +The OIDC SSO button stays the sole path when password auth is disabled. +""" +from __future__ import annotations + +import io +from unittest import mock + +import pytest + +import api.routes as routes +from api.routes import _LOGIN_PAGE_HTML + + +# --------------------------------------------------------------------------- +# Source-shape: the template + handler must agree on a single placeholder +# for the password controls, and the handler must call the live predicate +# --------------------------------------------------------------------------- + + +def test_login_template_uses_password_form_placeholder() -> None: + """The login template MUST carry a single ``{{PASSWORD_FORM_HTML}}`` placeholder. + + A silent reversion to the previous multi-line ```` + + ``