From 8895dfe89b550594871be846d411fef2dcda2141 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 30 Sep 2026 22:18:19 +0100 Subject: [PATCH 01/10] test: rotate refresh tokens in the mock auth server 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 --- pkg/mockapi/auth_server.go | 103 +++++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/pkg/mockapi/auth_server.go b/pkg/mockapi/auth_server.go index 258f39ed..cb0931da 100644 --- a/pkg/mockapi/auth_server.go +++ b/pkg/mockapi/auth_server.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "slices" @@ -24,10 +25,82 @@ var ValidAPITokens = []string{"api-token-1"} var accessTokens = []string{"access-token-1"} // AuthServer is a mock authentication server for testing. +// +// Like the real server, it rotates refresh tokens on every refresh. Reusing a +// rotated refresh token revokes all refresh tokens from the same login. type AuthServer struct { *httptest.Server revokedMu sync.Mutex revokedTokens []string + + refreshMu sync.Mutex + refreshTokens map[string]*refreshToken + refreshDelay time.Duration + refreshCount int + reuseDetected bool + revokedFamilies map[string]bool +} + +type refreshToken struct { + family string + used bool +} + +// AddRefreshToken makes a refresh token valid, as if it had been issued at login. +func (s *AuthServer) AddRefreshToken(token string) { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + s.refreshTokens[token] = &refreshToken{family: token} +} + +// SetRefreshDelay delays refresh token responses, e.g. to make concurrent refreshes overlap. +func (s *AuthServer) SetRefreshDelay(d time.Duration) { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + s.refreshDelay = d +} + +// ReuseDetected reports whether a rotated refresh token was sent again. +func (s *AuthServer) ReuseDetected() bool { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + return s.reuseDetected +} + +// issueRefreshToken returns a new refresh token. It starts a new family if family is empty. +// The caller must hold refreshMu. +func (s *AuthServer) issueRefreshToken(family string) string { + s.refreshCount++ + token := fmt.Sprintf("refresh-token-%d", s.refreshCount) + if family == "" { + family = token + } + s.refreshTokens[token] = &refreshToken{family: family} + return token +} + +// newRefreshToken is issueRefreshToken for a new login. +func (s *AuthServer) newRefreshToken() string { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + return s.issueRefreshToken("") +} + +// rotateRefreshToken exchanges a refresh token for a new one, or returns an OAuth error code. +func (s *AuthServer) rotateRefreshToken(token string) (newToken, errCode string) { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + rt, ok := s.refreshTokens[token] + if !ok || s.revokedFamilies[rt.family] { + return "", "invalid_grant" + } + if rt.used { + s.reuseDetected = true + s.revokedFamilies[rt.family] = true + return "", "invalid_grant" + } + rt.used = true + return s.issueRefreshToken(rt.family), "" } // RevokedTokens returns a copy of all tokens that have been revoked. @@ -56,7 +129,10 @@ func NewAuthServer(t *testing.T) *AuthServer { pendingAuths = map[string]pendingAuth{} // code → pendingAuth ) - srv := &AuthServer{} + srv := &AuthServer{ + refreshTokens: map[string]*refreshToken{}, + revokedFamilies: map[string]bool{}, + } mux.Get("/oauth2/authorize", func(w http.ResponseWriter, req *http.Request) { q := req.URL.Query() @@ -81,7 +157,7 @@ func NewAuthServer(t *testing.T) *AuthServer { "access_token": accessTokens[0], "expires_in": 3600, "token_type": "bearer", - "refresh_token": "test-refresh-token", + "refresh_token": srv.newRefreshToken(), }) return } @@ -109,20 +185,25 @@ func NewAuthServer(t *testing.T) *AuthServer { "access_token": accessTokens[0], "expires_in": 3600, "token_type": "bearer", - "refresh_token": "test-refresh-token", + "refresh_token": srv.newRefreshToken(), }) case "refresh_token": - if req.Form.Get("refresh_token") == "test-refresh-token" { - _ = json.NewEncoder(w).Encode(map[string]any{ - "access_token": accessTokens[0], - "expires_in": 3600, - "token_type": "bearer", - "refresh_token": "test-refresh-token", - }) + srv.refreshMu.Lock() + delay := srv.refreshDelay + srv.refreshMu.Unlock() + time.Sleep(delay) + newToken, errCode := srv.rotateRefreshToken(req.Form.Get("refresh_token")) + if errCode != "" { + writeOAuthError(w, errCode, "The refresh token is invalid.") return } - writeOAuthError(w, "invalid_grant", "The refresh token is invalid.") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessTokens[0], + "expires_in": 3600, + "token_type": "bearer", + "refresh_token": newToken, + }) default: writeOAuthError(w, "unsupported_grant_type", "Unsupported grant type: "+req.Form.Get("grant_type")) From 7bb19412613f8153ebabf379649a7269ffe54a05 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 30 Sep 2026 22:18:19 +0100 Subject: [PATCH 02/10] fix(legacy): reload the session from storage before refreshing a token 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 --- integration-tests/auth_refresh_test.go | 62 +++++++++++ legacy/src/Service/Api.php | 34 ++++-- legacy/tests/Service/ApiRefreshLockTest.php | 113 ++++++++++++++++++++ 3 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 integration-tests/auth_refresh_test.go create mode 100644 legacy/tests/Service/ApiRefreshLockTest.php diff --git a/integration-tests/auth_refresh_test.go b/integration-tests/auth_refresh_test.go new file mode 100644 index 00000000..849b9637 --- /dev/null +++ b/integration-tests/auth_refresh_test.go @@ -0,0 +1,62 @@ +package tests + +import ( + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/pkg/mockapi" +) + +// TestAuthRefresh_Concurrent runs several processes that load the same expired session. +// Only one should use the refresh token: the others must use the token it saved. +func TestAuthRefresh_Concurrent(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + // Keep the first refresh in progress while the other processes start. + authServer.SetRefreshDelay(2 * time.Second) + authServer.AddRefreshToken("initial-refresh-token") + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ID: "u1"}) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.extraEnv = append(f.extraEnv, EnvPrefix+"TOKEN=") + // Initialize before running commands concurrently. + f.dir = t.TempDir() + getCommandName(t) + writeOAuthSession(t, f.home, "default", map[string]any{ + "accessToken": "expired-token", + "tokenType": "bearer", + "expires": time.Now().Add(-time.Hour).Unix(), + "refreshToken": "initial-refresh-token", + }) + + const processes = 3 + type result struct { + stdout, stderr string + err error + } + results := make([]result, processes) + var wg sync.WaitGroup + for i := range processes { + wg.Go(func() { + stdout, stderr, err := f.RunCombinedOutput("auth:token", "--no-warn") + results[i] = result{stdout, stderr, err} + }) + } + wg.Wait() + + for i, r := range results { + if assert.NoError(t, r.err, "process %d stderr: %s", i, r.stderr) { + assert.Equal(t, "access-token-1", strings.TrimSpace(r.stdout), "process %d", i) + } + } + assert.False(t, authServer.ReuseDetected(), "a rotated refresh token was reused") +} diff --git a/legacy/src/Service/Api.php b/legacy/src/Service/Api.php index 93a30056..0ebeea48 100644 --- a/legacy/src/Service/Api.php +++ b/legacy/src/Service/Api.php @@ -124,6 +124,11 @@ class Api */ private ?SessionStorageInterface $sessionStorage = null; + /** + * The ID of the session in persistent storage, if any. + */ + private ?string $storedSessionId = null; + /** * Sets whether we are currently verifying login using a test request. */ @@ -312,17 +317,18 @@ private function getConnectorOptions(): array // Acquire a lock to prevent tokens being refreshed at the same time in // different CLI processes. $refreshLockName = 'refresh--' . $this->config->getSessionIdSlug(); - $connectorOptions['on_refresh_start'] = function ($originalRefreshToken) use ($refreshLockName) { + $connectorOptions['on_refresh_start'] = function ($originalRefreshToken) use ($refreshLockName): ?AccessToken { $this->io->debug('Refreshing access token'); - $connector = $this->getClient(false)->getConnector(); - return $this->fileLock->acquireOrWait($refreshLockName, function (): void { - $this->stdErr->writeln('Waiting for token refresh lock', OutputInterface::VERBOSITY_VERBOSE); - }, function () use ($connector, $originalRefreshToken) { - $session = $connector->getSession(); - $accessToken = $this->tokenFromSession($session); + // Refresh tokens are single-use, so use the stored token if another process has refreshed it. + $check = function () use ($originalRefreshToken): ?AccessToken { + $accessToken = $this->loadStoredToken(); return $accessToken && $accessToken->getRefreshToken() !== $originalRefreshToken ? $accessToken : null; - }); + }; + $result = $this->fileLock->acquireOrWait($refreshLockName, function (): void { + $this->stdErr->writeln('Waiting for token refresh lock', OutputInterface::VERBOSITY_VERBOSE); + }, $check); + return $result instanceof AccessToken ? $result : $check(); }; $connectorOptions['on_refresh_end'] = function () use ($refreshLockName): void { $this->fileLock->release($refreshLockName); @@ -467,6 +473,17 @@ private function isApiTokenInvalid(mixed $body): bool * * @return AccessToken|null */ + /** + * Loads the token from session storage, bypassing the in-memory session. + */ + private function loadStoredToken(): ?AccessToken + { + if (!isset($this->sessionStorage, $this->storedSessionId)) { + return null; + } + return $this->tokenFromSession(new Session($this->storedSessionId, $this->sessionStorage->load($this->storedSessionId))); + } + private function tokenFromSession(SessionInterface $session): ?AccessToken { if (!$session->get('accessToken')) { @@ -548,6 +565,7 @@ public function getClient(bool $autoLogin = true, bool $reset = false): Platform $this->io->debug('Loading session'); try { $session->setStorage($this->sessionStorage); + $this->storedSessionId = $sessionId; } catch (\RuntimeException $e) { if ($this->sessionStorage instanceof CredentialHelperStorage) { $previous = $e->getPrevious(); diff --git a/legacy/tests/Service/ApiRefreshLockTest.php b/legacy/tests/Service/ApiRefreshLockTest.php new file mode 100644 index 00000000..13a54742 --- /dev/null +++ b/legacy/tests/Service/ApiRefreshLockTest.php @@ -0,0 +1,113 @@ +tempDirSetUp(); + $this->config = new Config([ + 'PLATFORMSH_CLI_HOME' => (string) $this->tempDir, + 'PLATFORMSH_CLI_SESSION_ID' => 'refresh-test', + 'PLATFORMSH_CLI_API_DISABLE_CREDENTIAL_HELPERS' => '1', + ]); + $this->storage = new File($this->config->getSessionDir()); + } + + public function tearDown(): void + { + // Api caches the client statically: do not leak this session into other tests. + (new \ReflectionProperty(Api::class, 'client'))->setValue(null, null); + } + + public function testUsesTheStoredTokenWhenTheLockIsFree(): void + { + $onRefreshStart = $this->loadStaleSession(); + + $token = $onRefreshStart('refresh-1'); + + $this->assertInstanceOf(AccessToken::class, $token); + $this->assertSame('access-2', $token->getToken()); + $this->assertSame('refresh-2', $token->getRefreshToken()); + } + + public function testUsesTheStoredTokenAfterWaiting(): void + { + $onRefreshStart = $this->loadStaleSession(); + + // Another process holds the lock. + $otherLock = new FileLock($this->config); + $this->assertNull($otherLock->acquireOrWait('refresh--' . $this->config->getSessionIdSlug())); + + $token = $onRefreshStart('refresh-1'); + + $this->assertInstanceOf(AccessToken::class, $token); + $this->assertSame('refresh-2', $token->getRefreshToken()); + } + + public function testAllowsARefreshWhenTheStoredTokenIsUnchanged(): void + { + $this->storage->save('refresh-test', $this->sessionData('access-1', 'refresh-1')); + $onRefreshStart = $this->onRefreshStart(); + + $this->assertNull($onRefreshStart('refresh-1')); + } + + /** + * Loads the session in memory, then simulates another process refreshing the token. + */ + private function loadStaleSession(): callable + { + $this->storage->save('refresh-test', $this->sessionData('access-1', 'refresh-1')); + $onRefreshStart = $this->onRefreshStart(); + $this->storage->save('refresh-test', $this->sessionData('access-2', 'refresh-2')); + + return $onRefreshStart; + } + + private function onRefreshStart(): callable + { + $api = new Api($this->config, new ArrayCache(), new BufferedOutput()); + $connector = $api->getClient(false, true)->getConnector(); + $this->assertInstanceOf(Connector::class, $connector); + $this->assertSame('refresh-1', $connector->getSession()->get('refreshToken')); + + $onRefreshStart = $connector->getConfig()['on_refresh_start']; + $this->assertIsCallable($onRefreshStart); + + return $onRefreshStart; + } + + /** + * @return array + */ + private function sessionData(string $accessToken, string $refreshToken): array + { + return [ + 'accessToken' => $accessToken, + 'tokenType' => 'bearer', + 'expires' => time() + 900, + 'refreshToken' => $refreshToken, + ]; + } +} From 61ea0c1a7517e3c82d55de75618dc253b0cde8e1 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 30 Sep 2026 23:17:03 +0100 Subject: [PATCH 03/10] fix(legacy): use OS file locks in FileLock 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 --- legacy/src/Service/FileLock.php | 111 ++++++++------------------ legacy/tests/Service/FileLockTest.php | 67 ++++++++++++++++ 2 files changed, 99 insertions(+), 79 deletions(-) create mode 100644 legacy/tests/Service/FileLockTest.php diff --git a/legacy/src/Service/FileLock.php b/legacy/src/Service/FileLock.php index cb57d59a..2ea1ab75 100644 --- a/legacy/src/Service/FileLock.php +++ b/legacy/src/Service/FileLock.php @@ -4,13 +4,18 @@ namespace Platformsh\Cli\Service; +/** + * Locks between CLI processes, using OS file locks. + * + * The OS releases a lock when its process exits, even if it crashes. + */ class FileLock { private readonly int $checkIntervalMs; private readonly int $timeLimit; private readonly bool $disabled; - /** @var array */ + /** @var array */ private array $locks = []; public function __construct(private readonly Config $config) @@ -23,6 +28,9 @@ public function __construct(private readonly Config $config) /** * Acquires a lock, or waits for one if it already exists. * + * If the lock is not acquired within the time limit, this returns null + * without holding the lock. + * * @param string $lockName * A unique name for the lock. * @param callable|null $onWait @@ -36,20 +44,16 @@ public function __construct(private readonly Config $config) */ public function acquireOrWait(string $lockName, ?callable $onWait = null, ?callable $check = null): mixed { - if ($this->disabled) { + if ($this->disabled || isset($this->locks[$lockName])) { return null; } - $runOnWait = false; - $filename = $this->filename($lockName); + $handle = $this->open($this->filename($lockName)); $start = \time(); - while (\time() - $start < $this->timeLimit) { - if (!\file_exists($filename)) { - break; - } - $content = $this->readWithLock($filename); - $lockedAt = \intval($content); - if ($lockedAt === 0 || \time() >= $lockedAt + $this->timeLimit) { - break; + $runOnWait = false; + while (!\flock($handle, LOCK_EX | LOCK_NB)) { + if (\time() - $start >= $this->timeLimit) { + \fclose($handle); + return null; } if ($onWait !== null && !$runOnWait) { $onWait(); @@ -59,23 +63,23 @@ public function acquireOrWait(string $lockName, ?callable $onWait = null, ?calla if ($check !== null) { $result = $check(); if ($result !== null) { - $this->release($lockName); + \fclose($handle); return $result; } } } - $this->writeWithLock($filename, (string) \time()); - $this->locks[$lockName] = $lockName; + $this->locks[$lockName] = $handle; return null; } /** - * Releases a lock that was created by acquire(). + * Releases a lock that was created by acquireOrWait(). */ public function release(string $lockName): void { - if (!$this->disabled && isset($this->locks[$lockName])) { - $this->writeWithLock($this->filename($lockName), ''); + if (isset($this->locks[$lockName])) { + \flock($this->locks[$lockName], LOCK_UN); + \fclose($this->locks[$lockName]); unset($this->locks[$lockName]); } } @@ -85,7 +89,7 @@ public function release(string $lockName): void */ public function __destruct() { - foreach ($this->locks as $lockName) { + foreach (\array_keys($this->locks) as $lockName) { $this->release($lockName); } } @@ -103,72 +107,21 @@ private function filename(string $lockName): string } /** - * Reads a file using a shared lock. - * - * @param string $filename - * @return string - */ - private function readWithLock(string $filename): string - { - $handle = \fopen($filename, 'r'); - if (!$handle) { - throw new \RuntimeException('Failed to open file for reading: ' . $filename); - } - try { - if (!\flock($handle, LOCK_SH)) { - \trigger_error('Failed to lock file: ' . $filename, E_USER_WARNING); - } - $content = \fgets($handle); - if ($content === false && !\feof($handle)) { - throw new \RuntimeException('Failed to read file: ' . $filename); - } - } finally { - if (!\flock($handle, LOCK_UN)) { - \trigger_error('Failed to unlock file: ' . $filename, E_USER_WARNING); - } - if (!\fclose($handle)) { - \trigger_error('Failed to close file: ' . $filename, E_USER_WARNING); - } - } - return (string) $content; - } - - /** - * Writes to a file using an exclusive lock. + * Opens a lock file, creating it if necessary. * - * @param string $filename - * @param string $content - * @return void + * @return resource */ - private function writeWithLock(string $filename, string $content): void + private function open(string $filename) { $dir = \dirname($filename); - if (!\is_dir($dir)) { - if (!\mkdir($dir, 0o777, true)) { - throw new \RuntimeException('Failed to create directory: ' . $dir); - } + if (!\is_dir($dir) && !\mkdir($dir, 0o777, true) && !\is_dir($dir)) { + throw new \RuntimeException('Failed to create directory: ' . $dir); } - $handle = \fopen($filename, 'w'); + // Mode "c" creates the file without truncating it. + $handle = \fopen($filename, 'c'); if (!$handle) { - throw new \RuntimeException('Failed to open file for writing: ' . $filename); - } - try { - if (!\flock($handle, LOCK_EX)) { - \trigger_error('Failed to lock file: ' . $filename, E_USER_WARNING); - } - if (\fputs($handle, $content) === false) { - throw new \RuntimeException('Failed to write to file: ' . $filename); - } - if (!\fsync($handle)) { - \trigger_error('Failed to sync file (fsync): ' . $filename, E_USER_WARNING); - } - } finally { - if (!\flock($handle, LOCK_UN)) { - \trigger_error('Failed to unlock file: ' . $filename, E_USER_WARNING); - } - if (!\fclose($handle)) { - \trigger_error('Failed to close file: ' . $filename, E_USER_WARNING); - } + throw new \RuntimeException('Failed to open lock file: ' . $filename); } + return $handle; } } diff --git a/legacy/tests/Service/FileLockTest.php b/legacy/tests/Service/FileLockTest.php new file mode 100644 index 00000000..2919ae31 --- /dev/null +++ b/legacy/tests/Service/FileLockTest.php @@ -0,0 +1,67 @@ +tempDirSetUp(); + $this->config = new Config(['PLATFORMSH_CLI_HOME' => (string) $this->tempDir]); + } + + public function testWaitsForAHeldLock(): void + { + $holder = new FileLock($this->config); + $this->assertNull($holder->acquireOrWait('test')); + + $this->assertSame('checked', (new FileLock($this->config))->acquireOrWait('test', null, fn(): string => 'checked')); + + $holder->release('test'); + $this->assertNull((new FileLock($this->config))->acquireOrWait('test', null, fn(): string => 'checked')); + } + + public function testLockIsFreedWhenTheHolderIsKilled(): void + { + $holder = $this->startHolderProcess('test'); + $this->assertSame('checked', (new FileLock($this->config))->acquireOrWait('test', null, fn(): string => 'checked')); + + \proc_terminate($holder, 9); + \proc_close($holder); + + $this->assertNull((new FileLock($this->config))->acquireOrWait('test', null, fn(): string => 'checked')); + } + + /** + * Starts a process which acquires a lock and then sleeps. + * + * @return resource + */ + private function startHolderProcess(string $lockName) + { + $script = \sprintf( + 'require %s; $l = new %s(new %s(["PLATFORMSH_CLI_HOME" => %s])); $l->acquireOrWait(%s); echo "locked\n"; sleep(60);', + \var_export(\dirname(__DIR__, 2) . '/vendor/autoload.php', true), + FileLock::class, + Config::class, + \var_export($this->tempDir, true), + \var_export($lockName, true), + ); + $process = \proc_open([\PHP_BINARY, '-r', $script], [1 => ['pipe', 'w']], $pipes); + $this->assertIsResource($process); + $this->assertSame("locked\n", \fgets($pipes[1])); + + return $process; + } +} From 75f7d25d5eea482e94b50eef62c82ea3c01d4313 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 30 Sep 2026 23:25:16 +0100 Subject: [PATCH 04/10] fix(legacy): save the refreshed token before releasing the refresh lock 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 --- legacy/src/Service/Api.php | 85 +++++++++++++------- legacy/src/Session/FileStorage.php | 40 +++++++++ legacy/tests/LockHolderTrait.php | 34 ++++++++ legacy/tests/Service/ApiRefreshLockTest.php | 89 ++++++++++++++++----- legacy/tests/Service/FileLockTest.php | 26 +----- legacy/tests/data/oauth2-token-router.php | 17 ++++ 6 files changed, 217 insertions(+), 74 deletions(-) create mode 100644 legacy/src/Session/FileStorage.php create mode 100644 legacy/tests/LockHolderTrait.php create mode 100644 legacy/tests/data/oauth2-token-router.php diff --git a/legacy/src/Service/Api.php b/legacy/src/Service/Api.php index 0ebeea48..4b264541 100644 --- a/legacy/src/Service/Api.php +++ b/legacy/src/Service/Api.php @@ -22,6 +22,7 @@ use GuzzleHttp\Psr7\UriResolver; use GuzzleHttp\Utils; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; +use League\OAuth2\Client\Grant\RefreshToken; use League\OAuth2\Client\Token\AccessToken; use Platformsh\Cli\CredentialHelper\KeyringUnavailableException; use Platformsh\Cli\CredentialHelper\Manager; @@ -54,7 +55,7 @@ use Platformsh\Client\PlatformClient; use Platformsh\Client\Session\Session; use Platformsh\Client\Session\SessionInterface; -use Platformsh\Client\Session\Storage\File; +use Platformsh\Cli\Session\FileStorage; use Platformsh\Client\Session\Storage\SessionStorageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -317,18 +318,31 @@ private function getConnectorOptions(): array // Acquire a lock to prevent tokens being refreshed at the same time in // different CLI processes. $refreshLockName = 'refresh--' . $this->config->getSessionIdSlug(); - $connectorOptions['on_refresh_start'] = function ($originalRefreshToken) use ($refreshLockName): ?AccessToken { + $connectorOptions['on_refresh_start'] = function (string $originalRefreshToken) use ($refreshLockName): AccessToken { $this->io->debug('Refreshing access token'); - // Refresh tokens are single-use, so use the stored token if another process has refreshed it. - $check = function () use ($originalRefreshToken): ?AccessToken { - $accessToken = $this->loadStoredToken(); - return $accessToken && $accessToken->getRefreshToken() !== $originalRefreshToken - ? $accessToken : null; - }; - $result = $this->fileLock->acquireOrWait($refreshLockName, function (): void { + $this->fileLock->acquireOrWait($refreshLockName, function (): void { $this->stdErr->writeln('Waiting for token refresh lock', OutputInterface::VERBOSITY_VERBOSE); - }, $check); - return $result instanceof AccessToken ? $result : $check(); + }); + + // Refresh tokens are single-use, so use the stored token if another process has refreshed it. + $storedToken = $this->loadStoredToken(); + if ($storedToken && $storedToken->getRefreshToken() !== $originalRefreshToken) { + return $storedToken; + } + + // Refresh and save the token before on_refresh_end releases the lock. + $connector = $this->getClient(false)->getConnector(); + if (!$connector instanceof Connector) { + throw new \LogicException('Unexpected connector type'); + } + $token = $connector->getOAuth2Provider()->getAccessToken(new RefreshToken(), [ + 'refresh_token' => $originalRefreshToken, + ]); + if (!$token instanceof AccessToken) { + throw new \LogicException('Unexpected access token type'); + } + $connector->saveToken($token); + return $token; }; $connectorOptions['on_refresh_end'] = function () use ($refreshLockName): void { $this->fileLock->release($refreshLockName); @@ -466,13 +480,6 @@ private function isApiTokenInvalid(mixed $body): bool return false; } - /** - * Loads and returns an AccessToken, if possible, from a session. - * - * @param SessionInterface $session - * - * @return AccessToken|null - */ /** * Loads the token from session storage, bypassing the in-memory session. */ @@ -481,9 +488,37 @@ private function loadStoredToken(): ?AccessToken if (!isset($this->sessionStorage, $this->storedSessionId)) { return null; } - return $this->tokenFromSession(new Session($this->storedSessionId, $this->sessionStorage->load($this->storedSessionId))); + try { + $data = $this->sessionStorage->load($this->storedSessionId); + } catch (\RuntimeException $e) { + throw $this->convertStorageException($e); + } + return $this->tokenFromSession(new Session($this->storedSessionId, $data)); } + /** + * Converts a session storage error into a keyring error, if applicable. + */ + private function convertStorageException(\RuntimeException $e): \RuntimeException + { + if ($this->sessionStorage instanceof CredentialHelperStorage) { + $previous = $e->getPrevious(); + if ($previous instanceof ProcessTimedOutException) { + return KeyringUnavailableException::fromTimeout($previous); + } elseif ($previous instanceof ProcessFailedException) { + return KeyringUnavailableException::fromFailure($previous); + } + } + return $e; + } + + /** + * Loads and returns an AccessToken, if possible, from a session. + * + * @param SessionInterface $session + * + * @return AccessToken|null + */ private function tokenFromSession(SessionInterface $session): ?AccessToken { if (!$session->get('accessToken')) { @@ -567,15 +602,7 @@ public function getClient(bool $autoLogin = true, bool $reset = false): Platform $session->setStorage($this->sessionStorage); $this->storedSessionId = $sessionId; } catch (\RuntimeException $e) { - if ($this->sessionStorage instanceof CredentialHelperStorage) { - $previous = $e->getPrevious(); - if ($previous instanceof ProcessTimedOutException) { - throw KeyringUnavailableException::fromTimeout($previous); - } elseif ($previous instanceof ProcessFailedException) { - throw KeyringUnavailableException::fromFailure($previous); - } - } - throw $e; + throw $this->convertStorageException($e); } } @@ -633,7 +660,7 @@ private function initSessionStorage(): void // Fall back to file storage. $this->io->debug('Using filesystem for session storage'); - $this->sessionStorage = new File($this->config->getSessionDir()); + $this->sessionStorage = new FileStorage($this->config->getSessionDir()); } } diff --git a/legacy/src/Session/FileStorage.php b/legacy/src/Session/FileStorage.php new file mode 100644 index 00000000..805dc2cc --- /dev/null +++ b/legacy/src/Session/FileStorage.php @@ -0,0 +1,40 @@ + + */ + public function load(string $sessionId): array + { + $filename = $this->getFilename($sessionId); + if (!\is_readable($filename)) { + return []; + } + $handle = \fopen($filename, 'r'); + if (!$handle) { + return []; + } + try { + \flock($handle, LOCK_SH); + $raw = \stream_get_contents($handle); + } finally { + \fclose($handle); + } + $data = $raw !== false ? \json_decode($raw, true) : null; + + return \is_array($data) ? $data : []; + } +} diff --git a/legacy/tests/LockHolderTrait.php b/legacy/tests/LockHolderTrait.php new file mode 100644 index 00000000..5b96e031 --- /dev/null +++ b/legacy/tests/LockHolderTrait.php @@ -0,0 +1,34 @@ + %s])); $l->acquireOrWait(%s); echo "locked\n"; sleep(%d);', + \var_export(\dirname(__DIR__) . '/vendor/autoload.php', true), + FileLock::class, + Config::class, + \var_export($homeDir, true), + \var_export($lockName, true), + $holdSeconds, + ); + $process = \proc_open([\PHP_BINARY, '-r', $script], [1 => ['pipe', 'w']], $pipes); + $this->assertIsResource($process); + $this->assertSame("locked\n", \fgets($pipes[1])); + + return $process; + } +} diff --git a/legacy/tests/Service/ApiRefreshLockTest.php b/legacy/tests/Service/ApiRefreshLockTest.php index 13a54742..dc78c118 100644 --- a/legacy/tests/Service/ApiRefreshLockTest.php +++ b/legacy/tests/Service/ApiRefreshLockTest.php @@ -9,8 +9,8 @@ use PHPUnit\Framework\TestCase; use Platformsh\Cli\Service\Api; use Platformsh\Cli\Service\Config; -use Platformsh\Cli\Service\FileLock; use Platformsh\Cli\Tests\HasTempDirTrait; +use Platformsh\Cli\Tests\LockHolderTrait; use Platformsh\Client\Connection\Connector; use Platformsh\Client\Session\Storage\File; use Symfony\Component\Console\Output\BufferedOutput; @@ -18,31 +18,32 @@ class ApiRefreshLockTest extends TestCase { use HasTempDirTrait; + use LockHolderTrait; - private Config $config; + /** @var resource|null */ + private $tokenServer = null; private File $storage; public function setUp(): void { $this->tempDirSetUp(); - $this->config = new Config([ - 'PLATFORMSH_CLI_HOME' => (string) $this->tempDir, - 'PLATFORMSH_CLI_SESSION_ID' => 'refresh-test', - 'PLATFORMSH_CLI_API_DISABLE_CREDENTIAL_HELPERS' => '1', - ]); - $this->storage = new File($this->config->getSessionDir()); + $this->storage = new File($this->config()->getSessionDir()); } public function tearDown(): void { // Api caches the client statically: do not leak this session into other tests. (new \ReflectionProperty(Api::class, 'client'))->setValue(null, null); + if ($this->tokenServer !== null) { + \proc_terminate($this->tokenServer); + \proc_close($this->tokenServer); + } } public function testUsesTheStoredTokenWhenTheLockIsFree(): void { - $onRefreshStart = $this->loadStaleSession(); + $onRefreshStart = $this->loadStaleSession($this->config()); $token = $onRefreshStart('refresh-1'); @@ -53,41 +54,58 @@ public function testUsesTheStoredTokenWhenTheLockIsFree(): void public function testUsesTheStoredTokenAfterWaiting(): void { - $onRefreshStart = $this->loadStaleSession(); - - // Another process holds the lock. - $otherLock = new FileLock($this->config); - $this->assertNull($otherLock->acquireOrWait('refresh--' . $this->config->getSessionIdSlug())); + $config = $this->config(); + $onRefreshStart = $this->loadStaleSession($config); + $holder = $this->startLockHolder((string) $this->tempDir, 'refresh--' . $config->getSessionIdSlug(), 1); $token = $onRefreshStart('refresh-1'); + \proc_close($holder); $this->assertInstanceOf(AccessToken::class, $token); $this->assertSame('refresh-2', $token->getRefreshToken()); } - public function testAllowsARefreshWhenTheStoredTokenIsUnchanged(): void + public function testSavesTheRefreshedTokenBeforeReturning(): void { + $config = $this->config(['PLATFORMSH_CLI_OAUTH2_TOKEN_URL' => $this->startTokenServer()]); $this->storage->save('refresh-test', $this->sessionData('access-1', 'refresh-1')); - $onRefreshStart = $this->onRefreshStart(); + $onRefreshStart = $this->onRefreshStart($config); + + $token = $onRefreshStart('refresh-1'); - $this->assertNull($onRefreshStart('refresh-1')); + // The token must be saved before on_refresh_end releases the lock. + $this->assertInstanceOf(AccessToken::class, $token); + $this->assertSame('refresh-2', $token->getRefreshToken()); + $this->assertSame('refresh-2', $this->storage->load('refresh-test')['refreshToken'] ?? null); + } + + /** + * @param array $env + */ + private function config(array $env = []): Config + { + return new Config($env + [ + 'PLATFORMSH_CLI_HOME' => (string) $this->tempDir, + 'PLATFORMSH_CLI_SESSION_ID' => 'refresh-test', + 'PLATFORMSH_CLI_API_DISABLE_CREDENTIAL_HELPERS' => '1', + ]); } /** * Loads the session in memory, then simulates another process refreshing the token. */ - private function loadStaleSession(): callable + private function loadStaleSession(Config $config): callable { $this->storage->save('refresh-test', $this->sessionData('access-1', 'refresh-1')); - $onRefreshStart = $this->onRefreshStart(); + $onRefreshStart = $this->onRefreshStart($config); $this->storage->save('refresh-test', $this->sessionData('access-2', 'refresh-2')); return $onRefreshStart; } - private function onRefreshStart(): callable + private function onRefreshStart(Config $config): callable { - $api = new Api($this->config, new ArrayCache(), new BufferedOutput()); + $api = new Api($config, new ArrayCache(), new BufferedOutput()); $connector = $api->getClient(false, true)->getConnector(); $this->assertInstanceOf(Connector::class, $connector); $this->assertSame('refresh-1', $connector->getSession()->get('refreshToken')); @@ -98,6 +116,33 @@ private function onRefreshStart(): callable return $onRefreshStart; } + /** + * Starts a mock token endpoint, and returns its URL. + */ + private function startTokenServer(): string + { + $socket = \stream_socket_server('tcp://127.0.0.1:0'); + $this->assertIsResource($socket); + $address = (string) \stream_socket_get_name($socket, false); + \fclose($socket); + $port = (int) \substr($address, (int) \strrpos($address, ':') + 1); + + $router = \dirname(__DIR__) . '/data/oauth2-token-router.php'; + $process = \proc_open([\PHP_BINARY, '-S', $address, $router], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $this->assertIsResource($process); + $this->tokenServer = $process; + + for ($i = 0; $i < 50; $i++) { + $connection = @\fsockopen('127.0.0.1', $port); + if ($connection) { + \fclose($connection); + return 'http://' . $address . '/oauth2/token'; + } + \usleep(100_000); + } + $this->fail('The token server did not start'); + } + /** * @return array */ @@ -106,7 +151,7 @@ private function sessionData(string $accessToken, string $refreshToken): array return [ 'accessToken' => $accessToken, 'tokenType' => 'bearer', - 'expires' => time() + 900, + 'expires' => \time() + 900, 'refreshToken' => $refreshToken, ]; } diff --git a/legacy/tests/Service/FileLockTest.php b/legacy/tests/Service/FileLockTest.php index 2919ae31..2498ddac 100644 --- a/legacy/tests/Service/FileLockTest.php +++ b/legacy/tests/Service/FileLockTest.php @@ -8,10 +8,12 @@ use Platformsh\Cli\Service\Config; use Platformsh\Cli\Service\FileLock; use Platformsh\Cli\Tests\HasTempDirTrait; +use Platformsh\Cli\Tests\LockHolderTrait; class FileLockTest extends TestCase { use HasTempDirTrait; + use LockHolderTrait; private Config $config; @@ -34,7 +36,7 @@ public function testWaitsForAHeldLock(): void public function testLockIsFreedWhenTheHolderIsKilled(): void { - $holder = $this->startHolderProcess('test'); + $holder = $this->startLockHolder((string) $this->tempDir, 'test'); $this->assertSame('checked', (new FileLock($this->config))->acquireOrWait('test', null, fn(): string => 'checked')); \proc_terminate($holder, 9); @@ -42,26 +44,4 @@ public function testLockIsFreedWhenTheHolderIsKilled(): void $this->assertNull((new FileLock($this->config))->acquireOrWait('test', null, fn(): string => 'checked')); } - - /** - * Starts a process which acquires a lock and then sleeps. - * - * @return resource - */ - private function startHolderProcess(string $lockName) - { - $script = \sprintf( - 'require %s; $l = new %s(new %s(["PLATFORMSH_CLI_HOME" => %s])); $l->acquireOrWait(%s); echo "locked\n"; sleep(60);', - \var_export(\dirname(__DIR__, 2) . '/vendor/autoload.php', true), - FileLock::class, - Config::class, - \var_export($this->tempDir, true), - \var_export($lockName, true), - ); - $process = \proc_open([\PHP_BINARY, '-r', $script], [1 => ['pipe', 'w']], $pipes); - $this->assertIsResource($process); - $this->assertSame("locked\n", \fgets($pipes[1])); - - return $process; - } } diff --git a/legacy/tests/data/oauth2-token-router.php b/legacy/tests/data/oauth2-token-router.php new file mode 100644 index 00000000..b36db969 --- /dev/null +++ b/legacy/tests/data/oauth2-token-router.php @@ -0,0 +1,17 @@ + 'invalid_grant', 'error_description' => 'The refresh token is invalid.']); + return; +} +echo \json_encode([ + 'access_token' => 'access-2', + 'refresh_token' => 'refresh-2', + 'expires_in' => 900, + 'token_type' => 'bearer', +]); From f64d20ea6d04197a39c63874b776f0e501e5fc0d Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 30 Sep 2026 23:31:09 +0100 Subject: [PATCH 05/10] fix(legacy): do not refresh a token without the refresh lock 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 --- legacy/src/Service/Api.php | 4 ++++ legacy/src/Service/FileLock.php | 15 +++++++++++--- legacy/tests/Service/ApiRefreshLockTest.php | 22 +++++++++++++++++++-- legacy/tests/Service/FileLockTest.php | 14 +++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/legacy/src/Service/Api.php b/legacy/src/Service/Api.php index 4b264541..4cce2ae5 100644 --- a/legacy/src/Service/Api.php +++ b/legacy/src/Service/Api.php @@ -329,6 +329,10 @@ private function getConnectorOptions(): array if ($storedToken && $storedToken->getRefreshToken() !== $originalRefreshToken) { return $storedToken; } + // Without the lock, a refresh could reuse a token that another process is refreshing. + if (!$this->fileLock->isHeld($refreshLockName)) { + throw new \RuntimeException('Timed out waiting for another process to refresh the access token. Please try again.'); + } // Refresh and save the token before on_refresh_end releases the lock. $connector = $this->getClient(false)->getConnector(); diff --git a/legacy/src/Service/FileLock.php b/legacy/src/Service/FileLock.php index 2ea1ab75..ceb749b3 100644 --- a/legacy/src/Service/FileLock.php +++ b/legacy/src/Service/FileLock.php @@ -12,16 +12,17 @@ class FileLock { private readonly int $checkIntervalMs; - private readonly int $timeLimit; private readonly bool $disabled; /** @var array */ private array $locks = []; - public function __construct(private readonly Config $config) + /** + * @param int $timeLimit The maximum time to wait for a lock, in seconds. + */ + public function __construct(private readonly Config $config, private readonly int $timeLimit = 30) { $this->checkIntervalMs = 500; - $this->timeLimit = 30; $this->disabled = $this->config->getBool('api.disable_locks'); } @@ -72,6 +73,14 @@ public function acquireOrWait(string $lockName, ?callable $onWait = null, ?calla return null; } + /** + * Checks whether this process holds a lock, or locks are disabled. + */ + public function isHeld(string $lockName): bool + { + return $this->disabled || isset($this->locks[$lockName]); + } + /** * Releases a lock that was created by acquireOrWait(). */ diff --git a/legacy/tests/Service/ApiRefreshLockTest.php b/legacy/tests/Service/ApiRefreshLockTest.php index dc78c118..9a12f229 100644 --- a/legacy/tests/Service/ApiRefreshLockTest.php +++ b/legacy/tests/Service/ApiRefreshLockTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Platformsh\Cli\Service\Api; use Platformsh\Cli\Service\Config; +use Platformsh\Cli\Service\FileLock; use Platformsh\Cli\Tests\HasTempDirTrait; use Platformsh\Cli\Tests\LockHolderTrait; use Platformsh\Client\Connection\Connector; @@ -79,6 +80,23 @@ public function testSavesTheRefreshedTokenBeforeReturning(): void $this->assertSame('refresh-2', $this->storage->load('refresh-test')['refreshToken'] ?? null); } + public function testFailsWithoutRefreshingAfterTimingOut(): void + { + // The token URL is unreachable, so any refresh attempt fails differently. + $config = $this->config(['PLATFORMSH_CLI_OAUTH2_TOKEN_URL' => 'http://127.0.0.1:1/oauth2/token']); + $this->storage->save('refresh-test', $this->sessionData('access-1', 'refresh-1')); + $onRefreshStart = $this->onRefreshStart($config, new FileLock($config, 1)); + $holder = $this->startLockHolder((string) $this->tempDir, 'refresh--' . $config->getSessionIdSlug()); + + try { + $this->expectExceptionMessage('Timed out waiting for another process to refresh the access token'); + $onRefreshStart('refresh-1'); + } finally { + \proc_terminate($holder, 9); + \proc_close($holder); + } + } + /** * @param array $env */ @@ -103,9 +121,9 @@ private function loadStaleSession(Config $config): callable return $onRefreshStart; } - private function onRefreshStart(Config $config): callable + private function onRefreshStart(Config $config, ?FileLock $fileLock = null): callable { - $api = new Api($config, new ArrayCache(), new BufferedOutput()); + $api = new Api($config, new ArrayCache(), new BufferedOutput(), null, null, $fileLock); $connector = $api->getClient(false, true)->getConnector(); $this->assertInstanceOf(Connector::class, $connector); $this->assertSame('refresh-1', $connector->getSession()->get('refreshToken')); diff --git a/legacy/tests/Service/FileLockTest.php b/legacy/tests/Service/FileLockTest.php index 2498ddac..c5aeeaaa 100644 --- a/legacy/tests/Service/FileLockTest.php +++ b/legacy/tests/Service/FileLockTest.php @@ -34,6 +34,20 @@ public function testWaitsForAHeldLock(): void $this->assertNull((new FileLock($this->config))->acquireOrWait('test', null, fn(): string => 'checked')); } + public function testIsHeldOnlyWhenAcquired(): void + { + $holder = new FileLock($this->config); + $holder->acquireOrWait('test'); + $this->assertTrue($holder->isHeld('test')); + + $waiter = new FileLock($this->config, 1); + $this->assertNull($waiter->acquireOrWait('test')); + $this->assertFalse($waiter->isHeld('test')); + + $holder->release('test'); + $this->assertFalse($holder->isHeld('test')); + } + public function testLockIsFreedWhenTheHolderIsKilled(): void { $holder = $this->startLockHolder((string) $this->tempDir, 'test'); From 0c99301414998ef9151569f6fdfadff8e109b647 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 30 Sep 2026 23:41:16 +0100 Subject: [PATCH 06/10] fix(legacy): hold the refresh lock until the middleware saves the token 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 --- legacy/src/Service/Api.php | 34 ++++++++++++++++++--- legacy/src/Session/SessionConnector.php | 32 +++++++++++++++++++ legacy/tests/Service/ApiRefreshLockTest.php | 30 ++++++++++++++---- 3 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 legacy/src/Session/SessionConnector.php diff --git a/legacy/src/Service/Api.php b/legacy/src/Service/Api.php index 4cce2ae5..dbbc52f4 100644 --- a/legacy/src/Service/Api.php +++ b/legacy/src/Service/Api.php @@ -56,6 +56,7 @@ use Platformsh\Client\Session\Session; use Platformsh\Client\Session\SessionInterface; use Platformsh\Cli\Session\FileStorage; +use Platformsh\Cli\Session\SessionConnector; use Platformsh\Client\Session\Storage\SessionStorageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -130,6 +131,18 @@ class Api */ private ?string $storedSessionId = null; + /** + * Whether a refreshed token is waiting to be saved, while holding the refresh lock. + */ + private bool $refreshPendingSave = false; + + /** + * Releases the refresh lock after a refreshed token is saved. + * + * @see Api::getConnectorOptions() + */ + private ?\Closure $onTokenSaved = null; + /** * Sets whether we are currently verifying login using a test request. */ @@ -327,6 +340,7 @@ private function getConnectorOptions(): array // Refresh tokens are single-use, so use the stored token if another process has refreshed it. $storedToken = $this->loadStoredToken(); if ($storedToken && $storedToken->getRefreshToken() !== $originalRefreshToken) { + $this->refreshPendingSave = true; return $storedToken; } // Without the lock, a refresh could reuse a token that another process is refreshing. @@ -334,7 +348,6 @@ private function getConnectorOptions(): array throw new \RuntimeException('Timed out waiting for another process to refresh the access token. Please try again.'); } - // Refresh and save the token before on_refresh_end releases the lock. $connector = $this->getClient(false)->getConnector(); if (!$connector instanceof Connector) { throw new \LogicException('Unexpected connector type'); @@ -345,11 +358,21 @@ private function getConnectorOptions(): array if (!$token instanceof AccessToken) { throw new \LogicException('Unexpected access token type'); } - $connector->saveToken($token); + $this->refreshPendingSave = true; return $token; }; + // After a successful refresh, the middleware saves the token after + // on_refresh_end, so the lock is released when the token is saved. $connectorOptions['on_refresh_end'] = function () use ($refreshLockName): void { - $this->fileLock->release($refreshLockName); + if (!$this->refreshPendingSave) { + $this->fileLock->release($refreshLockName); + } + }; + $this->onTokenSaved = function () use ($refreshLockName): void { + if ($this->refreshPendingSave) { + $this->refreshPendingSave = false; + $this->fileLock->release($refreshLockName); + } }; $connectorOptions['on_refresh_error'] = fn(IdentityProviderException $e): ?AccessToken => $this->onRefreshError($e); @@ -610,7 +633,10 @@ public function getClient(bool $autoLogin = true, bool $reset = false): Platform } } - $connector = new Connector($options, $session); + $connector = new SessionConnector($options, $session); + if ($this->onTokenSaved !== null) { + $connector->setOnTokenSaved($this->onTokenSaved); + } self::$client = new PlatformClient($connector); diff --git a/legacy/src/Session/SessionConnector.php b/legacy/src/Session/SessionConnector.php new file mode 100644 index 00000000..c0cd3a58 --- /dev/null +++ b/legacy/src/Session/SessionConnector.php @@ -0,0 +1,32 @@ +onTokenSaved = \Closure::fromCallable($callback); + } + + public function saveToken(AccessTokenInterface $token): void + { + try { + parent::saveToken($token); + } finally { + if ($this->onTokenSaved !== null) { + ($this->onTokenSaved)(); + } + } + } +} diff --git a/legacy/tests/Service/ApiRefreshLockTest.php b/legacy/tests/Service/ApiRefreshLockTest.php index 9a12f229..335db20d 100644 --- a/legacy/tests/Service/ApiRefreshLockTest.php +++ b/legacy/tests/Service/ApiRefreshLockTest.php @@ -66,18 +66,31 @@ public function testUsesTheStoredTokenAfterWaiting(): void $this->assertSame('refresh-2', $token->getRefreshToken()); } - public function testSavesTheRefreshedTokenBeforeReturning(): void + public function testHoldsTheLockUntilTheTokenIsSaved(): void { $config = $this->config(['PLATFORMSH_CLI_OAUTH2_TOKEN_URL' => $this->startTokenServer()]); $this->storage->save('refresh-test', $this->sessionData('access-1', 'refresh-1')); - $onRefreshStart = $this->onRefreshStart($config); + $connector = $this->connector($config); + $lockName = 'refresh--' . $config->getSessionIdSlug(); - $token = $onRefreshStart('refresh-1'); + ['on_refresh_start' => $onRefreshStart, 'on_refresh_end' => $onRefreshEnd] = $connector->getConfig(); + $this->assertIsCallable($onRefreshStart); + $this->assertIsCallable($onRefreshEnd); - // The token must be saved before on_refresh_end releases the lock. + // The middleware calls on_refresh_end, and then saves the token. + $token = $onRefreshStart('refresh-1'); + $onRefreshEnd('refresh-1'); $this->assertInstanceOf(AccessToken::class, $token); $this->assertSame('refresh-2', $token->getRefreshToken()); + + $otherProcess = new FileLock($config, 1); + $otherProcess->acquireOrWait($lockName); + $this->assertFalse($otherProcess->isHeld($lockName)); + + $connector->saveToken($token); $this->assertSame('refresh-2', $this->storage->load('refresh-test')['refreshToken'] ?? null); + $otherProcess->acquireOrWait($lockName); + $this->assertTrue($otherProcess->isHeld($lockName)); } public function testFailsWithoutRefreshingAfterTimingOut(): void @@ -121,14 +134,19 @@ private function loadStaleSession(Config $config): callable return $onRefreshStart; } - private function onRefreshStart(Config $config, ?FileLock $fileLock = null): callable + private function connector(Config $config, ?FileLock $fileLock = null): Connector { $api = new Api($config, new ArrayCache(), new BufferedOutput(), null, null, $fileLock); $connector = $api->getClient(false, true)->getConnector(); $this->assertInstanceOf(Connector::class, $connector); $this->assertSame('refresh-1', $connector->getSession()->get('refreshToken')); - $onRefreshStart = $connector->getConfig()['on_refresh_start']; + return $connector; + } + + private function onRefreshStart(Config $config, ?FileLock $fileLock = null): callable + { + $onRefreshStart = $this->connector($config, $fileLock)->getConfig()['on_refresh_start']; $this->assertIsCallable($onRefreshStart); return $onRefreshStart; From c667ee43bcc9096ea46ad6b2fdf68fba4b0fe3cd Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 30 Sep 2026 23:49:13 +0100 Subject: [PATCH 07/10] fix(legacy): require the refresh lock before using a stored token 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 --- legacy/src/Service/Api.php | 9 +++++---- legacy/tests/Service/ApiRefreshLockTest.php | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/legacy/src/Service/Api.php b/legacy/src/Service/Api.php index dbbc52f4..af95db7a 100644 --- a/legacy/src/Service/Api.php +++ b/legacy/src/Service/Api.php @@ -337,16 +337,17 @@ private function getConnectorOptions(): array $this->stdErr->writeln('Waiting for token refresh lock', OutputInterface::VERBOSITY_VERBOSE); }); + // Without the lock, the token could be refreshed or saved over another process's newer token. + if (!$this->fileLock->isHeld($refreshLockName)) { + throw new \RuntimeException('Timed out waiting for another process to refresh the access token. Please try again.'); + } + // Refresh tokens are single-use, so use the stored token if another process has refreshed it. $storedToken = $this->loadStoredToken(); if ($storedToken && $storedToken->getRefreshToken() !== $originalRefreshToken) { $this->refreshPendingSave = true; return $storedToken; } - // Without the lock, a refresh could reuse a token that another process is refreshing. - if (!$this->fileLock->isHeld($refreshLockName)) { - throw new \RuntimeException('Timed out waiting for another process to refresh the access token. Please try again.'); - } $connector = $this->getClient(false)->getConnector(); if (!$connector instanceof Connector) { diff --git a/legacy/tests/Service/ApiRefreshLockTest.php b/legacy/tests/Service/ApiRefreshLockTest.php index 335db20d..de1673c2 100644 --- a/legacy/tests/Service/ApiRefreshLockTest.php +++ b/legacy/tests/Service/ApiRefreshLockTest.php @@ -6,6 +6,7 @@ use Doctrine\Common\Cache\ArrayCache; use League\OAuth2\Client\Token\AccessToken; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Platformsh\Cli\Service\Api; use Platformsh\Cli\Service\Config; @@ -93,12 +94,28 @@ public function testHoldsTheLockUntilTheTokenIsSaved(): void $this->assertTrue($otherProcess->isHeld($lockName)); } - public function testFailsWithoutRefreshingAfterTimingOut(): void + /** + * @return array + */ + public static function timeoutCases(): array + { + return [ + 'unchanged stored token' => [false], + // The lock holder may be about to save an even newer token. + 'newer stored token' => [true], + ]; + } + + #[DataProvider('timeoutCases')] + public function testFailsAfterTimingOut(bool $storedTokenChanged): void { // The token URL is unreachable, so any refresh attempt fails differently. $config = $this->config(['PLATFORMSH_CLI_OAUTH2_TOKEN_URL' => 'http://127.0.0.1:1/oauth2/token']); $this->storage->save('refresh-test', $this->sessionData('access-1', 'refresh-1')); $onRefreshStart = $this->onRefreshStart($config, new FileLock($config, 1)); + if ($storedTokenChanged) { + $this->storage->save('refresh-test', $this->sessionData('access-2', 'refresh-2')); + } $holder = $this->startLockHolder((string) $this->tempDir, 'refresh--' . $config->getSessionIdSlug()); try { From 565a780ad5ffa8d9125974b42b3e00c84d40f3a5 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 1 Oct 2026 01:17:40 +0100 Subject: [PATCH 08/10] chore(deps): update platformsh/client and platformsh/oauth2 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 --- legacy/composer.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/legacy/composer.lock b/legacy/composer.lock index ad2cb224..972b9c8e 100644 --- a/legacy/composer.lock +++ b/legacy/composer.lock @@ -794,12 +794,12 @@ "source": { "type": "git", "url": "https://github.com/platformsh/platformsh-client-php.git", - "reference": "ae46040164a44efaeec15758ec4220e3cbf3e3fd" + "reference": "a263eb88563b3aeb1f9fdcae627144e2acb51e30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/ae46040164a44efaeec15758ec4220e3cbf3e3fd", - "reference": "ae46040164a44efaeec15758ec4220e3cbf3e3fd", + "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/a263eb88563b3aeb1f9fdcae627144e2acb51e30", + "reference": "a263eb88563b3aeb1f9fdcae627144e2acb51e30", "shasum": "" }, "require": { @@ -835,7 +835,7 @@ "issues": "https://github.com/platformsh/platformsh-client-php/issues", "source": "https://github.com/platformsh/platformsh-client-php/tree/3.x" }, - "time": "2026-08-07T13:05:25+00:00" + "time": "2026-10-01T00:14:21+00:00" }, { "name": "platformsh/console-form", @@ -883,16 +883,16 @@ }, { "name": "platformsh/oauth2", - "version": "1.0.0-beta3", + "version": "1.0.0-beta4", "source": { "type": "git", "url": "https://github.com/platformsh/platformsh-oauth2-php.git", - "reference": "3c0e12549850837a827ca432a50aa052e3cab250" + "reference": "38fd5e7941247ea183a336add0ab993ae7e7422f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/platformsh/platformsh-oauth2-php/zipball/3c0e12549850837a827ca432a50aa052e3cab250", - "reference": "3c0e12549850837a827ca432a50aa052e3cab250", + "url": "https://api.github.com/repos/platformsh/platformsh-oauth2-php/zipball/38fd5e7941247ea183a336add0ab993ae7e7422f", + "reference": "38fd5e7941247ea183a336add0ab993ae7e7422f", "shasum": "" }, "require": { @@ -921,9 +921,9 @@ "description": "Platform.sh OAuth2 client", "support": { "issues": "https://github.com/platformsh/platformsh-oauth2-php/issues", - "source": "https://github.com/platformsh/platformsh-oauth2-php/tree/1.0.0-beta3" + "source": "https://github.com/platformsh/platformsh-oauth2-php/tree/1.0.0-beta4" }, - "time": "2024-11-25T19:19:41+00:00" + "time": "2026-10-01T00:14:08+00:00" }, { "name": "psr/container", From ba126219078913a206152b25b6b4d6e4a36e4b98 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 1 Oct 2026 01:20:48 +0100 Subject: [PATCH 09/10] refactor(legacy): remove token refresh workarounds fixed upstream 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 --- legacy/src/Service/Api.php | 83 ++++----------------- legacy/src/Session/FileStorage.php | 40 ---------- legacy/src/Session/SessionConnector.php | 32 -------- legacy/tests/Service/ApiRefreshLockTest.php | 60 +++------------ legacy/tests/data/oauth2-token-router.php | 17 ----- 5 files changed, 23 insertions(+), 209 deletions(-) delete mode 100644 legacy/src/Session/FileStorage.php delete mode 100644 legacy/src/Session/SessionConnector.php delete mode 100644 legacy/tests/data/oauth2-token-router.php diff --git a/legacy/src/Service/Api.php b/legacy/src/Service/Api.php index af95db7a..6880cf30 100644 --- a/legacy/src/Service/Api.php +++ b/legacy/src/Service/Api.php @@ -22,7 +22,6 @@ use GuzzleHttp\Psr7\UriResolver; use GuzzleHttp\Utils; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; -use League\OAuth2\Client\Grant\RefreshToken; use League\OAuth2\Client\Token\AccessToken; use Platformsh\Cli\CredentialHelper\KeyringUnavailableException; use Platformsh\Cli\CredentialHelper\Manager; @@ -55,8 +54,7 @@ use Platformsh\Client\PlatformClient; use Platformsh\Client\Session\Session; use Platformsh\Client\Session\SessionInterface; -use Platformsh\Cli\Session\FileStorage; -use Platformsh\Cli\Session\SessionConnector; +use Platformsh\Client\Session\Storage\File; use Platformsh\Client\Session\Storage\SessionStorageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -126,23 +124,6 @@ class Api */ private ?SessionStorageInterface $sessionStorage = null; - /** - * The ID of the session in persistent storage, if any. - */ - private ?string $storedSessionId = null; - - /** - * Whether a refreshed token is waiting to be saved, while holding the refresh lock. - */ - private bool $refreshPendingSave = false; - - /** - * Releases the refresh lock after a refreshed token is saved. - * - * @see Api::getConnectorOptions() - */ - private ?\Closure $onTokenSaved = null; - /** * Sets whether we are currently verifying login using a test request. */ @@ -331,49 +312,33 @@ private function getConnectorOptions(): array // Acquire a lock to prevent tokens being refreshed at the same time in // different CLI processes. $refreshLockName = 'refresh--' . $this->config->getSessionIdSlug(); - $connectorOptions['on_refresh_start'] = function (string $originalRefreshToken) use ($refreshLockName): AccessToken { + $connectorOptions['on_refresh_start'] = function (string $originalRefreshToken) use ($refreshLockName): ?AccessToken { $this->io->debug('Refreshing access token'); $this->fileLock->acquireOrWait($refreshLockName, function (): void { $this->stdErr->writeln('Waiting for token refresh lock', OutputInterface::VERBOSITY_VERBOSE); }); - // Without the lock, the token could be refreshed or saved over another process's newer token. if (!$this->fileLock->isHeld($refreshLockName)) { throw new \RuntimeException('Timed out waiting for another process to refresh the access token. Please try again.'); } // Refresh tokens are single-use, so use the stored token if another process has refreshed it. - $storedToken = $this->loadStoredToken(); + $session = $this->getClient(false)->getConnector()->getSession(); + try { + $session->reload(); + } catch (\RuntimeException $e) { + throw $this->convertStorageException($e); + } + $storedToken = $this->tokenFromSession($session); if ($storedToken && $storedToken->getRefreshToken() !== $originalRefreshToken) { - $this->refreshPendingSave = true; return $storedToken; } - $connector = $this->getClient(false)->getConnector(); - if (!$connector instanceof Connector) { - throw new \LogicException('Unexpected connector type'); - } - $token = $connector->getOAuth2Provider()->getAccessToken(new RefreshToken(), [ - 'refresh_token' => $originalRefreshToken, - ]); - if (!$token instanceof AccessToken) { - throw new \LogicException('Unexpected access token type'); - } - $this->refreshPendingSave = true; - return $token; + // The middleware refreshes the token, and saves it before calling on_refresh_end. + return null; }; - // After a successful refresh, the middleware saves the token after - // on_refresh_end, so the lock is released when the token is saved. $connectorOptions['on_refresh_end'] = function () use ($refreshLockName): void { - if (!$this->refreshPendingSave) { - $this->fileLock->release($refreshLockName); - } - }; - $this->onTokenSaved = function () use ($refreshLockName): void { - if ($this->refreshPendingSave) { - $this->refreshPendingSave = false; - $this->fileLock->release($refreshLockName); - } + $this->fileLock->release($refreshLockName); }; $connectorOptions['on_refresh_error'] = fn(IdentityProviderException $e): ?AccessToken => $this->onRefreshError($e); @@ -508,22 +473,6 @@ private function isApiTokenInvalid(mixed $body): bool return false; } - /** - * Loads the token from session storage, bypassing the in-memory session. - */ - private function loadStoredToken(): ?AccessToken - { - if (!isset($this->sessionStorage, $this->storedSessionId)) { - return null; - } - try { - $data = $this->sessionStorage->load($this->storedSessionId); - } catch (\RuntimeException $e) { - throw $this->convertStorageException($e); - } - return $this->tokenFromSession(new Session($this->storedSessionId, $data)); - } - /** * Converts a session storage error into a keyring error, if applicable. */ @@ -628,16 +577,12 @@ public function getClient(bool $autoLogin = true, bool $reset = false): Platform $this->io->debug('Loading session'); try { $session->setStorage($this->sessionStorage); - $this->storedSessionId = $sessionId; } catch (\RuntimeException $e) { throw $this->convertStorageException($e); } } - $connector = new SessionConnector($options, $session); - if ($this->onTokenSaved !== null) { - $connector->setOnTokenSaved($this->onTokenSaved); - } + $connector = new Connector($options, $session); self::$client = new PlatformClient($connector); @@ -691,7 +636,7 @@ private function initSessionStorage(): void // Fall back to file storage. $this->io->debug('Using filesystem for session storage'); - $this->sessionStorage = new FileStorage($this->config->getSessionDir()); + $this->sessionStorage = new File($this->config->getSessionDir()); } } diff --git a/legacy/src/Session/FileStorage.php b/legacy/src/Session/FileStorage.php deleted file mode 100644 index 805dc2cc..00000000 --- a/legacy/src/Session/FileStorage.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - public function load(string $sessionId): array - { - $filename = $this->getFilename($sessionId); - if (!\is_readable($filename)) { - return []; - } - $handle = \fopen($filename, 'r'); - if (!$handle) { - return []; - } - try { - \flock($handle, LOCK_SH); - $raw = \stream_get_contents($handle); - } finally { - \fclose($handle); - } - $data = $raw !== false ? \json_decode($raw, true) : null; - - return \is_array($data) ? $data : []; - } -} diff --git a/legacy/src/Session/SessionConnector.php b/legacy/src/Session/SessionConnector.php deleted file mode 100644 index c0cd3a58..00000000 --- a/legacy/src/Session/SessionConnector.php +++ /dev/null @@ -1,32 +0,0 @@ -onTokenSaved = \Closure::fromCallable($callback); - } - - public function saveToken(AccessTokenInterface $token): void - { - try { - parent::saveToken($token); - } finally { - if ($this->onTokenSaved !== null) { - ($this->onTokenSaved)(); - } - } - } -} diff --git a/legacy/tests/Service/ApiRefreshLockTest.php b/legacy/tests/Service/ApiRefreshLockTest.php index de1673c2..497b8a38 100644 --- a/legacy/tests/Service/ApiRefreshLockTest.php +++ b/legacy/tests/Service/ApiRefreshLockTest.php @@ -22,9 +22,6 @@ class ApiRefreshLockTest extends TestCase use HasTempDirTrait; use LockHolderTrait; - /** @var resource|null */ - private $tokenServer = null; - private File $storage; public function setUp(): void @@ -37,10 +34,6 @@ public function tearDown(): void { // Api caches the client statically: do not leak this session into other tests. (new \ReflectionProperty(Api::class, 'client'))->setValue(null, null); - if ($this->tokenServer !== null) { - \proc_terminate($this->tokenServer); - \proc_close($this->tokenServer); - } } public function testUsesTheStoredTokenWhenTheLockIsFree(): void @@ -67,29 +60,22 @@ public function testUsesTheStoredTokenAfterWaiting(): void $this->assertSame('refresh-2', $token->getRefreshToken()); } - public function testHoldsTheLockUntilTheTokenIsSaved(): void + public function testAllowsARefreshWhenTheStoredTokenIsUnchanged(): void { - $config = $this->config(['PLATFORMSH_CLI_OAUTH2_TOKEN_URL' => $this->startTokenServer()]); + $config = $this->config(); $this->storage->save('refresh-test', $this->sessionData('access-1', 'refresh-1')); - $connector = $this->connector($config); - $lockName = 'refresh--' . $config->getSessionIdSlug(); - - ['on_refresh_start' => $onRefreshStart, 'on_refresh_end' => $onRefreshEnd] = $connector->getConfig(); + ['on_refresh_start' => $onRefreshStart, 'on_refresh_end' => $onRefreshEnd] = $this->connector($config)->getConfig(); $this->assertIsCallable($onRefreshStart); $this->assertIsCallable($onRefreshEnd); - - // The middleware calls on_refresh_end, and then saves the token. - $token = $onRefreshStart('refresh-1'); - $onRefreshEnd('refresh-1'); - $this->assertInstanceOf(AccessToken::class, $token); - $this->assertSame('refresh-2', $token->getRefreshToken()); - + $lockName = 'refresh--' . $config->getSessionIdSlug(); $otherProcess = new FileLock($config, 1); + + // The middleware refreshes while the lock is held. + $this->assertNull($onRefreshStart('refresh-1')); $otherProcess->acquireOrWait($lockName); $this->assertFalse($otherProcess->isHeld($lockName)); - $connector->saveToken($token); - $this->assertSame('refresh-2', $this->storage->load('refresh-test')['refreshToken'] ?? null); + $onRefreshEnd('refresh-1'); $otherProcess->acquireOrWait($lockName); $this->assertTrue($otherProcess->isHeld($lockName)); } @@ -109,8 +95,7 @@ public static function timeoutCases(): array #[DataProvider('timeoutCases')] public function testFailsAfterTimingOut(bool $storedTokenChanged): void { - // The token URL is unreachable, so any refresh attempt fails differently. - $config = $this->config(['PLATFORMSH_CLI_OAUTH2_TOKEN_URL' => 'http://127.0.0.1:1/oauth2/token']); + $config = $this->config(); $this->storage->save('refresh-test', $this->sessionData('access-1', 'refresh-1')); $onRefreshStart = $this->onRefreshStart($config, new FileLock($config, 1)); if ($storedTokenChanged) { @@ -169,33 +154,6 @@ private function onRefreshStart(Config $config, ?FileLock $fileLock = null): cal return $onRefreshStart; } - /** - * Starts a mock token endpoint, and returns its URL. - */ - private function startTokenServer(): string - { - $socket = \stream_socket_server('tcp://127.0.0.1:0'); - $this->assertIsResource($socket); - $address = (string) \stream_socket_get_name($socket, false); - \fclose($socket); - $port = (int) \substr($address, (int) \strrpos($address, ':') + 1); - - $router = \dirname(__DIR__) . '/data/oauth2-token-router.php'; - $process = \proc_open([\PHP_BINARY, '-S', $address, $router], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); - $this->assertIsResource($process); - $this->tokenServer = $process; - - for ($i = 0; $i < 50; $i++) { - $connection = @\fsockopen('127.0.0.1', $port); - if ($connection) { - \fclose($connection); - return 'http://' . $address . '/oauth2/token'; - } - \usleep(100_000); - } - $this->fail('The token server did not start'); - } - /** * @return array */ diff --git a/legacy/tests/data/oauth2-token-router.php b/legacy/tests/data/oauth2-token-router.php deleted file mode 100644 index b36db969..00000000 --- a/legacy/tests/data/oauth2-token-router.php +++ /dev/null @@ -1,17 +0,0 @@ - 'invalid_grant', 'error_description' => 'The refresh token is invalid.']); - return; -} -echo \json_encode([ - 'access_token' => 'access-2', - 'refresh_token' => 'refresh-2', - 'expires_in' => 900, - 'token_type' => 'bearer', -]); From e351aca5f69cd3d65065027791aa3a3cfc5c5c7e Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 1 Oct 2026 01:27:22 +0100 Subject: [PATCH 10/10] chore(deps): require platformsh/oauth2 1.0.0-beta4 or later 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 --- legacy/composer.json | 2 +- legacy/composer.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/legacy/composer.json b/legacy/composer.json index 3380afe7..cca78fa2 100644 --- a/legacy/composer.json +++ b/legacy/composer.json @@ -21,7 +21,7 @@ "khill/php-duration": "^1.1", "symfony/polyfill-mbstring": "^1.19", "symfony/polyfill-iconv": "^1.19", - "platformsh/oauth2": "^1@beta", + "platformsh/oauth2": "^1.0.0-beta4", "giggsey/libphonenumber-for-php-lite": "^8.13", "symfony/var-dumper": "^7.3" }, diff --git a/legacy/composer.lock b/legacy/composer.lock index 972b9c8e..0b7b9b0b 100644 --- a/legacy/composer.lock +++ b/legacy/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7b82e9a5b71609c891c5da0ec002ec94", + "content-hash": "aeaacf3fb12f52bfbb228bd5541ea1fa", "packages": [ { "name": "cocur/slugify",