Refresh the token once, however many requests want it - #1
Merged
Conversation
A refresh token is single-use. The server rotates it on every grant and, when a rotated one comes back, reads that as a stolen copy being replayed and revokes the whole family — access token, refresh token, the lot. ProfileTokens::refreshed dropped the profile lock before making the network call, so it was only ever safe with one request in flight. `imogen upload --concurrency 8` has eight. They all crossed the 60-second staleness margin in the same instant, all read the same refresh token, and all spent it: one grant and seven replays. The seven revoked the family, so the upload did not merely fail to refresh, it signed the machine out mid-run and every request after it returned 401. Uploading a Google Takeout export hit this after roughly 550 files. It is reachable from the TUI too, which is just as concurrent. Hold a lock across the whole refresh so only one is ever in flight, and have callers sample the refresh token before queueing: whoever wins spends it, and the rest wake to find a different token saved, which tells them the work is done and that spending theirs again would be the replay the server punishes. Both routes in — the proactive expiry check and the 401 retry — go through it. The tests stand up an authorization server that rotates and revokes exactly as the real one does, and count what it is asked. Against the old code they fail the way production did: one grant, five replays, family revoked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJdrwxFz6eYGnxLHVXxy17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What happens
imogen upload --concurrency 8signs the machine out partway through a largerun. Every request after it returns
401 unauthorized, andimogen statusreports
signed in no— not a stale token, a revoked one. Re-authorizing withimogen loginis the only way back. It is reachable from the TUI too, which isjust as concurrent.
Found while importing a Google Takeout export: 555 files in, the whole run
turned into 401s.
Why
This is ours, not the server's.
imogen-serverrotates refresh tokens andrevokes the family when a rotated one comes back — reuse detection, exactly as
OAuth 2.1 and RFC 6819 prescribe, because a second use of a single-use token is
indistinguishable from a stolen copy being replayed:
ProfileTokens::refreshed()dropped the profile lock before making the networkcall, so it was only ever safe with one request in flight:
needs_refresh()fires 60 seconds before expiry, so all eight in-flightrequests cross that line in the same instant, read the same refresh token, and
spend it: one grant, seven replays. The seven revoke the family. The 401 retry
path (
RefreshToken::refresh) funnels into the same function, so the resultingburst of 401s makes it worse rather than recovering.
The fix
A lock held across the whole refresh, so only one is ever in flight, plus a
check that makes the queued callers cheap instead of merely serial: they sample
the refresh token before queueing, and whoever wakes to find a different token
saved knows the work is already done and that spending theirs would be the
replay the server punishes. Both routes in — the proactive expiry check and the
401 retry — go through it.
Six concurrent callers now produce one grant and zero replays, instead of one
grant and five revocations.
Tests
FakeAuthServerrotates and revokes exactly as the real server does, and countswhat it is asked, so the tests assert on the behaviour that actually bit rather
than on a mock's say-so. Against the old code they fail the way production did:
a_later_expiry_refreshes_againcovers the other direction — the guard must notwedge the profile on the first token it ever fetched.
53 tests pass,
cargo clippy --all-targets -- -D warningsandcargo fmt --checkare clean.
Not covered
Refresh is now single-flight within a process. Two
imogenprocesses at once(an upload and the TUI, say) can still race, because they coordinate only
through
~/.config/imogen/cli.json, whichrefreshed()read-modify-writes withno lock — so a losing racer can also clobber a good token with its own. Narrower
window, separate fix; worth a follow-up issue rather than widening this one.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KJdrwxFz6eYGnxLHVXxy17