Skip to content

fix: waiting token refreshes re-read the token instead of spending a stale copy #150 - #151

Merged
rathlinus merged 1 commit into
bulwarkmail:mainfrom
wmcparla:fix/token-refresh-race
Nov 15, 2026
Merged

rathlinus merged 1 commit into
bulwarkmail:mainfrom
wmcparla:fix/token-refresh-race

Conversation

@wmcparla

Copy link
Copy Markdown
Contributor

Summary

Android signs OAuth accounts out when the app wakes after the push handler has refreshed the token twice (#150). Several requests call ensureFreshToken() at once; the first takes the stored bundle, and the others refresh with the in-memory copy they read before awaiting adoptStoredTokens(). That token is two rotations old, and Stalwart revokes the whole grant when it comes back.

With this change every waiting caller reads the in-memory tokens again after adoptStoredTokens(), whatever it returned, and refreshes only if the token it finds is still due.

Changes

All in src/api/jmap-client.ts.

  • ensureFreshToken() re-reads currentOAuthTokens() after adoptStoredTokens() instead of only when it returned true. A caller that lost the race to another one now finds the adopted tokens and returns.
  • forceRefreshToken(): when another caller took newer tokens while this one waited, it returns true so the request is retried with them, instead of refreshing a token that is good. This one only cost an extra rotation, not a sign-out.
  • After the re-read, an OAuth bundle without expiresAt is not refreshed, as at the top of the function (an account switch while waiting could otherwise reach the refresh).

Related issues

Closes #150

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactor / code quality improvement
  • Chore / dependency update / CI change

Checklist

  • I have read the Contributing Guide
  • My code follows the project's code style and conventions
  • I have run npm run typecheck and there are no errors (native has no lint script)
  • The build passes (no npm run build in native; an arm64 + x86_64 release APK, assembleGithubRelease -PallowDebugSignedRelease=true, builds and runs)
  • I have tested my changes locally
  • I have added or updated documentation if needed (none needed)
  • I have updated translations (locales/) if my changes affect user-facing text (no user-facing text)
  • I have included screenshots or a screen recording for UI changes (no UI change)

Tests: npx vitest run: 7105 passed, 163 skipped (the live Stalwart suites), 0 failed. Two new cases in src/api/__tests__/jmap-client-hardening.test.ts:

  • seven concurrent ensureFreshToken() calls on a token two hours stale while storage holds a newer bundle: none refreshes (on main, six do);
  • two concurrent forceRefreshToken() calls in the same situation: both return true without refreshing (on main, the second refreshes).

Tested on the Android 16 emulator against a self-hosted Stalwart 1.1.0 with the steps in #150 (OAuth sign-in, battery Restricted, two push-handler refreshes an hour apart by moving the clock, then open the app), using builds with token logging added:

0.1.94 This PR
Callers that found the token due 6 4
Took the stored token 1 1
Refreshed with the token two rotations old the other 5 (one request) none
Result 400, grant revoked, "Sign in again" inbox loads, still signed in

The same build has also been running on a Galaxy S25 (One UI 8.5), where the race was first captured.

Not tested: iOS (no Mac here). The iOS notification extension also refreshes and writes back, so the same race should apply there and the same fix covers it.

Notes for reviewers

  • How Stalwart 1.1.0 treats old refresh tokens, measured with a throwaway account: one rotation old with its successor unused gets 200 and that same successor; one whose successor has been used gets invalid_grant and the grant is revoked. So a refresh whose answer is lost is harmless; only a copy two rotations old signs the account out. The comment above takes tokens stored just after its own refresh was refused assumes the server refuses the one-rotation case, which Stalwart does not, but that path still works.
  • Not in this PR: persistRefreshedTokens() writes its result over memory and storage without checking that its input is still current. If the push handler stores a newer bundle while an app refresh is in flight, the older result can overwrite it. I have not seen this happen; it would need its own change (persist only when the refreshed token is still the current one).

🤖 Generated with Claude Code

@wmcparla
wmcparla marked this pull request as ready for review November 15, 2026 03:26
@rathlinus
rathlinus merged commit c7cda31 into bulwarkmail:main Nov 15, 2026
1 check passed
@rathlinus

Copy link
Copy Markdown
Member

Thanks, merged. The other race you mention (an app refresh writing its result over a newer stored bundle) is still open. With Stalwart it's harmless, since the one-rotation-old token returns the same successor.

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.

[Bug]: Android signs OAuth accounts out after the push handler refreshed twice (stale token spent on wake)

2 participants