dsl's avatar
Level 2

Sanctum with Socialite and SPA

Sanctum with Socialite and SPA

I'm having hard times trying to get Socialite and Sanctum running on SPA webiste... i would like to use SPA authentication as recommended in laravel documantation sanctum#spa-authentication (not allowed to post links 😅). I would also like to login user right away.

the flow i am trying to use is clicking link on SPA -> API server redirects to social network -> (social network login) -> callback to API server where i create and log in user -> redirect back to SPA. both SPA and API are on the same domain.

is it even possible? event after few hours i am not able set it right... i suppose i cannot use Socialite Stateless Authentication socialite#stateless-authentication because it wont't work with the sanctum session authentication ... so i end up with

api routes:

Route::group([
	'middleware' => 'web',
	'excluded_middleware' => EnsureFrontendRequestsAreStateful::class,
], function () {
	Route::get('auth/{provider}', [LoginController::class, 'redirectToProvider']);
	Route::get('auth/{provider}/callback', [LoginController::class, 'handleProviderCallback']);
});

and LoginController:

public function redirectToProvider($provider): RedirectResponse
{
	return Socialite::driver($provider)->redirect();
}

public function handleProviderCallback($provider): RedirectResponse
{
	$user = Socialite::driver($provider)->user();
	
	$newUser = User::firstOrCreate([
			'email' => $user->getEmail()
		], [
			'email_verified_at' => now(),
			'name' => $user->getName(),
			'status' => true,
			'provider' => $provider,
			'provider_id' => $user->getId(),
	]);
	
	Auth::login($newUser);
	
	return redirect(env('SPA_URL') . '/registration-followup');
}

but when i send axios request from registration-followup page to API server, user is not logged in.

1 like
13 replies
dsl's avatar
Level 2

is nobody using Sanctum for SPA and social network login? or should the approach be somehow different?

1 like
dsl's avatar
Level 2

Oh, i did after all. Solution turned out to be move the routes (without middleware group) to web routes:

Route::get('auth/{provider}', [LoginController::class, 'redirectToProvider']);
Route::get('auth/{provider}/callback', [LoginController::class, 'handleProviderCallback']);

in app/Http/Kernel.php:

	protected $middlewareGroups = [
		'web' => [
			EncryptCookies::class,
			AddQueuedCookiesToResponse::class,
			StartSession::class,
			AuthenticateSession::class,
			ShareErrorsFromSession::class,
			VerifyCsrfToken::class,
			SubstituteBindings::class,
		],

		'api' => [
			EnsureFrontendRequestsAreStateful::class,
			'throttle:api',
			SubstituteBindings::class,
		],
	];

also, SANCTUM_STATEFUL_DOMAINS needs to be with possible subdomain (not sure about port in case of localhost) in .env:

SANCTUM_STATEFUL_DOMAINS=yourdomain.com,api.yourdomain.com
1 like
dansku's avatar

awesome! can you share also the handleProviderCallback? are you just using then the return with the token, like this?

return response()->json($response, 200, ['Access-Token' => $token]);

thanks

1 like
dsl's avatar
Level 2

actually no, since you are redirected to the callback route by the social network, you need to find out if the user is new (then create him) or alredy registered (then just log him)... and then redirect to your frontent... see i.e. https://medium.com/@Alabuja/social-login-in-laravel-with-socialite-90dbf14ee0ab or https://www.positronx.io/laravel-socialite-login-with-facebook-tutorial-with-example/

also, if you are using SSR, dont forget to add where your SPA is proxied

SANCTUM_STATEFUL_DOMAINS=yourdomain.com,api.yourdomain.com,localhost:3000
1 like
dansku's avatar

yeah, what i was trying to do is, the callback goes to the backend,

public function callback(Request $request)
    {

                try {
                    $user = Socialite::driver("discord")->stateless()->user();

                    dd($user);
                } catch (ClientException $exception) {
                    return response()->json(['error' => 'Invalid credentials provided.'], 422);
                }

                // Create user and update provider table
                $userCreated = User::firstOrCreate(['email' => $user->getEmail()], ['email_verified_at' => now(), 'name' => $user->getName(), 'status' => true]);
                $userCreated->providers()->updateOrCreate(['provider' => "discord", 'provider_id' => $user->getId()], ['avatar' => $user->getAvatar()]);

                $token = $userCreated->createToken('token-name')->plainTextToken;

                // build specific response payload
                $response = [];
                $response["name"] = $user->name;
                $response["avatar"] = $user->avatar;
                $response["email"] = $user->email;
                $response["nickname"] = $user->nickname;

                return response()->json($response, 200, ['Access-Token' => $token]);
       

    }

this access token then it sent to the front-end. would this be a cookie response for the token? as sanctum is cookie based?

1 like
kentreez's avatar

@dansku For making SPA work with Sanctum cookie-based. The callback URL should go to Laravel backend (routes/web.php) directly. After we got $user object from the \Models\User then we use this method \Auth::login($user); to create user session. Finally we redirect user back to SPA URL.

aschorr's avatar

@kentreez I'm in the same position as above, and this all makes sense, however as you mention when we "Finally we redirect user back to SPA URL." How are we able to identify the user that we just authed with? For testing I have something like return redirect('http://' . env('CLIENT_SERVER') . '/user');. But there don't seem to be any cookies set on that domain upon redirect. I had thought they should be set automatically. Or must we manage the setting of the cookies ourselves?

1 like
R3N's avatar
R3N
Best Answer
Level 5

This is not the proper way to login in with a SPA.

You need to send a POST request instead of a get request when you are login in with a separated SPA.

Route::post('auth/{provider}/callback', [LoginController::class, 'handleProviderCallback']);
  1. SPA site.com/login click google login button
  2. button take you to google sign in and you sign in there
  3. google redirect you back to SPA site.com/login with a code
  4. You send that code to the AP site.com/api/login/google/callback using a post request
  5. SPA site.com sign in

The documentation does not explain this and had to figure it out. For a SPA you don't need to use route Route::get('auth/{provider}', [LoginController::class, 'redirectToProvider']);

This is how I did it using Nuxt.js using nuxt auth

      google: {
        clientId: process.env.GOOGLE_CLIENT_ID,
        codeChallengeMethod: "",
        responseType: "code",
        endpoints: {
          token: "/api/login/google/callback",
          userInfo: "/user",
        },
        grantType: "authorization_code",
      },
Route::post('/login/{provider}/callback', [SocialAuthController::class,'handleProviderCallback']);
     */
    public function handleProviderCallback($provider)
    {
        $validated = $this->validateProvider($provider);

        if (!is_null($validated)) {
            return $validated;
        }

        $providerUser = Socialite::driver($provider)->stateless()->user();

        $providerId = Provider::where('provider_id', $providerUser->getId())->first();
        
        if ($providerId) {
            $updateUser = User::where('email', $providerUser->getEmail())->first();
            $token = $updateUser->createToken('token-name')->plainTextToken;
        } else {
            $createUser = User::firstOrCreate([
                'email' => $providerUser->getEmail(),
                'email_verified_at' => now(),
                'name' => $providerUser->getName(),
                'avatar_path' => $providerUser->getAvatar()
            ]);
    
            $createUser->providers()->updateOrCreate([
                'provider' => $provider,
                'provider_id' => $providerUser->getId(),
            ]);

            $token = $createUser->createToken('token-name')->plainTextToken;
        }

        return response()->json([
            'token_type' => 'bearer',
            'access_token' => $token,
        ], 200);
    }

    protected function validateProvider($provider)
    {
        if (!in_array($provider, ['facebook', 'google'])) {
            return response()->json(['error' => 'Please login using facebook or google'], 422);
        }
    }
1 like
Phil_Dr's avatar

@R3N Did you add CLIENT_ID and CLIENT_SECRET in the .env In Laravel files? Or we don't need it in this case?

1 like
R3N's avatar

@Philip_Droubi Yes

GOOGLE_CLIENT_ID=googleid34t53g35gt35g3535rgf.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=secret44tt53t54
GOOGLE_REDIRECT_URI=http://site.com/dashboard/login
1 like
kellslte's avatar

@R3N I still have issues with this. I realized that the code coming from the Google end now is not an access token and I keep getting a 401 error when I try to authenticate the user with it.

Here is my code snippet

 public function __invoke(Request $request, String $provider): JsonResponse
    {
        try {
            // get the user information from the provider
            $providerAccount = Socialite::driver($provider)->stateless()->user();

            // check if the user record exists in the database
            $userProvider = Provider::firstWhere("provider_id", $providerAccount->getId());

            // return the user information with the authorization token if it exists
            if ($userProvider) {
                $existingAccount = User::firstWhere("email", $providerAccount->getEmail());

                $token = $existingAccount->createToken("social-login-token")->plainTextToken;
            } else {
                // create a new user record and return it with the authorization token if it does not
                $newUser = User::firstOrCreate([
                    "email" => $providerAccount->getEmail(),
                    "email_verified_at" => now()
                ]);

                $newUser->assignRole("customer");

                $newUser->settings()->create([
                    "first_name" => $providerAccount->given_name,
                    "last_name" => $providerAccount->family_name,
                ]);

                $newUser->providers()->updateOrCreate([
                    "provider" => $provider,
                    "provider_id" => $providerAccount->getId()
                ]);

                $token = $newUser->createToken("social-login-token")->plainTextToken;
            }

            return response()->json([
                "success" => true,
                "authorization" => [
                    "type" => "Bearer",
                    "token" => $token
                ]
            ], 200);
        } catch (\Throwable $th) {
            dd($th);
            
            return response()->json([
                "success" => false,
                "error" => [
                    "message" => $th->getMessage(),
                    "code" => $th->getCode(),
                ]
            ], 422);
        }
    }
google: {
        clientId: process.env.GOOGLE_CLIENT_ID,
        codeChallengeMethod: '',
        // redirectUri: `${process.env.APP_URL}/auth/google/callback`,
        scope: ['openid', 'profile', 'email'],
        responseType: 'code',
        grantType: 'authorization_code',
        endpoints: {
          csrf: { url: '/sanctum/csrf-cookie' },
          token: `${process.env.BASE_URL}/google/login/callback`, // some backend url to resolve your auth with google and give you the token back.
          userInfo: `${process.env.BASE_URL}/user` // the endpoint to get the user info after you recived the token 
        },
      }
Route::post('/{provider}/login/callback', SocialAuthController::class)

Here is what is being returned from the nuxt frontend and sent as a post request body parameter when the user tries to sign in

code: 4%2F0AbUR2VOO2uUtvBVLxJimdYaNU5v8VmuJpOwhqEZzAtDWkzZT1FCq3v8R8wa9z37fh2FNiA
client_id: 946106268129-a5jr2e23120cc0si78prh4thvhr60ujc.apps.googleusercontent.com
redirect_uri: http%3A%2F%2Flocalhost%3A3000%2Flogin
response_type: code
audience: 
grant_type: authorization_code

Then here is the error message in the response tab

{
    "success": false,
    "error": {
        "message": "Client error: `POST https:\/\/www.googleapis.com\/oauth2\/v4\/token` resulted in a `400 Bad Request` response:\n{\n  \"error\": \"invalid_request\",\n  \"error_description\": \"\nYou can\u0026#39;t sign in to this app because it doesn\u0026# (truncated...)\n",
        "code": 400
    }
}

Can anyone help me with this? Have you face a similar issue and how did you solve it?

JDD's avatar

@R3N Except here you are returning a bearer token, not using the Cookie auth intended for SPA

Please or to participate in this conversation.