Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions app/Console/Commands/RefreshExpiringTokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,41 @@

namespace App\Console\Commands;

use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;

class RefreshExpiringTokens extends Command
{
protected $signature = 'social:refresh-expiring-tokens';

protected $description = 'Proactively refresh tokens expiring in the next 2 hours (or already expired)';
protected $description = 'Proactively refresh social tokens before they expire';

/**
* Rotating refresh_token platforms only need a short lead: verify() won't
* rotate a still-valid token, so we catch them right before or after expiry.
* Extension-model platforms (Instagram/Threads) can't be refreshed once
* expired, so they get a much wider lead to survive queue backlog.
*/
public function handle(): void
{
$count = 0;

SocialAccount::query()
->where('status', Status::Connected)
->whereNotNull('token_expires_at')
->where('token_expires_at', '<=', now()->addHours(2))
->where(function (Builder $query) {
$query->where(function (Builder $extension) {
$extension->whereIn('platform', Platform::accessTokenExtendingPlatformValues())
->where('token_expires_at', '<=', now()->addDay());
})->orWhere(function (Builder $rotating) {
$rotating->whereNotIn('platform', Platform::accessTokenExtendingPlatformValues())
->where('token_expires_at', '<=', now()->addMinutes(30));
});
})
->chunk(50, function ($accounts) use (&$count) {
foreach ($accounts as $account) {
RefreshSocialToken::dispatch($account);
Expand Down
54 changes: 54 additions & 0 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,60 @@ public function requiresContent(): bool
};
}

/**
* Whether this platform refreshes by extending the access_token itself
* (Instagram/Threads long-lived tokens) instead of exchanging a separate
* refresh_token. Extension-model tokens cannot be refreshed once expired,
* so they must be refreshed proactively while still valid — the opposite
* of rotating refresh_token platforms, which we avoid refreshing until
* they actually expire so we don't rotate a still-valid single-use token.
*/
public function extendsAccessTokenOnRefresh(): bool
{
return match ($this) {
self::Instagram, self::Threads => true,
default => false,
};
}

/**
* The `platform` column values of the platforms that refresh by extending
* their access token in place (Instagram and Threads — see
* extendsAccessTokenOnRefresh), for use in database whereIn/whereNotIn
* filters. Derived from extendsAccessTokenOnRefresh() so the two never drift.
*
* @return array<int, string>
*/
public static function accessTokenExtendingPlatformValues(): array
{
return array_values(array_map(
fn (self $platform): string => $platform->value,
array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()),
));
}

/**
* The token lifetime, in seconds, to assume when the provider's OAuth
* response omits expires_in. Each value is that network's own documented
* default:
*
* - X: a 2-hour access token.
* - Instagram / Threads: Meta's 60-day long-lived token.
*
* Networks that always return expires_in (LinkedIn, TikTok, YouTube,
* Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or
* whose tokens never expire (Facebook, Mastodon, Telegram, Discord) have no
* fallback here and return null.
*/
public function defaultTokenTtlSeconds(): ?int
{
return match ($this) {
self::X => 7200,
self::Instagram, self::Threads => 5184000,
default => null,
};
}

public function queue(): string
{
return 'social-'.$this->value;
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/Auth/InstagramController.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function callback(Request $request): InertiaResponse
$avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null;

// Calculate token expiration (long-lived tokens last 60 days)
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
$expiresIn = $socialUser->expiresIn ?? $this->platform->defaultTokenTtlSeconds();
$tokenExpiresAt = now()->addSeconds($expiresIn);

$workspace->socialAccounts()->updateOrCreate(
Expand Down
22 changes: 13 additions & 9 deletions app/Http/Controllers/Auth/ThreadsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use App\Services\Social\TokenRedactor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
Expand Down Expand Up @@ -90,7 +91,7 @@ public function callback(Request $request): InertiaResponse
if ($tokenResponse->failed()) {
Log::error('Threads token exchange failed', [
'status' => $tokenResponse->status(),
'body' => $tokenResponse->body(),
'body' => TokenRedactor::redact($tokenResponse->body()),
]);
throw new \Exception('Failed to exchange token');
}
Expand All @@ -106,15 +107,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' => TokenRedactor::redact($longLivedResponse->body()),
]);
throw new \Exception('Failed to exchange long-lived token');
}

$longLivedData = $longLivedResponse->json();
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
$expiresIn = $longLivedData['expires_in'] ?? $this->platform->defaultTokenTtlSeconds();

// Fetch user profile
$profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [
'access_token' => $longLivedToken,
Expand Down Expand Up @@ -142,7 +146,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,
Expand Down
9 changes: 8 additions & 1 deletion app/Jobs/RefreshSocialToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ public function __construct(public SocialAccount $account) {}
public function handle(ConnectionVerifier $verifier): void
{
try {
$verifier->refreshToken($this->account);
if ($this->account->platform->extendsAccessTokenOnRefresh()) {
// Instagram/Threads extend the long-lived token itself and
// can't be refreshed once expired, so extend it while it's
// still valid instead of waiting for it to fail.
$verifier->refreshToken($this->account);
} else {
$verifier->verify($this->account);
}
} catch (PlatformUnavailableException $e) {
Log::warning('Token refresh skipped: platform unavailable', [
'account_id' => $this->account->id,
Expand Down
13 changes: 13 additions & 0 deletions app/Models/SocialAccount.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ protected function isTokenExpiringSoon(): Attribute
);
}

/**
* Whether the token should be refreshed before use. Rotating-refresh-token
* platforms are only refreshed once actually expired, to avoid rotating a
* still-valid single-use refresh_token; extension-model platforms
* (Instagram/Threads) must be refreshed while still valid because their
* token can't be extended once expired.
*/
public function needsProactiveTokenRefresh(): bool
{
return $this->is_token_expired
|| ($this->platform->extendsAccessTokenOnRefresh() && $this->is_token_expiring_soon);
}

protected function avatarUrl(): Attribute
{
return Attribute::make(
Expand Down
2 changes: 1 addition & 1 deletion app/Services/Social/AbstractLinkedInPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($this->account);
}

Expand Down
2 changes: 1 addition & 1 deletion app/Services/Social/BlueskyPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
}

Expand Down
97 changes: 57 additions & 40 deletions app/Services/Social/ConnectionVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordClient;
use App\Services\Social\Meta\GraphError;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
Expand All @@ -31,23 +32,48 @@ public function verify(SocialAccount $account): bool
// refresh, so proactive refreshes during races cause false-positive
// disconnects even though the access_token still works fine.
if ($account->is_token_expired) {
$this->refreshToken($account);

return $this->callVerifyEndpoint($account);
return $this->refreshThenVerify($account);
}

try {
return $this->callVerifyEndpoint($account);
} catch (TokenExpiredException $e) {
// Verify returned 401: the access_token is actually invalid.
// Refresh and retry once with the new token.
try {
$this->refreshToken($account);
} catch (TokenExpiredException) {
throw $e;
}
return $this->refreshThenVerify($account, $e);
}
}

/**
* Refresh the token, then verify with the new one.
*
* 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
*/
private function refreshThenVerify(SocialAccount $account, ?TokenExpiredException $original = null): bool
{
$accessTokenBeforeRefresh = $account->access_token;

try {
$this->refreshToken($account);

return $this->callVerifyEndpoint($account);
} catch (TokenExpiredException $e) {
$account->refresh();

if ($account->access_token !== $accessTokenBeforeRefresh) {
return $this->callVerifyEndpoint($account);
}

throw $original ?? $e;
}
}

Expand Down Expand Up @@ -155,7 +181,7 @@ private function refreshXToken(SocialAccount $account): void
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
]);

$account->refresh();
Expand Down Expand Up @@ -288,37 +314,43 @@ private function refreshPinterestToken(SocialAccount $account): void
private function refreshThreadsToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]));
$response = TokenRefreshClient::for(Platform::Threads)->send(
fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]),
GraphError::indicatesInvalidToken(...),
);

$data = $response->json();
$newToken = data_get($data, 'access_token');

$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', $account->platform->defaultTokenTtlSeconds())),
]);

$account->refresh();
}

private function refreshInstagramToken(SocialAccount $account): void
{
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]));
$response = TokenRefreshClient::for(Platform::Instagram)->send(
fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]),
GraphError::indicatesInvalidToken(...),
);

$data = $response->json();
$newToken = data_get($data, 'access_token');

$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', $account->platform->defaultTokenTtlSeconds())),
]);

$account->refresh();
Expand Down Expand Up @@ -385,13 +417,8 @@ private function verifyInstagram(SocialAccount $account): bool

$body = $response->json() ?? [];

if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;

if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Instagram access token is invalid or expired');
}

return $response->successful();
Expand All @@ -406,13 +433,8 @@ private function verifyFacebook(SocialAccount $account): bool

$body = $response->json() ?? [];

if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;

if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Facebook access token is invalid or expired');
}

return $response->successful();
Expand All @@ -427,13 +449,8 @@ private function verifyThreads(SocialAccount $account): bool

$body = $response->json() ?? [];

if (isset($body['error'])) {
$errorCode = $body['error']['code'] ?? null;
$errorType = $body['error']['type'] ?? null;

if ($errorType === 'OAuthException' || $errorCode === 190) {
throw new TokenExpiredException('Threads access token is invalid or expired');
}
if (GraphError::indicatesInvalidToken($body)) {
throw new TokenExpiredException('Threads access token is invalid or expired');
}

return $response->successful();
Expand Down
Loading
Loading