refactor: implement lazy token refreshing and persist Mastodon scopes
This commit is contained in:
parent
137cfb4f6e
commit
595510812c
5 changed files with 128 additions and 10 deletions
|
|
@ -183,6 +183,12 @@ public function callback(Request $request): View
|
|||
|
||||
$avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null;
|
||||
|
||||
// Mastodon returns the granted scopes in the token response as a
|
||||
// space-separated string. We persist them so the publisher can
|
||||
// verify required scopes (write:statuses, write:media) before
|
||||
// attempting to post.
|
||||
$grantedScopes = array_values(array_filter(explode(' ', (string) data_get($tokenData, 'scope', self::SCOPES))));
|
||||
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
|
|
@ -195,6 +201,7 @@ public function callback(Request $request): View
|
|||
'access_token' => $accessToken,
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $grantedScopes,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ protected function isTokenExpired(): Attribute
|
|||
protected function isTokenExpiringSoon(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addHour()),
|
||||
get: fn () => $this->token_expires_at && $this->token_expires_at->isBefore(now()->addMinutes(15)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,11 +20,39 @@ class ConnectionVerifier
|
|||
*/
|
||||
public function verify(SocialAccount $account): bool
|
||||
{
|
||||
// Refresh token if expired or expiring soon before verifying
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
// Hard-expired tokens cannot make API calls — refresh is mandatory.
|
||||
// For tokens that are still valid OR only "expiring soon", try the
|
||||
// verify endpoint FIRST with the current access_token. This avoids
|
||||
// rotating refresh_tokens unnecessarily — many providers (X v2,
|
||||
// LinkedIn, etc.) invalidate the previous refresh_token on each
|
||||
// refresh, so proactive refreshes during races cause false-positive
|
||||
// disconnects even though the access_token still works fine.
|
||||
if ($account->is_token_expired) {
|
||||
$this->refreshTokenIfNeeded($account);
|
||||
|
||||
return $this->callVerifyEndpoint($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->refreshTokenIfNeeded($account);
|
||||
} catch (TokenExpiredException) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->callVerifyEndpoint($account);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TokenExpiredException
|
||||
*/
|
||||
private function callVerifyEndpoint(SocialAccount $account): bool
|
||||
{
|
||||
return match ($account->platform) {
|
||||
Platform::LinkedIn => $this->verifyLinkedIn($account),
|
||||
Platform::LinkedInPage => $this->verifyLinkedInPage($account),
|
||||
|
|
@ -377,7 +405,13 @@ private function verifyX(SocialAccount $account): bool
|
|||
|
||||
private function verifyInstagram(SocialAccount $account): bool
|
||||
{
|
||||
$response = Http::get(config('trypost.platforms.instagram.graph_api').'/me', [
|
||||
// Basic Instagram tokens hit graph.instagram.com; Instagram via
|
||||
// Facebook Business uses a Facebook Page token, which only validates
|
||||
// against graph.facebook.com — using the wrong endpoint produces a
|
||||
// false-positive "token expired".
|
||||
$baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
$response = Http::get("{$baseUrl}/me", [
|
||||
'fields' => 'id,username',
|
||||
'access_token' => $account->access_token,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -263,7 +263,68 @@
|
|||
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
|
||||
});
|
||||
|
||||
test('refreshes token when expiring soon', function () {
|
||||
test('does NOT refresh proactively when token still works (lazy refresh)', function () {
|
||||
Http::fake([
|
||||
'api.linkedin.com/*' => Http::response(['sub' => '123'], 200),
|
||||
]);
|
||||
|
||||
// Token is "expiring soon" but access_token still works.
|
||||
$account = SocialAccount::factory()->linkedin()->create([
|
||||
'token_expires_at' => now()->addMinutes(10),
|
||||
'refresh_token' => 'old_refresh_token',
|
||||
]);
|
||||
|
||||
$verifier = new ConnectionVerifier;
|
||||
|
||||
expect($verifier->verify($account))->toBeTrue();
|
||||
|
||||
// Refresh endpoint must NOT have been called — verify worked without it.
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken'));
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'api.linkedin.com/rest/userinfo'));
|
||||
});
|
||||
|
||||
test('refreshes lazily on 401 then retries verify', function () {
|
||||
Http::fake([
|
||||
// First verify call returns 401, second (after refresh) returns 200.
|
||||
'api.linkedin.com/*' => Http::sequence()
|
||||
->push(['error' => 'unauthorized'], 401)
|
||||
->push(['sub' => '123'], 200),
|
||||
'www.linkedin.com/oauth/v2/accessToken' => Http::response([
|
||||
'access_token' => 'new_token',
|
||||
'refresh_token' => 'new_refresh_token',
|
||||
'expires_in' => 5184000,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->linkedin()->create([
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
'refresh_token' => 'old_refresh_token',
|
||||
]);
|
||||
|
||||
$verifier = new ConnectionVerifier;
|
||||
|
||||
expect($verifier->verify($account))->toBeTrue();
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken'));
|
||||
});
|
||||
|
||||
test('throws when verify returns 401 AND refresh also fails', function () {
|
||||
Http::fake([
|
||||
'api.linkedin.com/*' => Http::response(['error' => 'unauthorized'], 401),
|
||||
'www.linkedin.com/oauth/v2/accessToken' => Http::response(['error' => 'invalid_grant'], 400),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->linkedin()->create([
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
'refresh_token' => 'old_refresh_token',
|
||||
]);
|
||||
|
||||
$verifier = new ConnectionVerifier;
|
||||
|
||||
expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class);
|
||||
});
|
||||
|
||||
test('forces refresh when token is hard-expired', function () {
|
||||
Http::fake([
|
||||
'www.linkedin.com/oauth/v2/accessToken' => Http::response([
|
||||
'access_token' => 'new_token',
|
||||
|
|
@ -273,16 +334,29 @@
|
|||
'api.linkedin.com/*' => Http::response(['sub' => '123'], 200),
|
||||
]);
|
||||
|
||||
// Token expires in 30 minutes (less than 1 hour threshold)
|
||||
$account = SocialAccount::factory()->linkedin()->create([
|
||||
'token_expires_at' => now()->addMinutes(30),
|
||||
'token_expires_at' => now()->subMinutes(5),
|
||||
'refresh_token' => 'old_refresh_token',
|
||||
]);
|
||||
|
||||
$verifier = new ConnectionVerifier;
|
||||
$result = $verifier->verify($account);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
expect($verifier->verify($account))->toBeTrue();
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'linkedin.com/oauth/v2/accessToken'));
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken'));
|
||||
});
|
||||
|
||||
test('throws when refresh fails AND token is hard-expired', function () {
|
||||
Http::fake([
|
||||
'www.linkedin.com/oauth/v2/accessToken' => Http::response(['error' => 'invalid_grant'], 400),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->linkedin()->create([
|
||||
'token_expires_at' => now()->subMinutes(5),
|
||||
'refresh_token' => 'old_refresh_token',
|
||||
]);
|
||||
|
||||
$verifier = new ConnectionVerifier;
|
||||
|
||||
expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@
|
|||
'username' => 'testuser',
|
||||
'status' => Status::Connected->value,
|
||||
]);
|
||||
|
||||
$account = SocialAccount::where('platform', Platform::Mastodon->value)->first();
|
||||
expect($account->scopes)->toBe(['read:accounts', 'write:statuses', 'write:media']);
|
||||
});
|
||||
|
||||
test('mastodon callback fails with invalid state', function () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue