Skip to content

Refresh the token once, however many requests want it - #1

Merged
ergofobe merged 1 commit into
mainfrom
fix-concurrent-token-refresh
Aug 27, 2026
Merged

Refresh the token once, however many requests want it#1
ergofobe merged 1 commit into
mainfrom
fix-concurrent-token-refresh

Conversation

@ergofobe

Copy link
Copy Markdown
Owner

What happens

imogen upload --concurrency 8 signs the machine out partway through a large
run. Every request after it returns 401 unauthorized, and imogen status
reports signed in no — not a stale token, a revoked one. Re-authorizing with
imogen login is the only way back. It is reachable from the TUI too, which is
just 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-server rotates refresh tokens and
revokes 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:

// auth/oauth.ts
if (record.rotatedAt) {
  await this.revokeFamily(record.familyId)
  throw new OAuthError('invalid_grant', 'refresh token has already been rotated')
}

ProfileTokens::refreshed() dropped the profile lock before making the network
call, so it was only ever safe with one request in flight:

let (server, client_id, refresh_token) = {
    let profile = self.profile.lock().await;
    (...)                       // lock released here
};
let stored = oauth.refresh(&client_id, &refresh_token).await.ok()?;

needs_refresh() fires 60 seconds before expiry, so all eight in-flight
requests 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 resulting
burst 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

FakeAuthServer rotates and revokes exactly as the real server does, and counts
what 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:

concurrent_refresh_spends_the_token_once ... FAILED
  assertion failed: a replayed refresh token revokes the family and signs the machine out
  left: 5, right: 0
concurrent_unauthorized_retries_spend_the_token_once ... FAILED

a_later_expiry_refreshes_again covers the other direction — the guard must not
wedge the profile on the first token it ever fetched.

53 tests pass, cargo clippy --all-targets -- -D warnings and cargo fmt --check
are clean.

Not covered

Refresh is now single-flight within a process. Two imogen processes at once
(an upload and the TUI, say) can still race, because they coordinate only
through ~/.config/imogen/cli.json, which refreshed() read-modify-writes with
no 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

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
@ergofobe
ergofobe merged commit ae6e4ee into main Aug 27, 2026
2 checks passed
@ergofobe
ergofobe deleted the fix-concurrent-token-refresh branch August 27, 2026 04:41
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