From f80555e757d56be201d9c6aabde6fb27ff7669ca Mon Sep 17 00:00:00 2001 From: Stefan Slivinski Date: Wed, 29 Jul 2026 10:55:57 -0700 Subject: [PATCH] fix(auth): treat 403 invalid_grant as reauth, not a silent failure The token-refresh handler only classified an invalidated refresh token as an auth failure when the IdP returned HTTP 400 (per RFC 6749 5.2). Generac's Auth0/Apigee IdP returns 403 for a dead refresh token, so the check fell through to a generic RuntimeError. The coordinator caught that as a plain Exception -> UpdateFailed, freezing every entity as "unavailable" and spamming "Unexpected error refreshing Generac data" with no user-facing reauth prompt. Trust the OAuth `error` field over the HTTP status: any invalid_grant payload now raises InvalidGrantError regardless of status. Additionally treat any bare 401/403 from the token endpoint as an auth failure so a rejected grant surfaces reauth instead of looping on UpdateFailed, while 5xx/network errors still fall through to RuntimeError for transient retry. Adds regression tests for the 403 invalid_grant case, a bare 403, and a transient 5xx (still RuntimeError). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- custom_components/generac/auth.py | 18 ++++++++++- tests/test_auth.py | 51 +++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/custom_components/generac/auth.py b/custom_components/generac/auth.py index e67f04a..e224780 100644 --- a/custom_components/generac/auth.py +++ b/custom_components/generac/auth.py @@ -1003,7 +1003,23 @@ async def _post(nonce: str | None) -> tuple[int, dict, str | None]: ) return - if status == 400 and payload.get("error") == "invalid_grant": + # OAuth2 (RFC 6749 §5.2) mandates HTTP 400 for invalid_grant, but + # Generac's IdP returns it as 403. Trust the OAuth `error` field over + # the HTTP status so an invalidated refresh token always triggers HA's + # reauth flow (InvalidGrantError -> ConfigEntryAuthFailed) instead of + # falling through to a generic RuntimeError that silently freezes every + # entity as "unavailable" with no user-facing prompt to re-login. + if payload.get("error") == "invalid_grant": raise InvalidGrantError(payload.get("error_description", "invalid_grant")) + # Any other outright rejection from the token endpoint (401/403) means + # our grant/credentials are no longer accepted — an auth failure, not a + # transient outage. Surface it as reauth rather than looping on + # UpdateFailed forever. Transient 5xx / network errors fall through to + # RuntimeError below so the coordinator retries instead of prompting. + if status in (401, 403): + raise InvalidGrantError( + f"token endpoint rejected credentials ({status}): {payload}" + ) + raise RuntimeError(f"token refresh failed: {status} {payload}") diff --git a/tests/test_auth.py b/tests/test_auth.py index e72336c..c3eecae 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -135,9 +135,54 @@ async def test_refresh_invalid_grant_raises(): await auth._refresh() -# --------------------------------------------------------------------------- -# P1: Auth0 ULP form-parser error code mapping -# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_refresh_invalid_grant_403_raises(): + """Generac returns invalid_grant with HTTP 403, not the RFC-standard 400. + + Regression: the old `status == 400` guard let this fall through to a + generic RuntimeError, so the coordinator hit its catch-all ("Unexpected + error refreshing Generac data") instead of ConfigEntryAuthFailed — every + entity went unavailable with no reauth prompt. + """ + auth = _make_auth(refresh_token="rt-REVOKED") + auth._session.post = MagicMock( + return_value=_acm( + _token_resp( + 403, + { + "error": "invalid_grant", + "error_description": "Unknown or invalid refresh token.", + }, + ) + ) + ) + + with pytest.raises(InvalidGrantError): + await auth._refresh() + + +@pytest.mark.asyncio +async def test_refresh_bare_403_raises_invalid_grant(): + """A 401/403 with no OAuth error body is still an auth failure -> reauth.""" + auth = _make_auth(refresh_token="rt-REVOKED") + auth._session.post = MagicMock( + return_value=_acm(_token_resp(403, {"raw": "Forbidden"})) + ) + + with pytest.raises(InvalidGrantError): + await auth._refresh() + + +@pytest.mark.asyncio +async def test_refresh_transient_5xx_raises_runtime_error(): + """Transient 5xx stays a RuntimeError (-> UpdateFailed -> retry), not reauth.""" + auth = _make_auth(refresh_token="rt-OLD") + auth._session.post = MagicMock( + return_value=_acm(_token_resp(500, {"error": "server_error"})) + ) + + with pytest.raises(RuntimeError): + await auth._refresh() def _ulp_error_resp(status: int, code: str | None):