Single-flight OAuth token refresh#56
Open
kmatzen wants to merge 1 commit into
Open
Conversation
getAccessToken read-modify-wrote a single localStorage record across an await, with no mutex. Nine independent call sites can overlap freely -- autosave firing while the user clicks Share, the project list loading thumbnails during a save -- and each one issued its own refresh and wrote back a record it had snapshotted before its await. TLA+ models of the old and new protocols are in specs/. TLC's counterexample against the old design: two callers snapshot the same record, both refresh, the first succeeds and advances the record, and the second then writes based on a read the record has already moved past. When that second call fails -- a blip, a 5xx, a rotated refresh token -- its write is writePersisted(null), signing the user out mid-save and discarding the session the first caller just established. Changes: - Single-flight. The first caller needing a refresh stores its promise; concurrent callers await that same promise. At most one refresh is ever in flight, so no caller can be signed out by another's failure. - Only a definitive auth failure clears the session. RefreshError carries a `definitive` flag set for 400 invalid_grant and 401; a network error or 5xx now rejects the caller but leaves the record intact. Previously any failure wiped it, turning a dropped packet into a forced re-auth. - The refresh re-reads storage after its await instead of spreading its pre-await snapshot, so a logout or a second tab's sign-in landing mid- refresh is no longer resurrected. Note on the spec: the fixed model's NoStaleOverwrite is close to structural -- single-flight makes staleness impossible by construction, and removing the lock deadlocks the model rather than violating that property. The properties with demonstrated teeth there are AtMostOneRefreshInFlight and WaitersDoNotRefresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner
Author
|
Old-design discrimination check: the 7 tests run against The three that pass are behaviors both designs share (definitive-failure signout, sequential refresh, skip-when-not-near-expiry). The four that fail are exactly the defects:
So these are regression tests, not tests that merely happen to pass. |
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.
getAccessTokenread-modify-wrote a singlelocalStoragerecord across anawait, with no mutex. Nine independent call sites can overlap freely — autosave firing while the user clicks Share, the project list loading thumbnails during a save — and each issued its own refresh, writing back a record snapshotted before its await.TLA+ models of both protocols are in
specs/. TLC's counterexample against the old design: two callers snapshot the same record, both refresh, the first succeeds and advances the record, and the second then writes based on a read the record has already moved past. When that second call fails — a blip, a 5xx, a rotated refresh token — its write iswritePersisted(null), signing the user out mid-save and discarding the session the first caller just established.Changes
RefreshErrorcarries adefinitiveflag, set for 400invalid_grantand 401. A network error or 5xx now rejects the caller but leaves the record intact — previously any failure wiped it, turning a dropped packet into a forced re-auth.Verification
tsc --noEmitHonest notes on the spec
Two things worth stating rather than glossing:
My first property was wrong. I initially wrote
NoSpuriousLogoutas "a successful refresh is never followed by a logout". That is too strong — a later, sequential refresh may legitimately discover a dead token and sign out correctly. TLC's "violation" of it was the trivial sequential path, not the concurrency bug, so that first counterexample proved nothing. The corrected property isNoStaleOverwrite: no caller writes based on a read the record has already moved past.A precedence bug made a property pass vacuously.
staleWrite' = staleWrite \/ IsStale(c)parses as(staleWrite' = staleWrite) \/ IsStale(c), since=binds tighter than\/. In the old-design spec TLC flagged an unassigned variable; in the fixed spec it silently meant the flag could never be set, so the property passed for no reason. Both are parenthesized now.The fixed model's
NoStaleOverwriteis close to structural. Single-flight makes staleness impossible by construction, and my vacuity check (removing the lock) deadlocks the model rather than violating that property — so I cannot demonstrate it has independent teeth there. The properties that do areAtMostOneRefreshInFlightandWaitersDoNotRefresh; removing the lock violates the former.Related
Independent of #50 — different files, no overlap. Both add to
specs/, but disjoint files.🤖 Generated with Claude Code