From e17fbe3bf7e9cf2dfa47d7ce22a29e5aaf6194a8 Mon Sep 17 00:00:00 2001 From: Jose Santiago Date: Sat, 24 Oct 2026 20:20:51 -0400 Subject: [PATCH 1/2] fix(auth): handle Auth0 password 400 without error code and strip email whitespace (#295) --- custom_components/generac/auth.py | 82 ++++++++++++++--- custom_components/generac/config_flow.py | 6 +- .../generac/translations/en.json | 2 +- tests/test_auth.py | 92 ++++++++++++++++++- 4 files changed, 159 insertions(+), 23 deletions(-) diff --git a/custom_components/generac/auth.py b/custom_components/generac/auth.py index e224780..86bde14 100644 --- a/custom_components/generac/auth.py +++ b/custom_components/generac/auth.py @@ -275,6 +275,42 @@ async def _authorize( return qs["state"][0] +_CREDENTIAL_ERROR_KEYWORDS = ( + "password", + "credential", + "user", + "email", + "lock", + "blocked", + "many-attempts", + "many_attempts", +) + + +def _extract_ulp_alert_text(html: str) -> Optional[str]: + """Extract human-readable error text from an Auth0 ULP error page. + + When Auth0 rejects a login form without a field-level `data-error-code` + attribute (e.g. a banner in `#prompt-alert`, a custom database script + error, or an account requiring a password reset after the April 2026 + security migration), pull the alert or inline error text so we can + classify and log the real failure reason instead of the leading blank + lines + `` of the page. + """ + for pattern in ( + r'id="prompt-alert"[^>]*>(.*?)', + r'class="[^"]*ulp-input-error-message[^"]*"[^>]*>(.*?)', + r'class="[^"]*ulp-validator-error[^"]*"[^>]*>(.*?)', + ): + m = re.search(pattern, html, re.DOTALL) + if m: + cleaned = re.sub(r"<[^>]+>", " ", m.group(1)) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + if cleaned: + return cleaned + return None + + async def _post_login_form( session: aiohttp.ClientSession, url: str, state: str, form: dict ) -> str: @@ -297,28 +333,42 @@ async def _post_login_form( text = await resp.text() # Auth0 ULP renders field-level errors as # class="ulp-input-error-message" data-error-code="" - # Surface the first code so the user sees a meaningful reason - # instead of a bare HTTP 400. + # and top-level banners as + # id="prompt-alert" ... data-error-code="" + # Surface the first code (or alert text) so the user sees a + # meaningful reason instead of a bare HTTP 400. m = re.search(r'data-error-code="([^"]+)"', text) code = m.group(1) if m else None + alert_text = _extract_ulp_alert_text(text) _LOGGER.warning( - "POST %s -> %s; auth0 error code=%s", url, resp.status, code + "POST %s -> %s; auth0 error code=%s alert=%r", + url, + resp.status, + code, + alert_text, ) if code: # Auth0 ULP renders field-level errors (wrong password, - # locked account, etc) with a data-error-code. Surface - # those as InvalidCredentialsError so the config flow - # maps them to "auth" instead of "internal". - if any( - s in code.lower() - for s in ("password", "credential", "user", "lock", "blocked") - ): + # invalid email format, locked account, etc.) with a + # data-error-code. Surface those as InvalidCredentialsError + # so the config flow maps them to "auth" instead of + # "auth0_step_login_form" / "internal". + if any(s in code.lower() for s in _CREDENTIAL_ERROR_KEYWORDS): raise InvalidCredentialsError(f"login rejected ({code})") raise RuntimeError( f"step=login_form url={url} status={resp.status} auth0_code={code}" ) + # An HTTP 400 from the password step (or a 400 with a rendered + # ULP alert banner) is an Auth0 login rejection even when the + # response HTML omits `data-error-code` (issue #295 — e.g. + # accounts that haven't completed the post-April-2026 password + # reset / EULA flow or custom DB script rejections). + if resp.status == 400 and (url == PASSWORD_URL or alert_text is not None): + detail = alert_text or "HTTP 400 at password step (no error code)" + raise InvalidCredentialsError(f"login rejected ({detail})") + snippet = alert_text or re.sub(r"\s+", " ", text).strip()[:200] raise RuntimeError( - f"step=login_form url={url} status={resp.status} no_code body={text[:200]!r}" + f"step=login_form url={url} status={resp.status} no_code body={snippet!r}" ) return resp.headers["Location"] @@ -368,7 +418,7 @@ async def _identifier_step( ) -> str: form = { "state": state, - "username": email, + "username": email.strip(), "js-available": "true", "webauthn-available": "true", "is-brave": "false", @@ -390,7 +440,7 @@ async def _password_step( ) -> str: form = { "state": state, - "username": email, + "username": email.strip(), "password": password, "action": "default", } @@ -624,7 +674,11 @@ def __init__( # Long-lived, HA-managed session — NOT owned here. Only used to # construct the returned GeneracAuth (which uses it for refresh). self._api_session = api_session - self._email = email + # Strip accidental surrounding whitespace from the email (e.g. + # copy-paste artifacts) so the identifier step doesn't fail with + # `invalid-email-format` and both steps send the exact same + # normalized identifier. + self._email = email.strip() self._password = password self._key = DPoPKey.generate() self._verifier, self._challenge = _make_pkce() diff --git a/custom_components/generac/config_flow.py b/custom_components/generac/config_flow.py index fb7fd3a..eda495e 100644 --- a/custom_components/generac/config_flow.py +++ b/custom_components/generac/config_flow.py @@ -157,7 +157,7 @@ async def async_step_user(self, user_input=None): errors: dict[str, str] = {} if user_input is not None: - email = user_input[CONF_USERNAME] + email = user_input[CONF_USERNAME].strip() password = user_input[CONF_PASSWORD] try: @@ -196,7 +196,7 @@ async def async_step_reconfigure(self, user_input=None): entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) if user_input is not None: - email = user_input[CONF_USERNAME] + email = user_input[CONF_USERNAME].strip() password = user_input[CONF_PASSWORD] scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) @@ -281,7 +281,7 @@ async def async_step_reauth_confirm(self, user_input=None): # Older config entries may not have stored email under CONF_USERNAME, # so fall back to the entry title (which we set to the email at # create time). - default_email = entry.data.get(CONF_USERNAME) or entry.title or "" + default_email = (entry.data.get(CONF_USERNAME) or entry.title or "").strip() if user_input is not None: password = user_input[CONF_PASSWORD] diff --git a/custom_components/generac/translations/en.json b/custom_components/generac/translations/en.json index e0bd1d4..81ccdc0 100644 --- a/custom_components/generac/translations/en.json +++ b/custom_components/generac/translations/en.json @@ -34,7 +34,7 @@ } }, "error": { - "auth": "Invalid email or password.", + "auth": "Invalid email or password. If you haven't reset your password since Generac's April 2026 security update, sign in at app.mobilelinkgen.com first to reset your password and accept any updated terms.", "mfa_invalid": "That code was incorrect or has expired. Enter the most recent code, or cancel and start over to get a new one.", "mfa_unsupported": "Your account uses a two-factor method this integration can't complete automatically (such as a push notification or security key). Approve the login in the MobileLink app, or switch your Generac/ecobee account to SMS, email, or authenticator-app codes, then try again.", "auth0_unreachable": "Could not reach auth.ecobee.com. Check the HA host's outbound network/DNS.", diff --git a/tests/test_auth.py b/tests/test_auth.py index c3eecae..7aeec3a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,11 +6,15 @@ from unittest.mock import MagicMock import pytest +from custom_components.generac.auth import _identifier_step +from custom_components.generac.auth import _password_step from custom_components.generac.auth import _post_login_form from custom_components.generac.auth import DPoPKey from custom_components.generac.auth import GeneracAuth +from custom_components.generac.auth import IDENTIFIER_URL from custom_components.generac.auth import InvalidCredentialsError from custom_components.generac.auth import InvalidGrantError +from custom_components.generac.auth import PASSWORD_URL def _acm(resp): @@ -206,9 +210,11 @@ def _ulp_error_resp(status: int, code: str | None): [ "invalid-password", "invalid_credentials", + "invalid-email-format", "user-not-found", "account-locked", "user-blocked", + "too-many-attempts", ], ) @pytest.mark.asyncio @@ -220,7 +226,7 @@ async def test_post_login_form_credential_codes_raise_invalid_credentials(code): with pytest.raises(InvalidCredentialsError): await _post_login_form( session, - "https://auth.ecobee.com/u/login/password", + PASSWORD_URL, "state-xyz", {"password": "x"}, ) @@ -235,7 +241,7 @@ async def test_post_login_form_unknown_code_raises_runtime_error(): with pytest.raises(RuntimeError) as exc_info: await _post_login_form( session, - "https://auth.ecobee.com/u/login/password", + PASSWORD_URL, "state-xyz", {"password": "x"}, ) @@ -244,15 +250,91 @@ async def test_post_login_form_unknown_code_raises_runtime_error(): @pytest.mark.asyncio async def test_post_login_form_no_code_raises_runtime_error(): - """A non-redirect with no parseable error code becomes a plain RuntimeError.""" + """A non-redirect with no parseable error code on non-password step becomes a plain RuntimeError.""" session = MagicMock() - session.post = MagicMock(return_value=_acm(_ulp_error_resp(400, None))) + session.post = MagicMock(return_value=_acm(_ulp_error_resp(500, None))) with pytest.raises(RuntimeError) as exc_info: await _post_login_form( session, - "https://auth.ecobee.com/u/login/password", + PASSWORD_URL, "state-xyz", {"password": "x"}, ) assert not isinstance(exc_info.value, InvalidCredentialsError) + + +@pytest.mark.asyncio +async def test_post_login_form_password_400_no_code_raises_invalid_credentials(): + """Regression (issue #295): HTTP 400 on /u/login/password with no data-error-code + (e.g. React ULP page returned for an account requiring a password reset) must + raise InvalidCredentialsError instead of an uncaught RuntimeError.""" + session = MagicMock() + resp = MagicMock() + resp.status = 400 + resp.text = AsyncMock( + return_value='\n\n\n' + ) + resp.headers = {} + session.post = MagicMock(return_value=_acm(resp)) + + with pytest.raises(InvalidCredentialsError): + await _post_login_form( + session, + PASSWORD_URL, + "state-xyz", + {"password": "x"}, + ) + + +@pytest.mark.asyncio +async def test_post_login_form_prompt_alert_without_code_raises_invalid_credentials(): + """An HTTP 400 with a #prompt-alert banner but no data-error-code surfaces the alert text.""" + session = MagicMock() + resp = MagicMock() + resp.status = 400 + resp.text = AsyncMock( + return_value='' + ) + resp.headers = {} + session.post = MagicMock(return_value=_acm(resp)) + + with pytest.raises(InvalidCredentialsError) as exc_info: + await _post_login_form( + session, + IDENTIFIER_URL, + "state-xyz", + {"username": "user@example.com"}, + ) + assert "Password reset required" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_identifier_and_password_steps_strip_email_whitespace(): + """Leading/trailing whitespace in email must be stripped before posting to Auth0.""" + session = MagicMock() + + id_resp = MagicMock() + id_resp.status = 302 + id_resp.headers = {"Location": "/u/login/password?state=pw-state"} + + pw_resp = MagicMock() + pw_resp.status = 302 + pw_resp.headers = {"Location": "/authorize/resume?state=resume-state"} + + session.post = MagicMock(side_effect=[_acm(id_resp), _acm(pw_resp)]) + + state1 = await _identifier_step(session, "init-state", " user@example.com \n") + assert state1 == "pw-state" + id_body = session.post.call_args_list[0].kwargs["data"] + assert "username=user%40example.com" in id_body + assert "+" not in id_body and "%20" not in id_body + + state2 = await _password_step( + session, "pw-state", " user@example.com \n", " pass " + ) + assert state2 == "resume-state" + pw_body = session.post.call_args_list[1].kwargs["data"] + assert "username=user%40example.com" in pw_body + # Password whitespace is preserved verbatim + assert "password=+pass+" in pw_body From c3dd18ef6f5900485bdcfec8a5ca5adb2ea99bf5 Mon Sep 17 00:00:00 2001 From: Jose Santiago Date: Sat, 24 Oct 2026 20:27:42 -0400 Subject: [PATCH 2/2] Remove requirement that homeassistant forbids from listing in custom components since it's a core requirement for HA itself --- custom_components/generac/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/generac/manifest.json b/custom_components/generac/manifest.json index 632495d..5275415 100644 --- a/custom_components/generac/manifest.json +++ b/custom_components/generac/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/binarydev/ha-generac", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/binarydev/ha-generac/issues", - "requirements": ["dacite==1.9.2", "cryptography>=41"], + "requirements": ["dacite==1.9.2"], "version": "0.5.4" }