Stop publishers/analytics from rotating still-valid tokens
Every publisher and analytics service proactively refreshed the token when it was expired OR merely "expiring soon" (within 15 min), calling refreshToken() directly. For X (and other single-use-refresh providers) that rotated a perfectly valid access_token whenever an operation ran in the token's final 15 minutes — the same needless rotation that breaks the refresh_token chain and disconnects accounts. Narrow every pre-check to refresh only when the token is actually expired. A still-valid token is used as-is; if it expires mid-operation the existing reactive retry (PublishToSocialPlatform) refreshes and retries. - Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks. - Remove the now-unused `isTokenExpiringSoon` accessor (no references remain anywhere in the repo). - The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and the expired-token path are unchanged. Refs #126
This commit is contained in:
parent
2f4b974130
commit
3dd1804c8e
20 changed files with 61 additions and 35 deletions
|
|
@ -86,13 +86,6 @@ protected function isTokenExpired(): Attribute
|
|||
);
|
||||
}
|
||||
|
||||
protected function isTokenExpiringSoon(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addMinutes(15)),
|
||||
);
|
||||
}
|
||||
|
||||
protected function avatarUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$this->account = $postPlatform->socialAccount;
|
||||
$this->hasRetried = false;
|
||||
|
||||
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
|
||||
if ($this->account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($this->account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
|
||||
|
||||
// Refresh token if needed
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
|
|||
{
|
||||
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$account = $postPlatform->socialAccount;
|
||||
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -407,7 +407,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
|
|||
*/
|
||||
public function getBoards(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array
|
|||
*/
|
||||
private function fetchFresh(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
// Refresh token if expired or expiring soon
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
// Refresh only when the token is actually expired
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->is_token_expired) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -804,11 +804,12 @@ protected function processingPollSeconds(): int
|
|||
->toThrow(TokenExpiredException::class);
|
||||
});
|
||||
|
||||
test('linkedin publisher refreshes the token when it is expiring soon', function () {
|
||||
test('linkedin publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
|
||||
$this->socialAccount->update([
|
||||
'token_expires_at' => now()->addMinutes(5),
|
||||
'refresh_token' => 'refresh-token-123',
|
||||
]);
|
||||
$originalAccessToken = $this->socialAccount->access_token;
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
|
||||
|
|
@ -822,8 +823,11 @@ protected function processingPollSeconds(): int
|
|||
$result = $this->publisher->publish($this->postPlatform);
|
||||
|
||||
expect($result['id'])->toBe('urn:li:share:soon');
|
||||
|
||||
// A still-valid token is used as-is — the single-use refresh_token is not rotated.
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken'));
|
||||
$this->socialAccount->refresh();
|
||||
expect($this->socialAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
|
||||
});
|
||||
|
||||
test('linkedin publisher falls back to an empty id and null url when the post id header is missing', function () {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,31 @@
|
|||
});
|
||||
});
|
||||
|
||||
test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
|
||||
$this->socialAccount->update([
|
||||
'token_expires_at' => now()->addMinutes(5),
|
||||
'refresh_token' => 'original-refresh-token',
|
||||
]);
|
||||
$originalAccessToken = $this->socialAccount->access_token;
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '999']], 200),
|
||||
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
||||
'access_token' => 'should-not-be-used',
|
||||
'refresh_token' => 'should-not-be-used',
|
||||
'expires_in' => 7200,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// X single-use refresh tokens: a still-valid access_token must NOT be rotated.
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token'));
|
||||
$this->socialAccount->refresh();
|
||||
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
|
||||
expect($this->socialAccount->refresh_token)->toBe('original-refresh-token');
|
||||
});
|
||||
|
||||
test('x publisher uses bearer token authentication', function () {
|
||||
Http::fake([
|
||||
'https://api.x.com/2/tweets' => Http::response([
|
||||
|
|
|
|||
|
|
@ -143,11 +143,12 @@
|
|||
->toThrow(TokenExpiredException::class);
|
||||
});
|
||||
|
||||
test('x publisher refreshes token when expiring soon', function () {
|
||||
test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
|
||||
$this->socialAccount->update([
|
||||
'token_expires_at' => now()->addMinutes(5),
|
||||
'refresh_token' => 'refresh-token-123',
|
||||
]);
|
||||
$originalAccessToken = $this->socialAccount->access_token;
|
||||
|
||||
Http::fake([
|
||||
'*/2/oauth2/token' => Http::response([
|
||||
|
|
@ -164,8 +165,11 @@
|
|||
$result = $publisher->publish($this->postPlatform);
|
||||
|
||||
expect($result['id'])->toBe('tweet-123');
|
||||
|
||||
// A still-valid token is used as-is — the single-use refresh_token is not rotated.
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/2/oauth2/token'));
|
||||
$this->socialAccount->refresh();
|
||||
expect($this->socialAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
|
||||
});
|
||||
|
||||
test('x publisher handles 403 error as generic error', function () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue