Add scopes to already connected account in Socialstream

Published at Dec 19, 2023

You have a project that uses Laravel Socialite via Socialstream (Socialite for Jetstream) and after that some of your users logged into their account, you realized that you were missing rights. This tutorial allows you to ask your user to give you additional rights!

This tutorial has been written for Socialstream 5.x

In my example, my users are logged in to Github but I need their permission to list their private repository. I knew I would eventually need this right, but I prefer to ask for more later when needed, so the user has more granular control over the right they grant to my application.

Updating Socialstream a bit

By default with SocialStream one of the actions is not published on your application, namely AuthenticateOAuthCallback.php

We don't need to rewrite the full action but we are gonna extend one of its functions. Here is the Action with alreadyAuthenticated() replaced with our own version. I highlighted and commented the changes. If you are working with a different or updated version, you may be able to replicate this tutorial.

Copied!
<?php
 
namespace App\Actions\Socialstream;
 
use App\Providers\RouteServiceProvider...
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\MessageBag;
use JoelButcher\Socialstream\ConnectedAccount;
use JoelButcher\Socialstream\Providers;
use Laravel\Jetstream\Jetstream;
use Laravel\Socialite\Contracts\User as ProviderUser
 
class AuthenticateOAuthCallback extends \JoelButcher\Socialstream\Actions\AuthenticateOAuthCallback
{
protected function alreadyAuthenticated(Authenticatable $user, ?ConnectedAccount $account, string $provider, ProviderUser $providerAccount): RedirectResponse
{
// Get the route
// While you are here, personalise this behaviour 
$route = match (true) {
str(session('socialstream.previous_url', ''))->endsWith('github_private') => route('projects.create.github_private'),
Route::has('profile.show') => route('profile.show'),
Route::has('dashboard') => route('dashboard'),
Route::has('home') => route('home'),
default => RouteServiceProvider::HOME
};
 
// Connect the account to the user, same as before
if (! $account) {
$this->createsConnectedAccounts->create($user, $provider, $providerAccount);
 
$status = __('You have successfully connected :provider to your account.', ['provider' => Providers::name($provider)]);
 
return class_exists(Jetstream::class)
? redirect()->to($route)->banner($status)
: redirect()->to($route)->with('status', $status);
}
 
// Extract condition. If it's not the same user, return an error as before.
$error = $account->user_id !== $user->id 
? __('This :Provider sign in account is already associated with another user. Please log in with that user or connect a different :Provider account.', ['provider' => Providers::name($provider)])
: __('This :Provider sign in account is already associated with your user.', ['provider' => Providers::name($provider)]);
 
if ($account->user_id !== $user->id) { 
$error = __('This :provider sign in account is already associated with another user. Please log in with that user or connect a different :provider account.', ['provider' => Providers::name($provider)]);
 
return class_exists(Jetstream::class)
? redirect()->to($route)->dangerBanner($error)
: redirect()->to($route)->withErrors((new MessageBag)->add('socialstream', $error));
 
// If its the same user, update the connected account with the new updated token 
$this->updatesConnectedAccounts->update($user, $account, $provider, $providerAccount);
$success = __('This :provider sign in account has been refreshed!', ['provider' => Providers::name($provider)]);
 
return class_exists(Jetstream::class)
? redirect()->to($route)->banner($success)
: redirect()->to($route);
}
}

Go to SocialstreamServiceProvider.php and add our own AuthenticateOAuthCallback

Copied!
<?php
namespace App\Providers;
 
use App\Actions\Socialstream\AuthenticateOAuthCallback
use App\Actions\Socialstream\CreateConnectedAccount...
use App\Actions\Socialstream\CreateUserFromProvider;
use App\Actions\Socialstream\GenerateRedirectForProvider;
use App\Actions\Socialstream\HandleInvalidState;
use App\Actions\Socialstream\ResolveSocialiteUser;
use App\Actions\Socialstream\UpdateConnectedAccount;
use Illuminate\Support\ServiceProvider;
use JoelButcher\Socialstream\Socialstream
 
class SocialstreamServiceProvider extends ServiceProvider
{
public function boot(): void
{
Socialstream::resolvesSocialiteUsersUsing(ResolveSocialiteUser::class);
Socialstream::createUsersFromProviderUsing(CreateUserFromProvider::class);
Socialstream::createConnectedAccountsUsing(CreateConnectedAccount::class);
Socialstream::updateConnectedAccountsUsing(UpdateConnectedAccount::class);
Socialstream::handlesInvalidStateUsing(HandleInvalidState::class);
Socialstream::generatesProvidersRedirectsUsing(GenerateRedirectForProvider::class);
Socialstream::authenticatesOAuthCallbackUsing(AuthenticateOAuthCallback::class); 
}
}

Checking the scopes

This part depends entirely on how you want to handle this in your application. You can add a new json column on connected_accounts to remember which scopes you already have. Let me know if you want a tutorial on this. Or you can check which scopes are available at runtime.

Here I only need to verify once if the Github Account have the scope I asked for. Here is a little function to achieve that:

Copied!
function checkGithubPrivateRepoScope(ConnectedAccount $github_connection): bool
{
$client = new \Github\Client();
$client->authenticate(config('services.github.client_id'), config('services.github.client_secret'), AuthMethod::CLIENT_ID);
$authorizations = $client
->authorizations()
->checkToken(config('services.github.client_id'), $github_connection->token);
 
if (! in_array('repo', $authorizations['scopes'])) {
return false;
}
 
return true;
}

Asking for new scopes

Now that we know we need more rights. We're gonna ask politely to the user if he want's to gives us more. For that you just have to make a link to the existing oauth.redirect route but with a new parameter that we will in GenerateRedirectForProvider

Copied!
<x-action-link href="{{ route('oauth.redirect', ['provider' => 'github', 'need_private_access' => true]) }}" class="flex gap-3" >
<x-socialstream-icons.github class="h-4 w-4"/>
{{ __('Authorize access to private repo'}}
</x-action-link>

Now to intercept this parameter, update GenerateRedirectForProvider.php to add the scope to the socialite request.

Copied!
<?php
namespace App\Actions\Socialstream;
 
+use Illuminate\Support\Facades\Request;
use JoelButcher\Socialstream\Contracts\GeneratesProviderRedirect;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
 
class GenerateRedirectForProvider implements GeneratesProviderRedirect
{
public function generate(string $provider): RedirectResponse
{
$scopes = [];
 
if ($provider == 'github') {
$scopes = array_merge($scopes, [
'read:user',
'user:email',
]);
 
// If the user clicked a button to have additional scope, add them to the array 
if (boolval(Request::get('need_private_access', false))) {
$scopes[] = 'repo';
}
}
 
return Socialite::driver($provider)
->scopes($scopes)
->redirect();
}
}

And voilĂ  !

When the user comes back, the updated token will be saved in the database and you will be able to list private repositories.

#jetstream #socialstream #github

Syntax highlighting provided by torchlight.dev