Debug School

rakesh kumar
rakesh kumar

Posted on • Edited on

End-to-End SSO Architecture with Google Login, Keycloak, Laravel Middleware, and Cross-Domain Logout

Introduction
Core Components of the Architecture
Authentication Flow (Login)
Middleware-Based Auto Login
Cross-Domain Session Control
Global Logout Architecture
Own Client Logout vs Peer Logout
Security Principles Behind the Design
Benefits of This Architecture

Introduction

Modern applications rarely live in isolation. Organizations often operate multiple web applications across different domains, all serving the same users. Requiring users to log in separately to each application leads to poor user experience, security risks, and operational complexity.

To solve this, we implement Single Sign-On (SSO) — a system where users authenticate once and gain access to multiple applications seamlessly.

This document explains a real-world, end-to-end SSO architecture built using:

Google OAuth for user-friendly authentication

Keycloak as the central identity provider (IdP)

Laravel applications acting as service providers

Middleware-based auto-login

Cross-domain token exchange

Global (broadcast) logout

The architecture focuses on security, scalability, and domain isolation, while still providing a smooth user experience.

Core Components of the Architecture

Combined Architecture + Full Flow Diagram (Google → Middleware → Logout)

Google OAuth (User Authentication Layer)

Google OAuth acts as the external authentication mechanism.
It answers one simple question:

“Is this user who they claim to be?”
Enter fullscreen mode Exit fullscreen mode

Key responsibilities:

Provides a trusted login mechanism

Handles identity verification

Returns verified user attributes (email, name)

Google OAuth does not manage sessions across your applications — it only authenticates the user once.

  1. Keycloak (Central Identity Layer)

Keycloak sits at the heart of the architecture as the Identity Provider (IdP).

Its responsibilities include:

Managing realms, clients, and roles

Issuing access tokens

Validating tokens (introspection)

Acting as a central authority for identity trust

In this system:

Keycloak is not the UI login page

It operates behind the scenes

Laravel apps interact with it server-to-server

Keycloak becomes the single source of truth for identity and authorization.

  1. Laravel Applications (Service A, B, C…)

Each Laravel application represents an independent service:

Separate domain

Separate database

Separate user sessions

Separate cookies

Despite this separation, all services trust Keycloak-issued identity.

Roles of Laravel apps:

Maintain local user records

Store token references

Enforce authorization rules

Manage sessions

Authentication Flow (Login)

Step 1: User Logs In Using Google

The authentication journey starts when the user clicks “Login with Google” in the primary application (Service A).

Google authenticates the user

The application receives verified user information

A local Laravel session is created

At this stage:

The user is logged into one application only

Other applications are unaware of this login


 public function handleGoogleCallback(Request $request)
{
    Log::info('[GOOGLE_CB] handleGoogleCallback hit');

    $inputrouteName = (string) session('login_from', 'login');
    Log::info('[GOOGLE_CB] login_from', ['route' => $inputrouteName]);


        $googleUser = Socialite::driver('google')->user();

        $email = (string) ($googleUser->email ?? '');
        $name  = (string) ($googleUser->name ?? '');

        if ($email === '') {
            Log::error('[GOOGLE_CB] Missing email from Google');
            return $this->redirectLoginError($inputrouteName, 'Email not received from Google');
        }

        $this->storeGoogleSession($request, $name, $email);

        $user = $this->findUserByEmail($email);

        if (!$user) {
            return $this->redirectLoginError($inputrouteName, 'Email does not exist');
        }

        $this->loginUserAndRegenerate($request, $user);
        // ✅ Reuse your common KC flow (same as OTP store)
        $this->syncKeycloakAndStoreTokens($user);
        return $this->redirectAfterLogin($user, $inputrouteName);


}
Enter fullscreen mode Exit fullscreen mode

Step 2: Synchronization with Keycloak

After local login, the application synchronizes the user with Keycloak.

Key actions:

Ensures the user exists in Keycloak

Obtains Keycloak-issued tokens

Stores token metadata securely in the database

This step establishes federated identity trust:

Google proves who the user is

Keycloak becomes the authority for all services


public function syncKeycloakAndStoreTokens(User $user): array
{
    // If resolveUser() is required, keep it; else remove to save latency
    $this->kcSync->resolveUser($user->email);

    $tokens = $this->syncWithKeycloak($user);

    // Hard safety checks
    if (!is_array($tokens) || empty($tokens['access_token']) || empty($tokens['expires_in'])) {
        Log::error('[KC_AUTO] Invalid tokens returned from syncWithKeycloak()', [
            'type' => is_array($tokens) ? 'array' : gettype($tokens),
            'keys' => is_array($tokens) ? array_keys($tokens) : null,
        ]);

        // Decide your policy:
        // 1) Allow local login but skip KC token storage (recommended)
        return [];
        // 2) OR fail login:
        // abort(response()->json(['success'=>false,'message'=>'Keycloak sync failed.'], 500));
    }

    $accessToken  = (string) $tokens['access_token'];
    $refreshToken = $tokens['refresh_token'] ?? null;
    $expiresIn    = (int) $tokens['expires_in'];

    // Store only hash for access_token
 $encAccess = encrypt($accessToken);
$encRefresh = $refreshToken ? encrypt($refreshToken) : null;

    UserKeycloakToken::updateOrCreate(
        ['user_id' => $user->id],
        [
            'access_token'  => $encAccess,
            'refresh_token' => $encRefresh,
            'expires_at'    => time() + $expiresIn,
            'revoked_at'    => null,
        ]
    );

    Log::info('[KC_AUTO] Token stored', [
        'user_id'       => $user->id,
        'expires_in'    => $expiresIn,
        'expires_at'    => time() + $expiresIn,
        'access_prefix' => substr($accessToken, 0, 16) . '...',
    ]);

    return $tokens;
}
Enter fullscreen mode Exit fullscreen mode
 public function syncWithKeycloak(User $user, bool $storeInSession = true): array|false
    {
        $this->logStart($user);

        $kcUserId = $this->getKcUserId($user);
        if (!$kcUserId) {
            $this->logFail('No kc_user_id stored', ['user_id' => $user->id, 'email' => $user->email]);
            return false;
        }

        [$kcBase, $realm] = $this->getKcConfig();
        if (!$kcBase || !$realm) {
            $this->logFail('Missing KEYCLOAK_BASE_URL or KEYCLOAK_REALM');
            return false;
        }

        $adminToken = $this->getAdminToken();
        if (!$adminToken) {
            $this->logFail('Admin token not available');
            return false;
        }

        $dummyPassword = $this->makeDummyPassword();

        // Step 1: remove required actions (best-effort)
        $this->removeRequiredActions($kcBase, $realm, $kcUserId, $adminToken);

        // Step 2: reset password (must succeed)
        if (!$this->resetPassword($kcBase, $realm, $kcUserId, $adminToken, $dummyPassword)) {
            return false;
        }

        // Step 3: login via password grant (must succeed)
        $tokens = $this->loginWithPasswordGrant($kcBase, $realm, $user->email, $dummyPassword);
        if ($tokens === false) {
            return false;
        }

        // Optional: store in session
        if ($storeInSession) {
            $this->storeTokensInSession($tokens);
        }

        $this->logSuccess($user, $tokens);

        return $tokens;
    }
Enter fullscreen mode Exit fullscreen mode

Step 3: Broadcasting Authentication to Other Domains

Once authenticated, the browser triggers a controlled broadcast to peer applications.

This broadcast:

Sends minimal identity context (email reference)

Does not expose sensitive credentials

Triggers identity setup in other domains

Each peer application:

Resolves the user locally

Establishes its own Keycloak trust

Sets its own secure authentication cookie

This preserves:

Domain isolation

Independent sessions

Shared identity trust


C:\myworkspace\motoshare-test\resources\views\dashboard.blade.php
Enter fullscreen mode Exit fullscreen mode
<script>
    window.MotoShareSSO = {

        email: @json(auth()->check() ? auth()->user()->email : null),
        accessToken:  "{{ session('access_token') }}",
        refreshToken: "{{ session('refresh_token') }}",
        idToken:      "{{ session('id_token') }}",
        ssoDomains:   @json(config('motoshare.sso_domains')),
        currentOrigin: window.location.origin

    };
</script>

Enter fullscreen mode Exit fullscreen mode
Route::post('/auth/token-exchange', [\App\Http\Controllers\Api\PartnerController::class, 'exchange'])
    ->middleware(['web', 'corsfix']);

// Handle OPTIONS preflight
Route::options('/auth/token-exchange', function () {
    return response()->json('OK', 204);
})->middleware(['web', 'corsfix']);
Enter fullscreen mode Exit fullscreen mode
public function exchange(Request $request)
{
    Log::info("Request Data exchange:", $request->all());
    Log::info('[EXCHANGE] Request received', [
        'host' => $request->getHost(),        
          'email' => $request->email,
    ]);     

    $host = $request->getHost();
   $xsrfCookie = $request->cookie('XSRF-TOKEN');
log::info("mycsrf cookies");
log::info($xsrfCookie);
    // Laravel session token (CSRF token stored in session)
    $csrfSession = $request->session()->token();

    \Log::info('[XSRF_DEBUG]', [
        'host' => $host,
        'xsrf_cookie_present' => !empty($xsrfCookie),
        'xsrf_cookie_preview' => $xsrfCookie ? substr($xsrfCookie, 0, 20) . '...' : null,
        'csrf_session_token_preview' => $csrfSession ? substr($csrfSession, 0, 20) . '...' : null,
        'all_cookie_names' => array_keys($request->cookies->all()),
    ]);

    // 1️⃣ Validate email
    $email = trim((string) $request->email);
    log::info($email);

      $user = $this->kcSync->resolveUser($email);
     $tokens = $this->ac->syncKeycloakAndStoreTokens($user, false);

    session([
        'kc_email' => $email,
    ]);
        Log::info('[EXCHANGE] Email stored in session', [
            'email' => session('kc_email')
        ]);

// 4️⃣ SAFE token logging (DO NOT LOG FULL TOKEN)

    Log::info('[EXCHANGE] Tokens received', [
        'has_access_token'  => !empty($tokens['access_token']),
        'access_token_len'  => isset($tokens['access_token'])
                                ? strlen($tokens['access_token'])
                                : 0,
        'has_refresh_token' => !empty($tokens['refresh_token']),
        'expires_in'        => $tokens['expires_in'] ?? null,
        'token_type'        => $tokens['token_type'] ?? null,
    ]);

    $accessToken = (string) $tokens['access_token'];
    $refreshToken = (string) $tokens['refresh_token'];

    if ($accessToken === '') {
        return response()->json(['error' => 'No access token'], 400);
    }

    // 1) Validate token (access first, fallback refresh/offline)
    // 3) Decide cookie domain

    $cookieDomain = $this->resolveCookieDomain($request);

    // 4) Optional: sync+store KC tokens for that user (reuse function)
    $this->syncUserTokensIfExists($email);

    // 5) Set cookie (access token only)
    $cookieAccess = $this->makeAccessCookie($accessToken, $cookieDomain, $request);

    // 6) Response with CORS headers
    return $this->corsJsonSuccess($request, $cookieAccess);
}
Enter fullscreen mode Exit fullscreen mode

Middleware-Based Auto Login

Why Middleware Is Critical

Users should not manually log in again when visiting another application.

This is handled by Laravel middleware, which runs before protected routes.

What the Middleware Does

On every request:

Reads the authentication cookie

Validates the token structure

Checks token validity (expiry, issuer, audience)

Extracts user identity

Confirms the token has not been revoked

Automatically logs the user into Laravel

The middleware acts as a security gatekeeper:

Prevents expired or forged tokens

Ensures revoked sessions cannot reappear

Maintains stateless verification logic

This design enables transparent SSO without sharing sessions across domains.

Cross-Domain Session Control

Token Storage as a Kill Switch

Each application stores token references in its own database.

This allows:

Centralized revocation

Session invalidation

Protection against replay attacks

Even if a browser still holds a cookie:

Middleware checks database state

Revoked tokens are immediately rejected

This ensures logout consistency and security.


class KeycloakAutoLogin
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    protected $utilityController;
    protected $kcSync;
    // Dependency Injection in constructor

    public function __construct(
        UtilityFunctionsController $utilityController,
        KeycloakUserSyncService $kcSync,
        KeycloakService $kc,
        AuthenticatedSessionController $ac,
    ) 

    {
        $this->utilityController = $utilityController;

        $this->kc            = $kc;
         $this->ac            = $ac;
          $this->kcSync            = $kcSync;
    }
public function handle(Request $request, Closure $next)
{
    Log::info('[KC_AUTO] check', [
        'host' => $request->getHost(),
        'url'  => $request->fullUrl(),
        'https'=> $request->secure() ? 'YES' : 'NO',
    ]);

    // Optional: if already logged-in, skip auto login
 $email = session('kc_email');
 log::info("my session email");
  log::info($email);

      $host = $request->getHost();
   $xsrfCookie = $request->cookie('XSRF-TOKEN');
log::info("mycsrf cookies");
log::info($xsrfCookie);
    // Laravel session token (CSRF token stored in session)
    $csrfSession = $request->session()->token();

    \Log::info('[XSRF_DEBUG]', [
        'host' => $host,
        'xsrf_cookie_present' => !empty($xsrfCookie),
        'xsrf_cookie_preview' => $xsrfCookie ? substr($xsrfCookie, 0, 20) . '...' : null,
        'csrf_session_token_preview' => $csrfSession ? substr($csrfSession, 0, 20) . '...' : null,
        'all_cookie_names' => array_keys($request->cookies->all()),
    ]);

    $accessToken = $this->getAccessToken($request);
    if (!$accessToken) {
        return $next($request);
    }




    $payload = $this->decodeJwtPayload($accessToken);
    if (!$payload) {
        return $next($request);
    }

    if (!$this->passesJwtGuards($payload)) {
        return $next($request);
    }

    $email = $this->extractEmail($payload);

    Log::info('[KC_AUTO][DEBUG] email extraction result', [
    'email' => $email,

]);



    if (!$email) {
        Log::warning('[KC_AUTO] JWT missing email/preferred_username');
        return $next($request);
    }

    // Your existing logic: if local DB access_token is null then logout
    if ($this->localTokenRowIsNull($email)) {
         Log::warning('localTokenRowIsNulled_username');
        $this->forceLogout($request);
        return $next($request);
    }

    $claims = $this->extractClaims($payload);


    return $this->autoLoginLaravelUser(
        $claims['email'],
        $claims['phone'],
        $claims['kcUserId'],
        $claims['username'],
        $claims['role'],
        $claims['status'],
        $claims['address'],
        $claims['sms_notification'],
        $claims['email_notification'],
        $claims['whatsapp_notification'],
         $claims['email_verified_at'],
        $claims['number_verified_at'],
        $request,
        $next
    );
}
private function buildSafeReturnTo(Request $request): string
{
    $path = $request->getPathInfo();      // "/drivers" or "/auth/keycloak/silent"
    $qs   = $request->getQueryString();   // "a=1&b=2"

    // ✅ Never return_to silent routes (prevents recursion)
    if (str_starts_with($path, '/auth/keycloak/silent')) {
        return '/';
    }

    // ✅ Remove nested return_to if present in query
    if ($qs) {
        parse_str($qs, $arr);
        if (isset($arr['return_to'])) {
            unset($arr['return_to']);
            $qs = http_build_query($arr);
        }
    }

    $returnTo = $path . ($qs ? ('?' . $qs) : '');

    // ✅ extra hard safety
    if ($returnTo === '' || str_contains($returnTo, '/auth/keycloak/silent')) {
        return '/';
    }

    return $returnTo;
}
/* ===================== Function Objects ===================== */

private function getAccessToken(Request $request): ?string
{
    // 1) Best: Laravel parsed cookie
    $token = $request->cookie('keycloak_access_token');
    if (!empty($token)) {
        return (string) $token;
    }

    // 2) Fallback: header parsing (your old logic, safer)
    $cookieHeader = (string) $request->header('Cookie', '');
    if ($cookieHeader === '') return null;

    if (preg_match('/keycloak_access_token=([^;]+)/', $cookieHeader, $m)) {
        return $m[1] ?? null;
    }

    return null;
}

private function passesJwtGuards(array $payload): bool
{
    return $this->checkNotExpired($payload)
        && $this->checkIssuer($payload)
        && $this->checkAudience($payload);
}

private function checkNotExpired(array $payload): bool
{
    $now = time();
    $exp = $payload['exp'] ?? null;

    if (!$exp || $exp < $now) {
        Log::warning('[KC_AUTO] token expired', ['exp' => $exp, 'now' => $now]);
        return false;
    }
    return true;
}

private function checkIssuer(array $payload): bool
{
    $realm = (string) env('KEYCLOAK_REALM');
    $base  = rtrim((string) env('KEYCLOAK_BASE_URL'), '/');

    $expected = "{$base}/realms/{$realm}";
    $actual   = $payload['iss'] ?? null;

    if ($actual !== $expected) {
        Log::warning('[KC_AUTO] iss mismatch', [
            'expected' => $expected,
            'actual'   => $actual,
        ]);
        return false;
    }

    return true;
}

private function checkAudience(array $payload): bool
{
    $expectedAud = (string) env('KEYCLOAK_CLIENT_ID');
    $aud = $payload['aud'] ?? [];

    if (is_string($aud)) $aud = [$aud];
    if (!is_array($aud)) $aud = [];

    if ($expectedAud !== '' && !in_array($expectedAud, $aud, true)) {
        Log::warning('[KC_AUTO] aud mismatch', [
            'expected' => $expectedAud,
            'actual'   => $aud,
        ]);
        return false;
    }

    return true;
}

private function extractEmail(array $payload): ?string
{
    return $payload['email']
        ?? $payload['preferred_username']
        ?? null;
}

private function localTokenRowIsNull(string $email): bool
{
    $user = User::query()->where('email', $email)->first();
log::info($user);
    if (!$user) {
        // If no user, do not logout, just skip auto-login
        return false;
    }

    $row = UserKeycloakToken::where('user_id', $user->id)->value('access_token');
log::info($row);
    return $row === null;
}

private function forceLogout(Request $request): void
{
    Auth::logout();
    $request->session()->invalidate();
    $request->session()->regenerateToken();

    Log::info('[KC_AUTO] forced logout (token row null)');
}

private function extractClaims(array $payload): array
{
    $now = now(); // single Carbon instance
    return [
        'email'    => $this->extractEmail($payload),
        'phone'    => $payload['phone'] ?? ($payload['phone_number'] ?? null),
        'username' => $payload['preferred_username'] ?? null,
        'kcUserId' => $payload['sub'] ?? null,

        'role'     => $payload['role'] ?? 'user',
        'status'   => $payload['status'] ?? 'active',
        'address'  => $payload['address'] ?? null,

        // handle possible typo with space
        'sms_notification'      => $payload['sms_notification'] ?? ($payload[' sms_notification'] ?? "1"),
        'email_notification'    => $payload['email_notification'] ?? "1",
        'whatsapp_notification' => $payload['whatsapp_notification'] ?? "1",
        'email_verified_at' => $now,
        'number_verified_at' => $now,
    ];
}
private function decodeJwtPayload(string $jwt): ?array
{
    $parts = explode('.', $jwt);

    if (count($parts) !== 3) {
        Log::warning("⚠️ [KC_AUTO] JWT malformed, expected 3 parts", [
            'parts_count' => count($parts),
        ]);
        return null;
    }

    // [0] = header, [1] = payload, [2] = signature
    $payloadB64 = $parts[1];

    $json = $this->base64UrlDecode($payloadB64);
    if ($json === null) {
        Log::warning("⚠️ [KC_AUTO] Failed base64url decode for JWT payload");
        return null;
    }

    $data = json_decode($json, true);
    if (!is_array($data)) {
        Log::warning("⚠️ [KC_AUTO] JWT payload is not valid JSON", [
            'json' => $json,
        ]);
        return null;
    }

    return $data;
}

/**
 * Helper for base64url decoding (used by JWT).
 */
private function base64UrlDecode(string $input): ?string
{
    $remainder = strlen($input) % 4;
    if ($remainder) {
        $padLen = 4 - $remainder;
        $input .= str_repeat('=', $padLen);
    }

    $decoded = base64_decode(strtr($input, '-_', '+/'), true);

    return $decoded === false ? null : $decoded;
}




private function autoLoginLaravelUser(
    $email,
    $phone,
    $kcUserId,
    $username,
    $role,
    $status,
    $address,
    $smsNotification,
    $emailNotification,
    $whatsappNotification,
    $emailverified,
    $numberverified,
    Request $request,
    Closure $next
) {
    $email = (string) $email;

    $user = $this->findOrCreateUserFromKc($email, $phone, $kcUserId, $username, $role, $status, $address,
        $smsNotification, $emailNotification, $whatsappNotification,$emailverified,$numberverified
    );

    // ✅ reuse your function (only once)
    $this->ac->syncKeycloakAndStoreTokens($user);

    $this->loginAndRegenerate($request, $user);

    return $next($request);
}

private function findOrCreateUserFromKc(
    string $email,
    $phone,
    $kcUserId,
    $username,
    $role,
    $status,
    $address,
    $smsNotification,
    $emailNotification,
    $whatsappNotification,
    $emailverified,
    $numberverified
): User {
    $user = User::query()->where('email', $email)->first();

    if (!$user) {
        return $this->createUserFromKc($email, $phone, $kcUserId, $username, $role, $status, $address,
            $smsNotification, $emailNotification, $whatsappNotification,$emailverified,$numberverified
        );
    }

    $this->updateUserFromKc($user, $phone, $kcUserId, $username, $role, $status, $address,
        $smsNotification, $emailNotification, $whatsappNotification,$emailverified,$numberverified
    );

    return $user;
}

private function createUserFromKc(
    string $email,
    $phone,
    $kcUserId,
    $username,
    $role,
    $status,
    $address,
    $smsNotification,
    $emailNotification,
    $whatsappNotification,
    $emailverified,
    $numberverified
): User {
    $user = new User();

    $user->name  = $username ?: strstr($email, '@', true);
    $user->email = $email;

    // Your column is `number` (not phone)
    $user->number = $phone ?: null;

    $user->kc_user_id            = $kcUserId ?: null;
    $user->role                  = $role ?: 'user';
    $user->status                = $status ?: 'active';
    $user->sms_notification      = $smsNotification ?? "1";
    $user->email_notification    = $emailNotification ?? "1";
    $user->whatsapp_notification = $whatsappNotification ?? "1";
    $user->address               = $address ?: null;
    $user->email_verified_at = $emailverified ?? "1";
    $user->number_verified_at  = $numberverified ?: null;

    $user->password = bcrypt(\Illuminate\Support\Str::random(32));
    $user->save();

    Log::info('[KC_AUTO] user created', [
        'user_id' => $user->id,
        'email'   => $email,
    ]);

    return $user;
}

private function updateUserFromKc(
    User $user,
    $phone,
    $kcUserId,
    $username,
    $role,
    $status,
    $address,
    $smsNotification,
    $emailNotification,
    $whatsappNotification,
     $emailverified,
    $numberverified
): void {
    $updates = [];

    // NOTE: DB column is number
    if ($phone && $user->number !== $phone) {
        $updates['number'] = $phone;
    }

    if ($kcUserId && $user->kc_user_id !== $kcUserId) {
        $updates['kc_user_id'] = $kcUserId;
    }

    if ($username && $user->name !== $username) {
        $updates['name'] = $username;
    }

    if ($role && $user->role !== $role) {
        $updates['role'] = $role;
    }

    if ($status && $user->status !== $status) {
        $updates['status'] = $status;
    }

    if ($address && $user->address !== $address) {
        $updates['address'] = $address;
    }

    if ($smsNotification !== null && $user->sms_notification !== $smsNotification) {
        $updates['sms_notification'] = $smsNotification;
    }

    if ($emailNotification !== null && $user->email_notification !== $emailNotification) {
        $updates['email_notification'] = $emailNotification;
    }

    if ($whatsappNotification !== null && $user->whatsapp_notification !== $whatsappNotification) {
        $updates['whatsapp_notification'] = $whatsappNotification;
    }

  if ($emailverified && $user->email_verified_at !== $emailverified) {
        $updates['email_verified_at'] = $emailverified;
    }

    if ($numberverified && $user->number_verified_at !== $numberverified) {
        $updates['number_verified_at'] = $numberverified;
    }

    if (!empty($updates)) {
        $user->update($updates);

        Log::info('[KC_AUTO] user updated', [
            'user_id' => $user->id,
            'updates' => array_keys($updates),
        ]);
    }
}

private function loginAndRegenerate(Request $request, User $user): void
{
    Auth::login($user);
    $request->session()->regenerate();
}


}
Enter fullscreen mode Exit fullscreen mode

Global Logout Architecture

Why Logout Is Hard in SSO

Logout is more complex than login because:

Multiple applications are involved

Sessions exist across domains

Browsers enforce cookie isolation

A simple local logout is insufficient.

Broadcast Logout Strategy

When a user logs out:

A browser-side broadcast notifies all peer applications

Each application:

Revokes its local token

Destroys its session

The originating application performs its own logout

This ensures:

No application remains authenticated

No stale session survives

User trust is maintained


public function destroy(Request $request)
    {      
           $id = Auth()->user()->id;

    UserKeycloakToken::where('user_id', $id)
    ->update([
        'access_token' => null,   // ✅ make it NULL
        'revoked_at'   => time(), // ✅ INT timestamp
    ]);


       //====================old part below starts=========================//
        Auth::guard('web')->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();
         return response()->view('auth.logout-call-traccar');
    //    return redirect('/');   // ✅ THIS is missing in your code
    }
Enter fullscreen mode Exit fullscreen mode
 public function ssologout(Request $request)
{
    Log::info('[SSO_LOGOUT] Hit', [
        'host'   => $request->getHost(),
        'origin' => $request->headers->get('origin'),
    ]);

    // 1) Get email from request (you can send it from Domain A)
    $email = $request->input('email');
    // OPTIONAL fallback: try from Authorization token (if you send Bearer) 

    Log::info('[SSO_LOGOUT] Email received', ['email' => $email]);

    // 2) Check if user exists in this domain DB
    $user = User::where('email', $email)->first();

    if (!$user) {
        Log::warning('[SSO_LOGOUT] User not found in this domain, bypassing', [
            'email' => $email,
            'host'  => $request->getHost(),
        ]);
        return response()->json(['ok' => true, 'skipped' => 'user_not_found']);
    }

    // 3) Revoke tokens for this user in THIS domain
    $affected = UserKeycloakToken::where('user_id', $user->id)

       ->update([
        'access_token' => null,   // ✅ make it NULL
        'revoked_at'   => time(), // ✅ INT timestamp
    ]);

    Log::info('[SSO_LOGOUT] Tokens revoked in this domain', [
        'user_id'   => $user->id,
        'affected'  => $affected,
        'host'      => $request->getHost(),
    ]);

    // OPTIONAL: also logout local session if any
    Auth::guard('web')->logout();
    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return response()->json([
        'ok'       => true,
        'revoked'  => $affected,
        'domain'   => $request->getHost(),
    ]); 
}
Enter fullscreen mode Exit fullscreen mode

Own Client Logout vs Peer Logout

The architecture distinguishes between:

Own client logout
The application where the logout originated

Peer logout
Other applications that must also revoke access

Both paths:

Clear token records

Destroy sessions

Prevent re-authentication via middleware

Security Principles Behind the Design

This architecture follows key security principles:

Zero Trust between services

No shared sessions

Token-based authentication

Database-backed revocation

Minimal data exchange

Defense-in-depth via middleware

Each application can independently enforce security without relying on others.

Benefits of This Architecture

Seamless user experience

Strong security boundaries

Scales across many domains

No cookie sharing hacks

Clear audit and revocation control

Easy to extend with roles and permissions

Conclusion

This end-to-end SSO architecture demonstrates how modern applications can implement secure, scalable, and user-friendly authentication across multiple domains.

By combining:

Google OAuth for user trust

Keycloak for identity authority

Laravel middleware for enforcement

Token-based session control

Broadcast logout for consistency

chatgpt

Top comments (0)