Repository navigation
fix(legacy): prevent concurrent token refreshes from reusing a rotated token - #194
Conversation
Like the real auth server, the mock now issues a new refresh token on every refresh. Reusing a rotated refresh token revokes all refresh tokens from the same login and returns invalid_grant. AddRefreshToken makes a pre-written session's refresh token valid, SetRefreshDelay makes concurrent refreshes overlap, and ReuseDetected reports whether a rotated token was sent again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refresh tokens are single-use: re-sending a rotated refresh token makes the auth server revoke every token from that login, and the CLI then logs the user out. The refresh lock's check read the in-memory session, which is loaded from storage only once, so it never saw another process's refresh. It also did not run at all when the lock was free. So a process that loaded its tokens before another process refreshed would refresh again with the rotated token. on_refresh_start now loads the token from session storage, bypassing the in-memory session, on each wait check and after acquiring the lock. If its refresh token differs from the one about to be sent, it returns the stored token, which the middleware then uses and saves. The integration test runs three processes on the same expired session against the mock auth server. Before the fix, two of them reused the rotated token and were logged out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FileLock stored a timestamp in the lock file and acquired it with check-then-write, so two processes could both acquire a free lock. A crashed holder also blocked others until the 30s expiry. It now holds a non-blocking exclusive flock() on the lock file. Acquiring is atomic, and the OS releases the lock when the holder exits. After 30s of waiting, acquireOrWait() returns null without the lock, as before when the lock expired. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 1 minor point · ⚪ 1 nitpick
🔍 Full review · 4 files reviewed
⚪ Nitpick
legacy/src/Service/Api.php:476—loadStoredToken()was inserted betweentokenFromSession()and its docblock. The old block ("Loads and returns an AccessToken, if possible, from a session. @PARAM SessionInterface $session ... @return AccessToken|null") now sits directly before the new docblock. It documents nothing, andtokenFromSession()is left without a docblock.
Verification
- The middleware's
finallyblock callson_refresh_end, so the lock is also released whenon_refresh_startreturns the stored token. FileLock::acquireOrWaitreleases the lock (only if held) before returning a non-null$checkresult, so the waiting path never leaks the lock.Session,AccessTokenandSessionStorageInterfaceare already imported in Api.php, andSession::__construct(id, data)matches the newloadStoredTokencall.- No existing Go test relied on the removed fixed
test-refresh-token: the other session fixtures use tokens that should fail or never refresh.
The PR adds a PHPUnit test (ApiRefreshLockTest, run by ./scripts/test/unit.sh in ci.yml) and a Go integration test (TestAuthRefresh_Concurrent, run by make integration-test in ci.yml) with a rotating mock auth server. Neither test targets the gap between releasing the lock and saving the token, so the integration test can only catch that race by chance.
Review details
- Commit: 7bb1941
- Model: claude-opus-5-5
Review 1 of 10 for this pull request · View the full run
There was a problem hiding this comment.
Note
Reviewed — No new issues found · 3 still open (1 nitpick)
🔁 Incremental · 2 files reviewed
Outstanding from earlier reviews:
- 🟡 #4149876246 —
legacy/src/Service/Api.php:331: Concurrent CLI processes can still reuse a rotated token and log the user out. — This pass didn't touch this.on_refresh_endstill releases the lock before the middleware saves the new token, and the switch to flock only makes the handoff instant, so the window is still open. UnlockedFile::loadreads can still come back as null. - 🔵 #4149876253 —
legacy/src/Service/Api.php:324: Repeated keyring calls while waiting; a keyring failure fails the refresh with a raw error. —$checkstill callssessionStorage->load()on every 500 ms poll and once more after the lock is acquired. A credential-helper failure still surfaces as a raw RuntimeException. - ⚪
legacy/src/Service/Api.php:476: The old docblock is orphaned and now misleads readers. — The orphaned 'Loads and returns an AccessToken...' docblock still sits above loadStoredToken's docblock, and tokenFromSession has none. (first raised)
Verification
acquireOrWaitcloses its handle on both the timeout return and the$checkreturn, so a waiter that gives up never keeps a lock or leaks a descriptor.release()unlocks and closes the handle and removes the key, and__destructiteratesarray_keys($this->locks), which matches the new handle-valued map.open()uses fopen mode 'c', so it never truncates a lock file that another process holds, and its mkdir check tolerates a concurrent mkdir.- When
acquireOrWaittimes out and returns null, theon_refresh_startcaller in Api.php still runs$check()before refreshing, so the stored-token check is not skipped. - The killed-holder test spawns PHP directly through array-form proc_open, so
proc_terminate(…, 9)kills the process that holds the flock and not a shell wrapper.
This pass adds legacy/tests/Service/FileLockTest.php. It covers waiting on a held lock and the lock being freed when the holder is SIGKILLed. It should run under the legacy-php job in .github/workflows/ci.yml. No test covers the timeout path, where the caller refreshes without the lock after 30 s.
Review 2 of 10 for this pull request · View the full run
The middleware calls on_refresh_end, which releases the lock, before it saves the new token. Another process could take the lock in between, still find the old refresh token in storage, and reuse it. on_refresh_start now refreshes and saves the token itself while holding the lock, then returns it. The checks while waiting are no longer needed, so storage (possibly the keyring) is loaded once per refresh, and keyring errors are converted as in getClient(). File storage now reads under a shared lock: File::save() truncates and writes under an exclusive lock, and the middleware's second save of the same token could otherwise be read as an empty session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
📋 PR Summary Stops concurrent CLI processes from re-sending a refresh token that has already been rotated, which causes the auth server to revoke the whole login. After taking the lock, Changes
|
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 1 minor point
🔁 Incremental · 6 files reviewed
Verification
connector->saveToken()now runs insideon_refresh_start, before the middleware'sfinallycallson_refresh_endto release the lock, so the save-after-release window is closed.FileStorage::loadreads underLOCK_SH, andFile::saveusesfile_put_contents(..., LOCK_EX), which truncates only after taking the lock. A concurrent read therefore cannot see an empty or partial file.- An
IdentityProviderExceptionthrown by the refresh insideon_refresh_startis still caught by the middleware's catch, which callson_refresh_error, and itsfinallystill releases the lock. - The per-poll storage reload is gone.
loadStoredTokennow runs once after the lock, and its storage errors go throughconvertStorageExceptionintoKeyringUnavailableException. LockHolderTraitresolvesdirname(__DIR__)/vendor/autoload.phpfromlegacy/tests, which points at the samelegacy/vendoras the old helper'sdirname(__DIR__, 2)path fromtests/Service.
ApiRefreshLockTest now covers three cases: the stored token used when the lock is free, the stored token used after waiting on a real holder process, and the refreshed token saved before returning (checked against a PHP built-in mock token server). FileLockTest covers a killed holder releasing the lock. Nothing tests the path where acquireOrWait times out, or storage via the credential helper.
Review 3 of 10 for this pull request · View the full run
After 30s of waiting, acquireOrWait() returns without the lock. A refresh at that point could reuse a token that the lock holder is still refreshing or saving, e.g. while it waits for a keyring prompt. on_refresh_start now fails with an error instead, unless storage already has a newer token. FileLock gets isHeld(), and a configurable time limit for tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No new issues found · 1 still open
🔁 Incremental · 4 files reviewed
Outstanding from earlier reviews:
- 🔵 #4149931017 —
legacy/src/Service/Api.php:348: Adds a redundant keyring or file write on every token refresh. — Still stands:$connector->saveToken($token)runs, and then the middleware saves the returned token again. The author accepts this and has said it will stay.
Verification
on_refresh_startnow throws instead of refreshing with$originalRefreshTokenwhenacquireOrWait()timed out without taking the lock.- The stored-token check still runs before the
isHeldguard, so a timed-out waiter still returns another process's rotated token rather than failing. isHeld()returns true whenapi.disable_locksis set, so turning locks off does not make every refresh throw.- A lock this process already holds makes
acquireOrWait()return early, andisHeld()still reports it as held, so the guard passes when the lock is re-entered.
This push adds two tests. ApiRefreshLockTest::testFailsWithoutRefreshingAfterTimingOut injects a 1-second FileLock while a child process holds the lock, and FileLockTest::testIsHeldOnlyWhenAcquired covers isHeld() for the holder and for a waiter that timed out. The vendored middleware is not installed in this sandbox, so I could not check how it handles an exception thrown from on_refresh_start.
Review 4 of 10 for this pull request · View the full run
The middleware calls on_refresh_end, and then saves the token returned by on_refresh_start. on_refresh_start already saved it, but the second save ran after the lock was released: if another process refreshed in between, it overwrote the newer token with the used one. After a successful refresh, on_refresh_end now keeps the lock, and it is released once the middleware has saved the token, via a Connector subclass that runs a callback after saveToken(). on_refresh_start no longer saves the token itself, so each refresh writes it once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a lock timeout, on_refresh_start could still return a newer stored token. The middleware then saved it without the lock, which could overwrite an even newer token that the lock holder had just saved. A process that times out waiting for the lock now always fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
platformsh/oauth2 1.0.0-beta4 saves a refreshed token before calling onRefreshEnd. platformsh/client (3.x) reads session files under a shared lock, adds Session::reload() and getId(), and skips saves when nothing changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The updated libraries save a refreshed token before onRefreshEnd, read session files under a shared lock, and can reload a session. So on_refresh_start now reloads the session and returns the stored token if it is newer, or null to let the middleware refresh. This removes SessionConnector, FileStorage, the session ID bookkeeping and the refresh inside on_refresh_start. The ordering test moved upstream, so the mock token endpoint is no longer needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 6 files reviewed
🔵 Minor point
legacy/composer.lock:886—Api::getConnectorOptions()now relies on two behaviours that only platformsh/oauth2 1.0.0-beta4 provides. First,on_refresh_startcan return null, which means "refresh it yourself". Second,GuzzleMiddleware::acquireAccessToken()saves the token throughsetAccessToken()beforeon_refresh_endreleases the lock. This push only bumps the lock file, though.legacy/composer.jsonstill requires"platformsh/oauth2": "^1@beta", and the platformsh/client 3.x commit it pins requires^1.0.0@beta, so composer still treats beta3 as a valid install. If a resolution picks beta3 (for examplecomposer update --prefer-lowest, or an update that ends up below beta4),on_refresh_endreleases the lock before the refreshed token is saved. That brings back the race this PR fixes, where another process reads the old token and reuses the rotated refresh token.
Verification
- In oauth2 beta4,
acquireAccessToken()callssetAccessToken()(which runs the token save callback that writes the session) before itsfinallycallson_refresh_end, so the lock is held until the save. - If
on_refresh_startthrows the timeout error, the middleware'sfinallystill callson_refresh_end, andFileLock::release()does nothing for a lock this process does not hold. - In the pinned client,
File::load()reads underLOCK_SH, so the deletedFileStoragesubclass is not needed. SessionInterfacedeclaresreload()andSession::reload()forces a reload from storage, soon_refresh_startsees another process's saved token.- When
on_refresh_startreturns the stored token,Connector::saveToken()writes nothing, becauseSession::save()skips data identical to what was loaded.
ApiRefreshLockTest was updated for the null return, but it no longer covers the save happening before the release, because that logic now lives in the upstream oauth2 middleware. The cross-process case is covered by integration-tests/auth_refresh_test.go, which runs in the ci.yml integration-test job. No workflow in the repo runs the legacy PHPUnit suite.
Review 7 of 10 for this pull request · View the full run
The token refresh lock relies on beta4 saving a refreshed token before calling onRefreshEnd. With beta3, the lock would be released before the save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refresh tokens are single-use: re-sending a rotated refresh token makes the auth server revoke every token from that login, and the CLI then logs the user out.
The refresh lock's check read the in-memory session, which is loaded from storage only once, so it never saw another process's refresh. It also did not run when the lock was free. So a process that loaded its tokens before another process refreshed would refresh again with the rotated token.
on_refresh_startnow reloads the session from storage after acquiring the lock. If the stored refresh token differs from the one about to be sent, it returns the stored token. Otherwise the middleware refreshes. If waiting for the lock times out, it fails instead of refreshing or saving without the lock.This updates two libraries with fixes this relies on:
onRefreshEnd, so the lock is held until the new token is saved (fix: save refreshed token before onRefreshEnd runs platformsh/platformsh-oauth2-php#9).Session::reload()(Fix session reads and saves across processes platformsh/platformsh-client-php#108).FileLockalso used check-then-write on a timestamp in the lock file, so two processes could both acquire a free lock, and a crashed holder blocked others for 30s. It now holds a non-blockingflock(), which is atomic and released by the OS when the holder exits.Tests:
🤖 Generated with Claude Code