Socialite Best Practices, a conversation

Hi guys :wave:

Looking to start a conversation on best practices when using Socialite, developing in a vacuum can be dangerous so I’d like this post to be a conversation.

This first post I’ve got a couple questions/topics I’d like to chat about, all focused on Laravel Socialite and extending to the 3rd party providers.

Authentication callback, Controller v. Closure

The first topic I’d like to pass by the community is whether you prefer handling the callback from the provider via a controller or within a route closure. I’ve included an example of my “handleProviderCallback()” method below.

// ProvidersController.php

namespace App\Http\Controllers\Auth;

use App\AccountService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;

class ProvidersController extends Controller
{
    public function redirectToProvider($provider){...}

    public function handleProviderCallback(AccountService $accountService, $provider)
    {
        if (request('error')) {
            abort(403, request('error_description'));
        }

        /**
         * Try getting the user from the provider, if it doesn't work have them login again.
         */
        try {
            $providerUser = Socialite::driver($provider)->user();
        } catch (\Throwable $th) {
            return redirect()->route('home');
        }

        /**
         * Use the account service to find or create the user to login.
         *
         * Note: the account service code is located below in another question.
         */
        $authUser = $accountService->findOrCreate($providerUser, $provider);

        Auth::login($authUser);

        return redirect()->intended('/');
    }
}

Account Service to Handle Finding or Creating a User

The second question is around utilizing an external class called the “AccountService” to find or create a user. I picked up this method from an article a couple of years ago and it’s something I think could be improved.

In a nutshell it handles returning an existing user if it already exists, returning an error if the email address associated with the provider user exists and then creating the new user and returning it.

// AccountService.php

namespace App;

use App\Models\LinkedProvider;
use App\Models\User;
use laravel\Socialite\Contracts\User as ProviderUser;

class AccountService
{
    public $user;

    public function findOrCreate(ProviderUser $providerUser, $provider)
    {
        $account = LinkedProvider::with('user')
            ->firstWhere([
                ['provider_name', '=', $provider],
                ['provider_id', '=', $providerUser->getId()],
            ]);

        if ($account && $account->has('user'))
        {
            return $account->user;
        }

        if (User::firstWhere('email', $providerUser->getEmail())) {
            abort(500, 'Nope, user already exists with that email.');
        }

        $this->user = User::create([
            'name' => $providerUser->getName(),
            'email' => $providerUser->getEmail(),
        ]);

        $this->user->linkedProviders()->create([
            'provider_name' => $provider,
            'provider_id' => $providerUser->getId(),
        ]);

        return $this->user;
    }
}

Looking forward to chatting and getting feedback on how I can improve this process.

Hi @alexjustesen ,

Thanks for reaching out! Definitely agree developing in a vacuum can be dangerous, I’d love to have some discussions on Laravel as well. It’s great to share ideas and practices!

Authentication callback, Controller v. Closure

I definitely prefer to handle all routes via a controller. I do this for multiple reasons.

First, It separates the route structure from the functionality. I can quickly add routes and find the route I’m looking for by handling all of their functionality in a controller. I even apply my middleware on a controller basis through the __construct() method. I’ve seen some debate about this being preferred or not. I prefer it because it keeps my route file clean and focuses the functionality within the controller.

Second, if you are applying any route caching, I don’t think (at least last time I looked) the cache driver supports routes handled with closures.

For our ID management platform (what you used to authenticate to this forum), I have a SocialAuthController.php that handles the callback from any social network that we have enabled:

class SocialAuthController extends Controller
{
    public function __construct()
    {

    }

    public function socialRedirect( $network )
    {
        ...
    }

    public function socialCallback( $network )
    {
        ...
    }
}

Account Service to Handle Finding or Creating a User

I’m glad to see this as a service! I definitely abstract all functionality into services to make for smaller controllers. My methodology is small route files (no closures). Controllers apply security (middleware, policies) and then direct the functionality off to a service. This way I can break down the logic into smaller components making it easier to maintain.

With Social authentication I find there are a ton of checks to take place before authenticating/registering a new user. By creating a service, you can break down all of the checks into smaller components and build up functionality in a maintainable fashion. I tend to follow a similar structure as your service where I perform the checks and return a response based on the outcome.

The only difference I have in my controllers is I try to send all functionality to a service and just return the result through the controller. So with the example above, I’d authenticate the user within the service if it passes and return the status code and redirect from the controller:

public function socialCallback( $network )
    {
        $socialUser = new ManageSocialUser( $network );
        $socialAuthRedirect = $socialUser->handleAuthentication();
        
        if( Auth::user()->profile_completed == null ){
            return redirect('/welcome');
        } else if( Session::has('nonce') ){
            $discourseSSO = new SSODiscourse( '', '' );
            return redirect( $discourseSSO->authenticateRegistration() );
        }else{
            return $socialAuthRedirect;
        }
    }

I pass all of the functionality to my ManageSocialUser service and then return the redirect from my controller. Preferably, I’d have even less in my controller than that, but sometimes it’s impossible.

Hope that helps a little bit! Let me know if you have any more questions too. I’d love to hear your feedback as well.

Thanks Dan, I do like your method of moving the all functionality to a service so the controller remains clean. That makes perfect sense from a support and extensibility standpoint and its clear I was 1/2 there. Def a place for me to improve.

My next thought I go back and forth on: If the email address from a provider (say your GH email) changes, do you update the existing one for that user or not, or do you even store it to begin with. Pros: ok cool the user doesn’t need to change an email. Cons: Maybe they don’t want the email changed and security?

1 Like

This is a great question. I have some UX thoughts but would love to hear @danpastori first :smiley:

For sure! I started doing that after reading Clean Code: Clean Code: A Handbook of Agile Software Craftsmanship: Robert C. Martin: 9780132350884: Amazon.com: Books. One of the most valuable books I’ve ever read (or at least 3/4 read I’m still reading lol). It applies to any language and goes through some of the bigger concepts of structuring apps in general. Once I started moving everything out of the controller and into services it made the code so much easier to maintain and read. I could break up code into smaller classes and reuse it in multiple places within the app.

As with the email address, this is a unique scenario. I’ve touched a few times near this, so I’ll share thoughts, but open to others as well. We usually use the email address right away on user sign up to send a validation email. With Social auth, that’s not the case, the user is already not a bot since they signed up with a social account. This removes the need for managing the email address from the social provider.

In all of our social apps, we do not update the email when the user authenticates from the social provider. We default the email to what was provided upon registration and allow them to change it in a settings page if needed. With this platform (Server Side Up ID + Discourse), we check to see if the email is already registered with an account and do not let the user re-register. If the user were to change their email address, they could re-register if their current account email does not match the email of the new attempted registration. Some apps, and I haven’t done this yet, allow for multiple social accounts to be bound together or to merge a UN + PW auth method WITH a social account. In that case, I’d definitely do some sort of pivot table and keep track of every email address entered.

@jaydrogers , What are your UX thoughts as well? This could definitely play into these scenarios.

LOL, I learned so much about how our own system works. Thanks @danpastori! :laughing:

First of all, I don’t know how Laravel Socialite works… so consider me dangerous.

I am torn on “what’s best for UX” but here are my thoughts:

Sign in with Social & Store Email

  • Prevents user from signing in with multiple social auth accounts (assuming the emails are the same)
  • You can alert the user of which account they signed up with (like we did for id.serversideup.net)
  • Emails are easily accessible by a developer if you need to send a message out

Do not store the email with social auth

  • User can manage everything in one place (their social account)
  • Everything is bound to the social account (not the email)
  • Question: From the app, how would you send an email though? Can you call their “currently used email”?

If you could easily access the email for the user (even if they change it in their social account), then almost not storing it would be better for the user experience.

So with Laravel Socialite, when you click “Sign In” or “Register with Social” the process is identical. The UX is slightly different depending on whether you create an account or log in (where you redirect after completion). This allows us to store the email once upon registration, but not to update it later on.

We essentially save the current email and allow the user to change it from within the app if they need. It’s a good idea to keep it on file for queued emails since you won’t be able to grab it without the user being authenticated.

This doesn’t mean we don’t store an email, we just store it once and allow the user to change and manage their email from within a settings page. Hope that helps. The oAuth dance and settings management is one of the biggest sources of friction when building your auth system.

1 Like

I think that solves it for me, 99% of all cases I’ll store the initial (on registration) email and let the user update it. However that 1% where I need to rely on my provider to give me the right/potentially changing address (like okta/ms oauth) for a corporate or enterprise environment I’ll opt to ask the provider for the address if I need to send an email.

So let’s chat one/multiple providers…

If I only think I’ll be using one provider… say a dev tool and I only want Github I prefer to store that provider ID on the users table. Easy peasy.

Users table migration
Schema::create('users', function (Blueprint $table) {
    $table->id();
    // ..
    $table->unsignedInteger('github_id')->nullable();
    $table->string('avatar')->nullable();
    $table->timestamps();
});

But, on an application where I want to provide multiple methods for SSO. Do you prefer to utilize a providers table like so:

Providers table migration
Schema::create('providers', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id');
    $table->string('name');
    $table->unsignedBigInteger('account_id');
    $table->timestamps();
});

Or a providers field on the users table with a json dataset? Personally I lean on the providers table so that joins can occur easily and provides portability just in case an old version of a DB is being used where JSON joins aren’t supported.

Are you looking for:

  1. A the user can log in through a choice of providers once?

Example: I can log in with Facebook, Google, Twitter, OR GitHub.

  1. A user can log in with multiple providers to the same account?

Example: I can log in with Facebook, Google, Twitter, AND GitHub to the same account?

With example number 1, I’d go with just a provider_id and provider field on the user table. With example number two I’d go with a providers table, set up a relationship to the user where you could do:

$user->whereHas('provider', function( $query ){
    $query->where('provider_id', '=', $providerID)
                ->where('provider', '=', $provider)
});

Something along those lines.

Yeah thinking along the lines of an “and” scenario. Could lead to something in the settings to connect your account to a list of other SSO providers. Also thank you for the book suggestion ended up ordering it yesterday and looking forward to the light reading.

I wanted to touch back on the Server Side Up ID you mentioned, is this an in house SSO provider? A sort of self-hosted auth0 type of thing?

I started going down that path for the tools I build for myself / friends and have started evaluating if I should build it from scratch (Passport) or utilizing Auth0 instead.

Self-hosted ID system written by the mighty @danpastori :grinning_face_with_smiling_eyes:

We were first like “oh man, this seems like a lot of effort…”

Today, we TOTALLY love having it. Its so powerful and it feels good to have 100% control over our user data. :+1:

So glad Dan knows how to build this kind of stuff, hahaha!

Definitely some sort of settings to “bind” accounts. If the user has two accounts, might have to consider a settings migration but that would be an extremely rare case.

Glad you ordered the book too! He has one called Clean Architecture as well. I have it but haven’t started it yet. I’ve been following his advice of reading slowly and trying to implement what I can. It’s DENSE so “light reading” may be a page and close it up. That’s what I’ve been doing lol.

And yup, as @jaydrogers mentioned, the Server Side Up ID is an in house SSO provider of sorts. We built it from the ground up with Laravel Socialite. Eventually we will expand it with Laravel Passport so we can sign in to our dev tools with your Server Side Up ID. So like with our book, we can trigger permissions upon purchase to grant access to the group on Discourse. That’s why we went down this route. Took a lot longer to implement and multiple steps, but the functionality is pretty scalable which is nice. A lot of people use Auth0 and like it as well. Didn’t they just get acquired by Okta as well?

Let me know if you have any more questions too! I’d love to see what you are building as welll!

Took some time over the weekend to put theory to paper… Going to be incorporating this into a new app for beer tastings :beers:.

I’ll roll through the migrations and models first, just to get those out of the way.

User model

No real changes here, just a relationship to the linked providers.

public function linkedProviders()
{
    return $this->hasMany('App\Models\LinkedProvider');
}

Provider migration

Schema::create('linked_providers', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id');
    $table->string('provider_name');
    $table->unsignedBigInteger('provider_id');
    $table->timestamps();
});

Provider model

// ...

class LinkedProvider extends Model
{
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'provider_name',
        'provider_id',
    ];

    /**
     * Relationships.
     */
    public function user()
    {
        return $this->belongsTo('App\Models\User');
    }
}

Now we’re getting into the meat of the SSO process, first up is a simple addition to the auth.php routes file to handle the social redirect and the callback.

use App\Http\Controllers\Auth\SocialAuthController;

Route::get('/auth/{provider}', [SocialAuthController::class, 'socialRedirect'])
        ->middleware('guest')
        ->name('auth.redirect');

Route::get('/auth/{provider}/callback', [SocialAuthController::class, 'socialCallback'])
        ->middleware('guest');

Next let’s dive into the controller, only two methods here. Really want to thank @danpastori for helping me think thought this part.

The socialRedirect does a simple check if the provider is valid (going to add more) and then redirects you for authentication.

socialCallback has been rewritten completely with all methods being moved to SocialAccountService class.

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\SocialAccountService;
use Laravel\Socialite\Facades\Socialite;

class SocialAuthController extends Controller
{
    public function socialRedirect($provider)
    {
        if (in_array($provider, ['untappd'])) {
            return Socialite::with($provider)->redirect();
        }

        return redirect()
            ->route('login')
            ->withErrors([
                'provider' => 'Oops, invalid SSO provider.',
            ]);
    }

    public function socialCallback($provider)
    {
        $socialUser = new SocialAccountService($provider);
        $socialUserRedirect = $socialUser->handleAuthentication();

        return $socialUserRedirect;
    }
}

The social account service does everything else.

  1. If a user has already registered with that provider and ID, sign em in.
  2. Make sure that email doesn’t already exist before creating a new account, don’t want to open up a security concern.
  3. Create a new account.
  4. One improvement was moving the authentication facade and any login related methods to a central method. In my mind this simplifies the login process and keeps it dry.
namespace App;

use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;

class SocialAccountService
{
    public $provider;

    public $socialUser;

    public $user;

    public function __construct($provider)
    {
        $this->provider = $provider;
    }

    public function handleAuthentication()
    {
        $this->socialUser = Socialite::driver($this->provider)->user();

        $this->user = User::query()
            ->whereHas('linkedProviders', function (Builder $query) {
                $query->where([
                    ['provider_name', $this->provider],
                    ['provider_id', $this->socialUser->getId()],
                ]);
            })
            ->first();

        if ($this->user) {
            return $this->authenticateUser($this->user);
        }

        if ($this->checkForRegistedUser($this->socialUser->getEmail())) {
            return redirect()
                ->route('register')
                ->withErrors([
                    'email' => 'Sorry, someone already registered with that email.',
                ]);
        }

        $this->user = User::create([
            'name' => $this->socialUser->getName(),
            'email' => $this->socialUser->getEmail(),
        ]);

        if ($this->user->markEmailAsVerified()) {
            event(new Verified($this->user));
        }

        $this->user->linkedProviders()
            ->create([
                'provider_name' => $this->provider,
                'provider_id' => $this->socialUser->getId(),
            ]);

        return $this->authenticateUser($this->user);
    }

    private function authenticateUser($user)
    {
        Auth::login($user);

        if ($this->provider === 'untappd') {
            $this->setSessionUntappdTokens();
        }

        return redirect()->intended('/');
    }

    private function checkForRegistedUser($email)
    {
        return User::firstWhere('email', $email);
    }

    private function setSessionUntappdTokens()
    {
        session(['untappd_token' => $this->socialUser->token]);
        session(['untappd_token_expires_in' => $this->socialUser->expiresIn]);
    }
}

Would love to hear your thoughts, recommendations and feedback. I’m going live with a limited preview later this week and this and an invite system are the last of my “todo” list for the initial release.

1 Like

Awesome work @alexjustesen !

Honestly, it looks very similar to what we have implemented. I really like your linked providers table. I think that’s going to be super interesting to see how that works.

The only question I have is looping back to emails. If there are going to be multiple providers (Facebook & Untapped, etc.). If the user registers with 2 accounts or so, it might be best to store the email address in your pivot table. That way you can keep track of all the email addresses bound to the same user and check there. I mean this is the .01% of times where someone forgets what they signed in with and creates a new account through a different provider. Just a thought.

Looks great so far though! I really don’t have anything else to add! Can’t wait to see the app.

Still working on how I’m going to do that but the PK for a user through out the app will be the user.id.

By that if a user is already authenticated via a provider or normal registration I’ll let them connect additional SSO methods… that or only allow one but let them change it.

1 Like

Hi all, I’ve just went through this whole convo on Socialite… With the approach that @alexjustesen posted, what happens in this situation:

  • User registers/logins using Socialite and let’s say google provider with email “user@email. com”
  • That user changes his actual email (that lives in the users table and is used with the “login with email/password” feature on the website to something like “other@email. com” and does not verify that email. Now this user can login to that account with his Google social acc which has email “user@email. com” and has “taken” away possibility for an actual owner of the “other@email. com” to login to the website using that email (I assume that “other@email. com” is not the actual email of the user who registered using google, he input his email either by mistake, misstyped it or did it intentionally.
  • User that is the owner of “other@email. com” now can’t register to the website and the owner of google’s account now has that email set as his email and another one “user@email. com” that is stored in the providers table which will log him in when he uses google auth.

Or another case:

  • User registers/logins using Socialite and let’s say google provider with email “user@email. com”
  • That user changes his actual email (that lives in the users table and is used with the “login with email/password” feature on the website to something like “other@email. com” and does not verify that email. Now this user can login to that account with his Google social acc which has email “user@email. com” and has “taken” away possibility for an actual owner of the “other@email. com” to login to the website using that email (I assume that “other@email. com” is not the actual email of the user who registered using google, he input his email either by mistake, misstyped it or did it intentionally.
  • Now his primary email is not “user@email. com” anymore and if some user comes to the website registration form, enters “user@email. com” and password, he successfully registers for the website (even though that email is actually the email of the person who used google auth to create his account).

All of this leads to data discrepancy and potential security risks. What is the way to do this properly? Or am I missing something and overthinking this?

Not sure if best way, but what I did to handle issues with unverified email when user changes it on his profile is this (using the same service logic that alex posted in earlier replies in the thread:


<?php

namespace App\Services;

use App\Models\Role;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;

class SocialAccountService
{
    /**
     * @var
     */
    public $provider;

    /**
     * @var
     */
    public $socialUser;

    /**
     * @var
     */
    public $user;

    /**
     * SocialAccountService constructor.
     * @param $provider
     */
    public function __construct($provider)
    {
        $this->provider = $provider;
    }

    /**
     * @return \Illuminate\Http\RedirectResponse
     */
    public function handleAuthentication(): \Illuminate\Http\RedirectResponse
    {
        $this->socialUser = Socialite::driver($this->provider)->user();

        $this->user = User::query()
            ->whereHas('socialAccounts', function (Builder $query) {
                $query->where([
                    ['provider', $this->provider],
                    ['provider_id', $this->socialUser->getId()],
                ]);
            })
            ->first();

        if ($this->user) {
            return $this->authenticateUser($this->user);
        }

        if ($existingUser = $this->checkForRegisteredUser($this->socialUser->getEmail())) {

            // if existing user has social account(s), we can't take over that account
            if($existingUser->socialAccounts()->count() && $existingUser->hasVerifiedEmail() === false) {
                return redirect()
                    ->route('register')
                    ->withErrors([
                        'email' => 'Sorry, someone already registered with that email.',
                    ]);
            }

            // if existing user doesn't have verified email and he has NO SOCIAL ACCOUNTS connected to him
            // take control of that user account with $this->socialUser
            // todo: potential vulnerability: if user is created by email/password and doesn't confirm email (uses someone else's email)
            // todo: his account will be overtaken by the user logging in / registering with social account that has that email
            // todo: not a very big concern as unverified account really can be counted as invalid account anyways
            if($existingUser->hasVerifiedEmail() === false) {

                $existingUser->password = null;
                $existingUser->save();

                if ($existingUser->markEmailAsVerified()) {
                    event(new Verified($existingUser));
                }

                $this->createSocialAccount($existingUser);
                return $this->authenticateUser($existingUser);
            } else {
                $this->createSocialAccount($existingUser);
                return $this->authenticateUser($existingUser);
            }

        }

        $this->user = User::create([
            'name' => $this->socialUser->getName() ?? $this->socialUser->getNickname() ?? '',
            'email' => $this->socialUser->getEmail(),
            'role_id' => Role::where('name', 'runner')->first()->id,
            'profile_photo_path' => $this->socialUser->getAvatar()
        ]);

        if ($this->user->markEmailAsVerified()) {
            event(new Verified($this->user));
        }

        $this->createSocialAccount($this->user);

        return $this->authenticateUser($this->user);
    }

    /**
     * @param $user
     * @return \Illuminate\Http\RedirectResponse
     */
    private function authenticateUser($user)
    {
        Auth::login($user);

        return redirect()->intended('/dashboard');
    }

    /**
     * @param $user
     */
    private function createSocialAccount($user) {
        $user->socialAccounts()
            ->create([
                'provider' => $this->provider,
                'provider_id' => $this->socialUser->getId(),
                'provider_email' => $this->socialUser->getEmail(),
                'provider_token' => $this->socialUser->token,
                'provider_refresh_token' => $this->socialUser->refreshToken,
                'provider_token_expires_in' => $this->socialUser->expiresIn
            ]);
    }

    /**
     * @param $email
     * @return mixed
     */
    private function checkForRegisteredUser($email)
    {
        return User::firstWhere('email', $email);
    }

}

So, instead of just immediately redirecting to register page if an email exists in the users table i do another check if that user has unverified email address and if so I “take control” of that account with the current social account that is logging in / registering and also “reset” the password to “null” to handle security issue when that user with unverified email had an email set (and would hence be able to login to the user that is logging in through social account now), since the account now has been verified. I only do this if the unverified user had no social accounts connected to his account (to prevent another security issue where new user would gain access to his existing social accounts)