Skip to content

Fix concurrent token refresh marking healthy accounts for re-authentication - #1

Open
rayan-mazouz wants to merge 3 commits into
devasheeshG:mainfrom
rayan-mazouz:fix/token-refresh-race
Open

rayan-mazouz wants to merge 3 commits into
devasheeshG:mainfrom
rayan-mazouz:fix/token-refresh-race

Conversation

@rayan-mazouz

Copy link
Copy Markdown

Note: this PR was vibe coded.

Summary

Concurrent token refreshes could mark healthy accounts as needing re-authentication.

ensure_fresh_token locks the account row with SELECT ... FOR UPDATE and re-checks the expiry before refreshing. But SQLAlchemy returns the identity-mapped object already in the session, without reloading its attributes. A worker that loaded the account before another worker rotated its tokens therefore still saw the old expiry and the old refresh token. It spent a refresh token that had already been rotated, the provider rejected it, and the account was flagged REAUTH_REQUIRED with an "authentication expired" notification. Two concurrent requests near token expiry are enough to trigger it, as is a request racing the quota refresher.

Commits

  • refactor(rotation): extract stored access token decryption. No behaviour change.
  • fix(rotation): re-read the account row under the refresh lock. Uses populate_existing(), flushing pending changes first so they are not discarded. Also stops if the account was marked for re-authentication in the meantime.
  • fix(rotation): reuse a concurrent replacement after an upstream 401. force_refresh=True is replaced by rejected_token=<the token that got the 401>. When several requests hit a 401 on the same token, only the first refreshes; the rest get its replacement.

Testing

  • New tests reproduce the race with two sessions: a stale worker no longer refreshes, or refreshes with the latest refresh token.
  • All new tests fail on main and pass on this branch. Before the fix, the stale worker spent upstream-refresh-token instead of the rotated one.
  • The full suite passes (154).

Both the fast path and the locked path decrypted the stored access token
and flagged the account for re-authentication on failure. Share one
helper so later changes to the locked path stay small.
ensure_fresh_token locked the row with SELECT ... FOR UPDATE and then
re-checked the expiry, but the query returned the identity-mapped object
with its previously loaded attributes. A worker that loaded the account
before another worker rotated its tokens therefore saw the old expiry and
spent the already-rotated refresh token. The provider rejected it and the
healthy account was marked as needing re-authentication.

Re-read the row with populate_existing() (flushing pending changes first so
they are not discarded), and stop if the account was marked for
re-authentication in the meantime.
When several in-flight requests hit a 401 on the same revoked token, each
forced its own refresh, spending one refresh token per request. Pass the
rejected access token instead of a force flag: under the row lock, a token
that no longer matches it was already replaced by another worker and is
returned as-is, so only the first request refreshes.
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