Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 68 additions & 14 deletions custom_components/generac/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 + `<!DOCTYPE html>` of the page.
"""
for pattern in (
r'id="prompt-alert"[^>]*>(.*?)</div>',
r'class="[^"]*ulp-input-error-message[^"]*"[^>]*>(.*?)</span>',
r'class="[^"]*ulp-validator-error[^"]*"[^>]*>(.*?)</div>',
):
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:
Expand All @@ -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="<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="<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"]

Expand Down Expand Up @@ -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",
Expand All @@ -390,7 +440,7 @@ async def _password_step(
) -> str:
form = {
"state": state,
"username": email,
"username": email.strip(),
"password": password,
"action": "default",
}
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions custom_components/generac/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion custom_components/generac/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
2 changes: 1 addition & 1 deletion custom_components/generac/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
92 changes: 87 additions & 5 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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"},
)
Expand All @@ -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"},
)
Expand All @@ -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<!DOCTYPE html><html lang=\'en\'><head><meta name="ulp-version" content="1.125.0"></head><body></body></html>'
)
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='<html><body><div id="prompt-alert" role="alert"><p>Password reset required</p></div></body></html>'
)
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
Loading