Skip to content
Open
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
18 changes: 17 additions & 1 deletion custom_components/generac/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
51 changes: 48 additions & 3 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading