feat: deauthorize strava
Laravel CI-CD / Tests Unitaires (push) Successful in 26s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m54s Details

This commit is contained in:
Leon Morival 2026-05-21 20:47:10 +02:00
parent 2f33919f8b
commit eda0ffd199
5 changed files with 131 additions and 3 deletions

View File

@ -8,6 +8,8 @@ use App\Http\Requests\StravaSyncRequest;
use App\Models\StravaConnection; use App\Models\StravaConnection;
use App\Models\WorkoutSessions; use App\Models\WorkoutSessions;
use App\Services\StravaClient; use App\Services\StravaClient;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -86,20 +88,47 @@ class StravaController extends Controller
public function status(Request $request): JsonResponse public function status(Request $request): JsonResponse
{ {
$connection = $request->user()->stravaConnection; $connection = $request->user()->stravaConnection;
$scopes = $connection?->scopes ?? [];
return response()->json([ return response()->json([
'data' => [ 'data' => [
'connected' => $connection !== null, 'connected' => $connection !== null,
'athleteId' => $connection?->athlete_id, 'athleteId' => $connection?->athlete_id,
'scopes' => $connection?->scopes ?? [], 'canReadActivities' => $connection !== null && $this->canReadActivities($scopes),
'requestedScopes' => $this->normalizeScopes(config('services.strava.scope', 'read,activity:read')),
'scopes' => $scopes,
'expiresAt' => $connection?->expires_at, 'expiresAt' => $connection?->expires_at,
], ],
]); ]);
} }
public function disconnect(Request $request): Response public function disconnect(Request $request, StravaClient $strava): Response|JsonResponse
{ {
$request->user()->stravaConnection()->delete(); $connection = $request->user()->stravaConnection;
if (! $connection) {
return response()->noContent();
}
try {
$strava->deauthorize($connection);
} catch (RequestException $exception) {
if ($exception->response->status() !== 401) {
report($exception);
return response()->json([
'message' => __('api.strava.disconnect_failed'),
], 502);
}
} catch (ConnectionException $exception) {
report($exception);
return response()->json([
'message' => __('api.strava.disconnect_failed'),
], 502);
}
$connection->delete();
return response()->noContent(); return response()->noContent();
} }

View File

@ -64,6 +64,20 @@ class StravaClient
return $connection->refresh(); return $connection->refresh();
} }
public function deauthorize(StravaConnection $connection): void
{
$accessToken = $this->accessToken($connection);
Http::baseUrl('https://www.strava.com')
->timeout(10)
->connectTimeout(3)
->retry([100, 500, 1000])
->post('/oauth/deauthorize?'.http_build_query([
'access_token' => $accessToken,
], '', '&', PHP_QUERY_RFC3986))
->throw();
}
public function activities(StravaConnection $connection, int $page, int $perPage): array public function activities(StravaConnection $connection, int $page, int $perPage): array
{ {
return Http::baseUrl('https://www.strava.com/api/v3') return Http::baseUrl('https://www.strava.com/api/v3')

View File

@ -34,6 +34,7 @@ return [
'refused' => 'Strava connection refused.', 'refused' => 'Strava connection refused.',
'missing_connection' => 'Connect Strava before syncing your workouts.', 'missing_connection' => 'Connect Strava before syncing your workouts.',
'missing_activity_scope' => 'The Strava connection must allow the activity:read or activity:read_all scope.', 'missing_activity_scope' => 'The Strava connection must allow the activity:read or activity:read_all scope.',
'disconnect_failed' => 'Unable to revoke Strava access right now. Please try again in a moment.',
], ],
'validation' => [ 'validation' => [
'image_required' => 'Add an image to analyze.', 'image_required' => 'Add an image to analyze.',

View File

@ -34,6 +34,7 @@ return [
'refused' => 'Connexion Strava refusée.', 'refused' => 'Connexion Strava refusée.',
'missing_connection' => 'Connecte Strava avant de synchroniser tes workouts.', 'missing_connection' => 'Connecte Strava avant de synchroniser tes workouts.',
'missing_activity_scope' => 'La connexion Strava doit autoriser le scope activity:read ou activity:read_all.', 'missing_activity_scope' => 'La connexion Strava doit autoriser le scope activity:read ou activity:read_all.',
'disconnect_failed' => "Impossible de retirer l'accès Strava pour le moment. Réessaie dans quelques instants.",
], ],
'validation' => [ 'validation' => [
'image_required' => 'Ajoute une image à analyser.', 'image_required' => 'Ajoute une image à analyser.',

View File

@ -96,6 +96,89 @@ it('stores strava tokens from an authorization code', function () {
]); ]);
}); });
it('revokes strava access before deleting the local connection', function () {
Http::preventStrayRequests();
Http::fake([
'https://www.strava.com/oauth/deauthorize*' => Http::response([
'access_token' => 'access-token',
]),
]);
$user = User::factory()->create();
StravaConnection::create([
'user_id' => $user->id,
'athlete_id' => 123456,
'access_token' => 'access-token',
'refresh_token' => 'refresh-token',
'expires_at' => now()->addHour(),
'scopes' => ['read', 'activity:read'],
'athlete' => ['id' => 123456],
]);
Sanctum::actingAs($user);
$this->deleteJson('/api/strava/connection')->assertNoContent();
expect($user->fresh()->stravaConnection)->toBeNull();
Http::assertSent(fn ($request): bool => $request->url() === 'https://www.strava.com/oauth/deauthorize?access_token=access-token');
});
it('deletes the local strava connection when strava already rejects the token', function () {
Http::preventStrayRequests();
Http::fake([
'https://www.strava.com/oauth/deauthorize*' => Http::response([
'message' => 'Authorization Error',
], 401),
]);
$user = User::factory()->create();
StravaConnection::create([
'user_id' => $user->id,
'athlete_id' => 123456,
'access_token' => 'access-token',
'refresh_token' => 'refresh-token',
'expires_at' => now()->addHour(),
'scopes' => ['read', 'activity:read'],
'athlete' => ['id' => 123456],
]);
Sanctum::actingAs($user);
$this->deleteJson('/api/strava/connection')->assertNoContent();
expect($user->fresh()->stravaConnection)->toBeNull();
});
it('keeps the local strava connection when strava revocation fails temporarily', function () {
Http::preventStrayRequests();
Http::fake([
'https://www.strava.com/oauth/deauthorize*' => Http::response([
'message' => 'temporary failure',
], 503),
]);
$user = User::factory()->create();
StravaConnection::create([
'user_id' => $user->id,
'athlete_id' => 123456,
'access_token' => 'access-token',
'refresh_token' => 'refresh-token',
'expires_at' => now()->addHour(),
'scopes' => ['read', 'activity:read'],
'athlete' => ['id' => 123456],
]);
Sanctum::actingAs($user);
$this
->deleteJson('/api/strava/connection')
->assertStatus(502)
->assertJsonPath('message', __('api.strava.disconnect_failed'));
expect($user->fresh()->stravaConnection)->not->toBeNull();
});
it('syncs strava activities into workouts', function () { it('syncs strava activities into workouts', function () {
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([