Skip to content

Distinguish rejected credentials from a generic login failure - #1300

Open
txtmode wants to merge 1 commit into
fronzbot:devfrom
txtmode:fix/oauth-signin-401-invalid-credentials
Open

Distinguish rejected credentials from a generic login failure#1300
txtmode wants to merge 1 commit into
fronzbot:devfrom
txtmode:fix/oauth-signin-401-invalid-credentials

Conversation

@txtmode

@txtmode txtmode commented Sep 5, 2026

Copy link
Copy Markdown

Description

When the account password no longer matches, Blink's OAuth signin step answers 401 with a body that says exactly that:

{"error":"unauthorized",
 "error_cause":"invalid_user_credentials",
 "error_description":"Invalid user credentials."}

oauth_signin read the response body only inside its 202 branch, so on a 401 it logged OAuth signin failed: status=401 body= and threw the explanation away. _oauth_login_flow then returned False, the same as for any other failure, so a caller cannot tell a rejected password from a transient server error.

Downstream that turns a five second fix into a long diagnosis. In Home Assistant, Blink.start() returns False, available stays False, and the coordinator raises ConfigEntryNotReady, so the integration retries every 10 minutes forever and never shows the re-authenticate prompt. I found this on an instance that had made roughly 1,800 failed logins over 12 days while the log only ever said "Login failed".

Changes

  • oauth_signin reads the body once for any status it has not already handled, so the failure log carries Blink's own explanation.
  • A 401 returns a new "INVALID_CREDENTIALS" result.
  • _oauth_login_flow raises UnauthorizedError for that result, the way it already raises BlinkTwoFARequiredError for 2FA. Both mean the app must involve the user rather than retry.

UnauthorizedError already propagates through Blink.start() untouched, so consumers that handle it get a working re-auth path with no further change. The 412, 202 and redirect paths behave exactly as before.

Testing

Five tests added to tests/test_oauth.py covering the 401 JSON body, a 401 non-JSON body, the body reaching the log on an unexpected status, the new raise, and the unchanged False for other failures. Full suite passes, 254 tests, ruff and black clean.

🤖 Generated with Claude Code

A wrong password makes the OAuth signin step return 401 with a JSON body
explaining why:

    {"error":"unauthorized",
     "error_cause":"invalid_user_credentials",
     "error_description":"Invalid user credentials."}

oauth_signin read the body only inside its 202 branch, so a 401 logged
"OAuth signin failed: status=401 body=" with the explanation discarded,
and _oauth_login_flow returned False like any other failure. Callers
cannot tell a rejected password from a transient server error, so they
retry forever instead of asking for new credentials. In Home Assistant
this shows up as Blink retrying every 10 minutes with no re-authenticate
prompt, because start() returns False and the coordinator raises
ConfigEntryNotReady rather than ConfigEntryAuthFailed.

Read the body once for every status that is not already handled, use it
for the log, and return a new "INVALID_CREDENTIALS" result on 401.
_oauth_login_flow now raises UnauthorizedError for that case, the same
way it raises BlinkTwoFARequiredError when 2FA is needed. Both mean the
app has to involve the user, and UnauthorizedError already passes
through Blink.start() untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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