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):