Never leave Instagram/Threads with a null token expiry

A null token_expires_at drops an account from every refresh path (the
cron's whereNotNull filter and the is_token_expired / is_token_expiring_soon
checks all treat null as "nothing to do"), so the token silently lapses.
Threads could persist null two ways: the long-lived exchange failing at
connect (kept the ~1h short-lived token) — now fails the connect instead;
and a refresh response omitting expires_in — now defaults to 60 days for
both Instagram and Threads, matching the X refresh convention.
This commit is contained in:
Paulo Castellano 2026-07-03 10:33:26 -03:00
parent 94d0156ec0
commit 545a779848
4 changed files with 121 additions and 10 deletions

View file

@ -106,15 +106,18 @@ public function callback(Request $request): InertiaResponse
'access_token' => $shortLivedToken,
]);
$longLivedToken = $shortLivedToken;
$expiresIn = null;
if ($longLivedResponse->successful()) {
$longLivedData = $longLivedResponse->json();
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
$expiresIn = $longLivedData['expires_in'] ?? null;
if ($longLivedResponse->failed()) {
Log::error('Threads long-lived token exchange failed', [
'status' => $longLivedResponse->status(),
'body' => $longLivedResponse->body(),
]);
throw new \Exception('Failed to exchange long-lived token');
}
$longLivedData = $longLivedResponse->json();
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
$expiresIn = $longLivedData['expires_in'] ?? 5184000;
// Fetch user profile
$profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [
'access_token' => $longLivedToken,
@ -142,7 +145,7 @@ public function callback(Request $request): InertiaResponse
'avatar_url' => $avatarPath,
'access_token' => $longLivedToken,
'refresh_token' => null,
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
'token_expires_at' => now()->addSeconds($expiresIn),
'scopes' => $this->scopes,
'status' => Status::Connected,
'error_message' => null,

View file

@ -322,7 +322,7 @@ private function refreshThreadsToken(SocialAccount $account): void
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 5184000)),
]);
$account->refresh();
@ -341,7 +341,7 @@ private function refreshInstagramToken(SocialAccount $account): void
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 5184000)),
]);
$account->refresh();

View file

@ -284,6 +284,42 @@
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
});
test('threads refresh records a 60-day expiry when the response omits expires_in', function () {
Http::fake([
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([
'access_token' => 'new-threads-token',
], 200),
]);
$account = SocialAccount::factory()->threads()->create([
'token_expires_at' => now()->addHours(2),
]);
(new ConnectionVerifier)->refreshToken($account);
$account->refresh();
expect($account->token_expires_at)->not->toBeNull();
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
});
test('instagram refresh records a 60-day expiry when the response omits expires_in', function () {
Http::fake([
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
'access_token' => 'new-instagram-token',
], 200),
]);
$account = SocialAccount::factory()->instagram()->create([
'token_expires_at' => now()->addHours(2),
]);
(new ConnectionVerifier)->refreshToken($account);
$account->refresh();
expect($account->token_expires_at)->not->toBeNull();
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
});
test('does NOT refresh proactively when token still works (lazy refresh)', function () {
Http::fake([
'api.linkedin.com/*' => Http::response(['sub' => '123'], 200),

View file

@ -217,3 +217,75 @@
expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(1);
});
test('threads callback fails the connect when the long-lived token exchange fails', function () {
$state = bin2hex(random_bytes(16));
session([
'social_connect_workspace' => $this->workspace->id,
'threads_oauth_state' => $state,
]);
Http::fake([
config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([
'access_token' => 'short-lived-token',
'user_id' => '123456789',
], 200),
config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response('upstream error', 503),
]);
$response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [
'code' => 'test-auth-code',
'state' => $state,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.error_connecting')));
// A short-lived-only account would silently die within the hour and never be
// picked up by the refresh cron, so the connect must not persist one.
$this->assertDatabaseMissing('social_accounts', [
'workspace_id' => $this->workspace->id,
'platform' => Platform::Threads->value,
]);
});
test('threads callback records a 60-day expiry when the long-lived exchange omits expires_in', function () {
$state = bin2hex(random_bytes(16));
session([
'social_connect_workspace' => $this->workspace->id,
'threads_oauth_state' => $state,
]);
Http::fake([
config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([
'access_token' => 'short-lived-token',
'user_id' => '123456789',
], 200),
config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response([
'access_token' => 'long-lived-token',
], 200),
config('trypost.platforms.threads.graph_api').'/123456789*' => Http::response([
'id' => '123456789',
'username' => 'testuser',
'name' => 'Test User',
], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [
'code' => 'test-auth-code',
'state' => $state,
]));
$response->assertOk();
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$account = $this->workspace->socialAccounts()
->where('platform', Platform::Threads)
->first();
expect($account->token_expires_at)->not->toBeNull();
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
});