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:
parent
94d0156ec0
commit
545a779848
4 changed files with 121 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue