From ba8068f517d947345d8a4034c814835cfbf6b5cf Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 11:39:48 -0300 Subject: [PATCH] Recover from a lost-rotation race on the post-refresh verify too refreshThenVerify only guarded refreshToken(); the verify that followed was outside the try, so the sub-commit window where a lock-skipped refresh reloads a not-yet-persisted token surfaced as a 401 there and falsely disconnected a still-usable single-use-refresh_token account (X/LinkedIn). The refresh and the verify that follows it now share one recovery: on a TokenExpiredException, reload and, if a concurrent refresh has since persisted a fresh access_token, verify with it instead of giving up. --- app/Services/Social/ConnectionVerifier.php | 16 ++++++----- .../Social/ConnectionVerifierTest.php | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index b795eed0..547b78d6 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -47,11 +47,13 @@ public function verify(SocialAccount $account): bool /** * Refresh the token, then verify with the new one. * - * If the refresh is rejected (4xx) but a concurrent refresh has already - * rotated the account and persisted a fresh access_token, reload and - * verify with that token instead of giving up — X (and other providers - * that single-use their refresh_token) otherwise disconnect a still-usable - * account whenever two refreshes race and one loses the rotation. + * If either the refresh is rejected (4xx) or the verify that follows it hits + * a token a concurrent refresh has already rotated — including the sub-commit + * window where a lock-skipped refresh reloads a not-yet-persisted token — + * reload and, when another process has since persisted a fresh access_token, + * verify with that instead of giving up. X (and other providers that + * single-use their refresh_token) would otherwise disconnect a still-usable + * account whenever two refreshes race and one loses. * * @throws TokenExpiredException * @throws PlatformUnavailableException @@ -62,6 +64,8 @@ private function refreshThenVerify(SocialAccount $account, ?TokenExpiredExceptio try { $this->refreshToken($account); + + return $this->callVerifyEndpoint($account); } catch (TokenExpiredException $e) { $account->refresh(); @@ -71,8 +75,6 @@ private function refreshThenVerify(SocialAccount $account, ?TokenExpiredExceptio throw $original ?? $e; } - - return $this->callVerifyEndpoint($account); } /** diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 29a38b99..9d523ec5 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -481,6 +481,33 @@ && $request->header('Authorization')[0] === 'Bearer fresh-token'); }); +test('does not disconnect when the verify after a refresh 401s once but a fresh token is available', function () { + Http::fake([ + // Refresh succeeds and rotates the token... + config('trypost.platforms.x.api').'/oauth2/token' => Http::response([ + 'access_token' => 'refreshed-token', + 'refresh_token' => 'new-refresh', + 'expires_in' => 7200, + ], 200), + // ...but the verify that follows 401s once (the sub-commit window where a + // lock-skipped refresh reloads a not-yet-persisted token) before + // succeeding on the reload + retry. + config('trypost.platforms.x.api').'/users/me' => Http::sequence() + ->push(['error' => 'unauthorized'], 401) + ->push(['data' => ['id' => '123']], 200), + ]); + + $account = SocialAccount::factory()->x()->create([ + 'status' => Status::Connected, + 'access_token' => 'stale-token', + 'refresh_token' => 'old-refresh', + 'token_expires_at' => now()->subHour(), + ]); + + expect((new ConnectionVerifier)->verify($account))->toBeTrue(); + expect($account->fresh()->status)->toBe(Status::Connected); +}); + test('4xx during refresh keeps raising TokenExpiredException', function () { Http::fake([ config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),