Implementing Single Sign-On (SSO) with Laravel , Step by Step

Murilo Livorato
9 min readJul 24, 2024

--

Laravel provides solutions for SSO (Single Sign-On) authentication with Laravel Passport , enabling users to access multiple applications with a single set of credentials, making it easy to implement a robust and secure SSO solution.

I will share my solution here and the code at the end .

Introduction

We will create 2 Laravel projects and we’ll use Laravel Passport for OAuth2 authorization .

The Auth project — will be responsible to allows and centralize those users .
The Supervisor project — it will be the backend for the adminstrator area for supervisor users .

Step 1 — Installing and Setting Laravel AuthProject

At this step, we will create the first application . the Auth Aplication .


# Create a new Laravel Project
- composer create-project laravel/laravel .

Install Laravel Breze

- composer require laravel/breeze --dev php artisan breeze:install

- php artisan breeze:install

Set Laravel Passport Clients -

## CREATE THE PASSPORT CLIENT 
- php artisan passport:client

Which user ID should the client be assigned to?:
> 1

What should we name the client?:
> Auth
## CREATE PASSPORT -- PASSWORD
php artisan passport:client --password --name=UserAdmin --provider=users

The database , will be like this -

Set the .env file with those passport users -

DB_CONNECTION=mysql
DB_HOST=172.23.0.1
DB_PORT=3306
DB_DATABASE=laravel_database_sso
DB_USERNAME=root
DB_PASSWORD=secret

PASSPORT_LOGIN_ENDPOINT="http://localhost:8081/oauth/token"

PASSPORT_CLIENT_ID="9c93bc86-7f11-4c1c-83ee-9ceb590b4e6f"
PASSPORT_CLIENT_SECRET="GLaFWG6NUFnx9f8w4JIeT1eXK7Y54LESrKlCsPbe"

PASSPORT_PASSWORD_ID="9be3009b-66e2-4400-97d2-92112d7db4cb"
PASSPORT_PASSWORD_SECRET="ksOW88P9jhPPD4sfBBezEYLS2B3FS7iv5DGdxyrJ"

Create Users Tables , Roles and Permissions Table

class CreateUserAdmins extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::defaultStringLength(191);
Schema::create('user_admins', function (Blueprint $table) {
$table->engine = 'InnoDB';

$table->bigIncrements('id');
$table->string('email')->unique();
$table->string('password');
$table->rememberToken();

$table->enum('status', ['active', 'no_active' ])->default('active');
$table->string('folder' , '60')->nullable();

$table->timestamps();

$table->softDeletes();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_admins');
}
}
class CreateUserAdminInfos extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::defaultStringLength(191);
Schema::create('user_admin_infos', function (Blueprint $table) {
$table->engine = 'InnoDB';

$table->bigIncrements('id');

$table->bigInteger('user_id')->unsigned();
$table->foreign('user_id')->references('id')->on('user_admins')->onDelete('cascade');

$table->string('cpf');
// $table->string('cpf')->unique();

$table->string('name');
$table->string('last_name')->nullable();
$table->string('phone')->nullable();



$table->timestamps();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('user_admin_infos', function(Blueprint $table){
$table->dropForeign('user_admin_infos_user_id_foreign');
});


Schema::dropIfExists('user_admin_infos');
}
}
class CreateUserUserAdminRoles extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::defaultStringLength(191);
Schema::create('user_user_admin_roles', function (Blueprint $table) {
$table->engine = 'InnoDB';

$table->bigInteger('user_id')->unsigned()->index();
$table->foreign('user_id')->references('id')->on('user_admins')->onDelete('cascade');

$table->integer('role_id')->unsigned()->index();
$table->foreign('role_id')->references('id')->on('user_admin_roles')->onDelete('cascade');

// $table->unique(['user_id','role_id']);
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('user_user_admin_roles', function(Blueprint $table){
$table->dropForeign('user_user_admin_roles_user_id_foreign');
$table->dropForeign('user_user_admin_roles_role_id_foreign');
});

Schema::dropIfExists('user_user_admin_roles');
}
}
class createUserAdminAccessAreasTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::defaultStringLength(191);
Schema::create('user_admin_access_areas', function (Blueprint $table) {
$table->engine = 'InnoDB';
$table->increments('id');
$table->string('title', 40)->unique();
$table->string('url_title', 40)->unique();
$table->text('description');
$table->text('image')->nullable();
$table->string('url', 300);
$table->timestamps();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_admin_access_areas');
}
};
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::defaultStringLength(191);
Schema::create('user_admin_user_admin_access_areas', function (Blueprint $table) {
$table->engine = 'InnoDB';

$table->bigInteger('user_id')->unsigned()->index();
$table->foreign('user_id')->references('id')->on('user_admins')->onDelete('cascade');

$table->integer('area_id')->unsigned()->index();
$table->foreign('area_id')->references('id')->on('user_admin_access_areas')->onDelete('cascade');

// $table->unique(['user_id','area_id']);
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('user_admin_user_admin_access_areas', function(Blueprint $table){
$table->dropForeign('user_admin_user_admin_access_areas_user_id_foreign');
$table->dropForeign('user_admin_user_admin_access_areas_area_id_foreign');
});
Schema::dropIfExists('user_admin_user_admin_access_areas');
}
};

Create Models

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\HasApiTokens;

class UserAdmin extends Authenticatable
{
use HasFactory, Notifiable , HasApiTokens;


protected $table = 'user_admins';
protected $fillable = [
'status',
'email',
'folder'
];

/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password', 'remember_token',
];


public function Roles(){
return $this->belongsToMany(UserAdminRole::class , 'user_user_admin_roles' , 'user_id' , 'role_id' );
}


public function hasRole($role)
{
if (is_string($role)) {
return $this->Roles->contains('title', $role);
}
}


public function AcessAreas(){
return $this->belongsToMany(UserAdminAccessArea::class , 'user_admin_user_admin_access_areas' , 'user_id' , 'area_id' );
}


public function hasAcessArea($role)
{
if (is_string($role)) {
return $this->Roles->contains('title', $role);
}
}

Migrate and Seed Files

## MIGRATE DB
- php artisan migrate
- php artisan db:seed

I changed the style -

Create the Admin Area

Now we can list all those areas that users has right to access -

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\UserAdmin;
use App\Models\UserAdminAccessArea;
use App\Traits\ApiResponse;
use Illuminate\Support\Facades\Auth;

/**
* HOME CONTROLLER
*/
class HomeController extends Controller
{
use ApiResponse;
public function __construct()
{
parent::__construct();

// verify admin user
$this->middleware('admin');
}
/**
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user_id = Auth::user()->id;

$access_area = UserAdminAccessArea::select('title', 'description', 'url')
->whereHas('Users', function ($query) use ($user_id) {
$query->where('id', $user_id);
})->get();

$user = UserAdmin::select([ 'id', 'status', 'email'])
->with(['AdminInfo' => function ($query) {
$query->select('name', 'cpf', 'phone', 'last_name', 'user_id');
}])->where('id', $user_id)->first();

return view('admin/home', ['access_areas' => $access_area, 'user' => ['name' => $user->AdminInfo->name . " ". $user->AdminInfo->last_name, 'email' => $user->email] ]);
}
@extends('layouts.admin')

@section('content')
<div class="container">
<div class="columns estatistic-list">
<div class="column box-area" >
<div class="columns">
<div class="column has-text-centered header-pg">
<h1 class="main-text">Wecome</h1>
<h1 class="main-text">You have access to Areas</h1>
</div>
</div>

<div class="columns">
<div class="column has-text-centered">
<ul class="list-areas">
@foreach($access_areas as $list)
<li><a href="{{ $list['url'] }}" target="_blank" >
<h3>{{ $list['title'] }}</h3>
</a></li>
@endforeach
</ul>
</div>
</div>
</div>
</div>
</div>
@endsection

And now you can login in the area with the user and password -

Step 2— Create the Supervisor Service ( Second Laravel Project )

Now we going to create the second project — The Supervisor .


# Create a new Laravel Project
- composer create-project laravel/laravel .

Install Passport

php artisan passport:client --personal

What should we name the personal access client? :
> Supervisor

Next step we will , add the information of the Auth Service connection in .env -

DB_CONNECTION=mysql
DB_HOST=172.23.0.1
DB_PORT=3306
DB_DATABASE=laravel_database_supervisor
DB_USERNAME=root
DB_PASSWORD=secret


AUTH_HTTP_HOST="http://localhost:8081"
AUTH_REQUEST_HOST="http://laravel_auth_sso_server:80"
AUTH_CLIENT_ID="9c93bc86–7f11–4c1c-83ee-9ceb590b4e6f"
AUTH_CLIENT_SECRET="GLaFWG6NUFnx9f8w4JIeT1eXK7Y54LESrKlCsPbe"
AUTH_SCOPE="access-supervisor-area"

At those guards and providers at confi/auth.php

'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport',
'provider' => 'users',
'hash' => false,
],
],

/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication drivers have a user provider. This defines how the
| users are actually retrieved out of your database or other storage
| mechanisms used by this application to persist your user's data.
|
| If you have multiple user tables or models you may configure multiple
| sources which represent each model / table. These sources may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/

'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\UserAdmin::class,
],

Create as well an User table , model and database for the Supervisor -

class CreateUserAdmins extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_admins', function (Blueprint $table) {
$table->engine = 'InnoDB';

$table->bigIncrements('id');
$table->string('email')->unique();
$table->rememberToken();

$table->enum('status', ['active', 'no_active'])->default('active');
$table->string('folder', '60')->nullable();
$table->string('auth_token', '400')->nullable();

$table->timestamps();

$table->softDeletes();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_admins');
}

We have now two services, with 2 Laravel projects -

We now have now 2 databases -

Step 4 — Requesting Tokens

Now what we need to do is to get from the Supervisor Service -

  • The authorize link ( from Auth Service ).
  • The token ( from Auth Service ).
  • And get the user information ( with the token that we got before ).

we can separate it in two methods at the supervisor service -

1. Get The Auth Link

In this method, it will access the route — {auth_service}/oauth/authorize .
With the authorization code .

 public function getAuthLink()
{
$state = Str::random(40);
$query = http_build_query([
'client_id' => $this->client_id,
'redirect_url' => $this->callback_url,
'response_type' => 'code',
'scope' => $this->scope,
'state' => $state,
]);

return response()->json(['authorize_url' => config('services.auth.http_host').'/oauth/authorize?'.$query, 'state' => $state], 200);
}

The result will be like this on postman -

It will return a authorize_url and state , with this link we will be able to access the url via browser —

{
"authorize_url": "http://localhost:8081/oauth/authorize?client_id=9c93bc86-7f11-4c1c-83ee-9ceb590b4e6f&redirect_url=http%3A%2F%2Flocalhost%3A8001%2Fcallback&response_type=code&scope=access-supervisor-area&state=klwOGbu1z7EvE2MPRCYamPu3lPuODDdtmEIi7s8v",
"state": "klwOGbu1z7EvE2MPRCYamPu3lPuODDdtmEIi7s8v"
}

Accessing with your e-mail and password , it will return back to supervisor service , on the route {supervisor_service}/api/auth/callback -

this call back url , is set previously on auth service database , on table oauth_clients .

now you will be redirected to -

{supervisor_service}/api/auth/callback?code=def5020049eea2613ac334072f606e0120a05eaf0a4ef12283e45d4272dfdbcc2f24d012b95769a594afcc63ccb29368338262ec892826c64342a9919dc866d987868ae2ea14262c2c94e165de43fd0dc98ff5d3695bca1f9f932d2108e1ea758aa89deeee54a67d2fd267ea1982e4cdb0ceeb590d2e423a1a78e7423775170d819f1d5efb104f0e2cdc61cebdb3b71bd20a18f545db3d1ab7980fe4c6a4d553a88adfad3ba9e56b04d0f5737d80eae4e033e464cf9813a27fad7952875ff2fbbccb10435cb5449384dc5b078629cff63378e5a74aed9cbf5c70228c1704b080fe08c5371531610a5d81d10417cfca5fd09716f261dd3916c0b12e528ca4854725e96b7f3775c720b029fe1910975f27d4f2cded7c14e914b2d95f77c25cfbb0f80c6d4acca6d9184d4efb38a14ba98e5bfb6c61e21a475bf2f17433db24e6978b91d8a2377d53c86e0cddf21196bc9719e6986b7f10c7ff4b27a107a59e1fb966b77e9dac822895d607f30e99aa21fb2376120042a3701a8c0c&state=VKBEKVPgvY0Rgqre03F4wBplP86A4qkWM8tDCId8

We logged in Auth service and now we were redirected back to supervisor service at the callback method .

Route::get('/auth/callback', [AuthController::class, 'getCallBack']);

2. Get Call Back

At the call back method, we will -

Get the Token , Get the User detail and Register new User in Supervisor Service .

we can get the token accessing the URL — {auth_service}/oauth/token .

$sso_token = $this->getAuthToken($params['state'], $params['code']);
private function getAuthToken($state, $code)
{
throw_unless(strlen($state) > 0 && $state, InvalidArgumentException::class);

$response = Http::asForm()->post(
$this->request_host.'/oauth/token',
[
'grant_type' => 'authorization_code',
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
'redirect_url' => $this->callback_url,
'code' => $code,
]);
return $response->json()['access_token'];
}

Now that we have the token ( the auth laravel project token ), we can access
and get the user data that we need to register in supervisor service .

private  function getUserDetaleAndRegisterNewUser($access_token, $area = 'supervisor')
{
$response = Http::withHeaders([
'Accept' => 'application/json',
])->withToken($access_token)->get($this->request_host.'/api/user?area='.$area);
$userData = $response->json();

$userAdmin = UserAdmin::updateOrCreate(
["email" => $userData['email']],
[
'status' => 'active',
'email' => $userData['email']
]);

$action = $userAdmin->AdminInfo()->exists() ? 'update' : 'create';
$userAdmin->AdminInfo()->{$action}([
'name' => $userData['name'],
'last_name' => $userData['last_name'],
'cpf' => $userData['cpf'],
'phone' => $userData['phone'],
'manager_status' => $userData['manager_status'],
]);

return $userAdmin;
}
$user = $this->getUserDetaleAndRegisterNewUser($sso_token);


if (!$user) {
return response()->json(['status' => 'not-authorized'], 400);
}
// return the token for access this laravel project
return $user->createToken('Supervisor')->accessToken;

Now on database , it will create a new register with the new Supervisor User -

And finally it will return a token -

I just printed that on screen but in a real project you could send a request for a api .
Now it will be able to access the supervisor area giving a new permission for the user .

Now in this protected route -

we will be able to access with the token -

it returns the user information accessing the supervisor service -

{
"email": "annamarie18@example.net",
"name": "Luigi",
"last_name": "Purdy",
"is_manager": false
}

we can see that the user now has permission accessing the supervisor protected routes links with the token .

Conclusion

Laravel Passport makes it straightforward to implement SSO authentication using OAuth2. By leveraging the authorize method and the built-in OAuth2 capabilities of Passport, developers can create a seamless authentication experience across multiple applications, improving security and user convenience.

Keep exploring Laravel’s ecosystem to unlock even more possibilities for your web development projects. Happy coding!

Git Hub Code -

Thanks a lot for reading till end. Follow or contact me via:
Github:https://github.com/murilolivorato
LinkedIn: https://www.linkedin.com/in/murilo-livorato-80985a4a/
Youtube : https://www.youtube.com/@murilolivorato1489

--

--