Skip to content

fix(auth): treat non-standard 403 invalid_grant as reauth instead of a silent failure - #296

Open
sslivins wants to merge 1 commit into
binarydev:mainfrom
sslivins:fix/invalid-grant-403-reauth
Open

fix(auth): treat non-standard 403 invalid_grant as reauth instead of a silent failure#296
sslivins wants to merge 1 commit into
binarydev:mainfrom
sslivins:fix/invalid-grant-403-reauth

Conversation

@sslivins

Copy link
Copy Markdown

Problem

When a Generac refresh token is invalidated, every entity goes unavailable with no user-facing prompt to re-login. The log just spams Unexpected error refreshing Generac data, and the config entry stays loaded, so there's no reauth flow and no Repairs card — the integration is silently broken until you notice the stale data yourself.

Root cause: Generac returns a non-standard HTTP status

RFC 6749 §5.2 mandates that an invalid_grant error be returned with HTTP 400. Generac's Auth0/Apigee token endpoint instead returns it as HTTP 403:

token refresh failed: 403 {'error': 'invalid_grant',
  'error_description': 'Unknown or invalid refresh token.'}

The refresh handler only classified this as an auth failure on the spec-compliant status:

if status == 400 and payload.get("error") == "invalid_grant":

Because Generac sends 403, this check is skipped and execution falls through to a generic RuntimeError. The coordinator catches that as a plain ExceptionUpdateFailed (never ConfigEntryAuthFailed), so HA never triggers the reauth flow.

Fix

Don't rely on Generac honoring the OAuth status-code convention. Trust the OAuth error field over the HTTP status:

  • Any invalid_grant payload → InvalidGrantError regardless of HTTP status.
  • Any bare 401/403 from the token endpoint → InvalidGrantError (a rejected grant is an auth failure, not a transient outage), so we don't loop on UpdateFailed forever.
  • 5xx / network errors still raise RuntimeErrorUpdateFailed for transient retry.

InvalidGrantError is already mapped to ConfigEntryAuthFailed in the coordinator, which starts HA's reauth flow.

Tests

Adds regression tests: 403 invalid_grant, a bare 403 (no OAuth error body), and a transient 5xx (still RuntimeError). pytest tests/test_auth.py → 14 passed.

Validation

Deployed to a live HA instance that had an already-invalidated refresh token. After restart: entry → setup_error, a reauth flow starts (step: reauth_confirm), a Repairs card appears (config_entry_reauth_generac_…), and the log now reads Generac auth rejected, requesting reauth instead of Unexpected error refreshing Generac data.

Complementary to #286 (which fixed the entry not reloading after a successful reauth); this fixes the reauth prompt not appearing in the first place.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

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>
@sslivins
sslivins marked this pull request as ready for review July 29, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant