Debug School

rakesh kumar
rakesh kumar

Posted on

Seamless Multi-Domain Login with Laravel and Keycloak: OTP Login Without Using Keycloak UI

Part A – Keycloak setup (UI mein kya fill karna hai)

Part B – Laravel setup (env, config, routes, controllers, logout, sab)

So that:

Login UI = tumhara Laravel OTP form (screenshot wala)

Internal auth provider = Keycloak (silent, no UI)

SSO across all domains = motoshare.in, .jp, .ca, .us, …

Logout on any one domain = logout from ALL domains, but UI mein sirf return redirect('/') dikhna chahiye.

Part A – Keycloak setup (UI mein kya fill karna hai)

PART A – Keycloak Setup (Once for all domains)
0️⃣ Realm

Realm name: motoshare

All MotoShare domains will use this single realm.

1️⃣ Create Client: motoshare-web

In Keycloak Admin:

Go to: Realm → Clients → Create client

Fill:

Client type: OpenID Connect

Client ID: motoshare-web

Click Next

Capability config:

Client authentication: ON (Confidential client – hum client_secret use karenge)

Standard flow: ON

Direct access grants: ON ✅ (important for grant_type=password)

Service accounts: OFF (optional)

Click Save.

Now you are on the Settings tab of client motoshare-web.

2️⃣ Settings tab – Multi-domain values
General

Client ID: motoshare-web

Name: MotoShare Websites

Root URL: (optional, you can leave empty or set https://motoshare.in)

Valid redirect URIs

Even though abhi hum direct-grant use karenge, future ke liye callback rakhna safe hai:

Add one per domain:

https://motoshare.in/auth/keycloak/callback
https://motoshare.jp/auth/keycloak/callback
https://motoshare.ca/auth/keycloak/callback
https://motoshare.us/auth/keycloak/callback
https://motoshare.sg/auth/keycloak/callback
... (jitne domains hain)

Valid post logout redirect URIs
https://motoshare.in/
https://motoshare.jp/
https://motoshare.ca/
https://motoshare.us/
https://motoshare.sg/
...

Web origins
https://motoshare.in
https://motoshare.jp
https://motoshare.ca
https://motoshare.us
https://motoshare.sg
...

Tip: You can also use + in Web Origins as wildcard (if allowed in your version), but listing exact domains is safer.

3️⃣ Credentials tab

Go to Clients → motoshare-web → Credentials

Copy Client Secret – use in .env as KEYCLOAK_CLIENT_SECRET.

4️⃣ Direct Access Grant (important)

Make sure under Settings of motoshare-web:

Direct Access Grants Enabled: ✅ ON

Ye hi allow karta hai humko:

grant_type = password

PART B – Laravel Setup (common for all MotoShare domains)

Same Laravel code, bas .env mein APP_URL har domain ka alag hoga.

1️⃣ .env variables

Har MotoShare Laravel app (IN, JP, CA, …) mein:

# APP URL per domain
APP_URL=https://motoshare.in   # (JP app mein https://motoshare.jp, etc.)

# Keycloak config (same for all domains)
KEYCLOAK_BASE_URL=https://auth.myhospitalnow.com
KEYCLOAK_REALM=motoshare
KEYCLOAK_CLIENT_ID=motoshare-web
KEYCLOAK_CLIENT_SECRET=YOUR_CLIENT_SECRET_HERE
Enter fullscreen mode Exit fullscreen mode

2️⃣ config/keycloak.php

Create file: config/keycloak.php

<?php

return [
    'base_url'       => env('KEYCLOAK_BASE_URL', 'https://auth.myhospitalnow.com'),
    'realm'          => env('KEYCLOAK_REALM', 'motoshare'),
    'client_id'      => env('KEYCLOAK_CLIENT_ID', 'motoshare-web'),
    'client_secret'  => env('KEYCLOAK_CLIENT_SECRET', null),
];
Enter fullscreen mode Exit fullscreen mode

Then:

php artisan config:clear
php artisan cache:clear

3️⃣ Routes (routes/web.php)

We’ll assume:

Tumhara login screen = OtpLoginController@showForm

AJAX OTP verify + login = OtpLoginController@store

Logout = Auth\LogoutController@destroy


use App\Http\Controllers\Auth\OtpLoginController;
use App\Http\Controllers\Auth\LogoutController;

// Show your custom OTP login form
Route::get('/login', [OtpLoginController::class, 'showForm'])->name('login');

// AJAX endpoint: verify OTP + Laravel login + Keycloak silent login
Route::post('/login/verify-otp', [OtpLoginController::class, 'store'])->name('login.verify');

// Logout (global SSO logout, silent)
Route::post('/logout', [LogoutController::class, 'destroy'])->name('logout');

// (Optional) keep for future if you ever use Keycloak redirect login
Route::get('/auth/keycloak/callback', function () {
    abort(404); // or a future implementation
})->name('keycloak.callback');
Enter fullscreen mode Exit fullscreen mode

4️⃣ OtpLoginController – Full Implementation

app/Http/Controllers/Auth/OtpLoginController.php

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
use App\Models\User;

class OtpLoginController extends Controller
{
    // Show your custom login form (phone + OTP)
    public function showForm()
    {
        return view('auth.login-otp'); // tumhara Blade file
    }

    // Verify OTP + Laravel login + Keycloak silent login
    public function store(Request $request)
    {
        Log::info('Request data:', $request->all());
        Log::info("coming store here");

        // 1️⃣ OTP VALIDATION
        $inputOtp  = $request->input('otp');
        $storedOtp = $request->session()->get('otp');

        if ($inputOtp != $storedOtp) {
            return response()->json([
                'success' => false,
                'message' => 'The OTP you entered is incorrect.'
            ], 400);
        }

        Log::info("OTP matches");

        // 2️⃣ USER FIND (by phone or email)
        $phone = $request->input('phone');
        $user  = User::where('number', $phone)->first();

        if (!$user) {
            $user = User::where('email', $phone)->first();
        }

        if (!$user) {
            Log::info("User not found with phone/email: " . $phone);
            return response()->json([
                'success' => false,
                'message' => 'Authentication failed. User not found.'
            ], 404);
        }

        // 3️⃣ LARAVEL LOGIN
        Auth::login($user);
        $request->session()->regenerate();

        Log::info("Laravel login successful. Now syncing with Keycloak...");

        // 4️⃣ SILENT KEYCLOAK LOGIN (Direct Access Grant)
        $keycloakBase  = config('keycloak.base_url');
        $realm         = config('keycloak.realm');
        $clientId      = config('keycloak.client_id');
        $clientSecret  = config('keycloak.client_secret');

        $tokenUrl = "{$keycloakBase}/realms/{$realm}/protocol/openid-connect/token";

        try {
            $response = Http::asForm()->post($tokenUrl, [
                'grant_type'    => 'password',
                'client_id'     => $clientId,
                'client_secret' => $clientSecret,
                'username'      => $user->email,   // MUST exist in Keycloak
                'password'      => 'dummy',        // password policy ko relax karo / dummy
            ]);

            if ($response->failed()) {
                Log::error("Keycloak Direct Grant failed: " . $response->body());
            } else {
                $tokens = $response->json();
                $request->session()->put('kc_tokens', $tokens);
                Log::info("Keycloak tokens stored successfully");
            }
        } catch (\Exception $e) {
            Log::error("Error calling Keycloak token endpoint: " . $e->getMessage());
        }

        // 5️⃣ ROLE-BASED REDIRECT
        $role           = $user->role;
        $inputrouteName = $request->input('route_name');

        return response()->json([
            'success'      => true,
            'message'      => 'Logged in successfully!',
            'role'         => $role,
            'redirect_url' => $this->getRedirectUrlForRole($role, $inputrouteName),
        ]);
    }

    // Your existing method – adjust as needed
    protected function getRedirectUrlForRole($role, $inputrouteName = null)
    {
        // Example logic – isko tum apne existing code se replace kar sakte ho
        if ($inputrouteName) {
            return route($inputrouteName);
        }

        switch ($role) {
            case 'admin':
                return route('admin.dashboard');
            case 'partner':
                return route('partner.dashboard');
            default:
                return url('/'); // homepage
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Imports ka dhyan:

use Illuminate\Support\Facades\Http;

use Illuminate\Support\Facades\Log;

use Illuminate\Support\Facades\Auth;

5️⃣ LogoutController – Silent Global Logout (All Domains)

app/Http/Controllers/Auth/LogoutController.php

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class LogoutController extends Controller
{
    public function destroy(Request $request)
    {
        $baseUrl      = config('keycloak.base_url');
        $realm        = config('keycloak.realm');
        $clientId     = config('keycloak.client_id');
        $clientSecret = config('keycloak.client_secret');

        // get tokens from session
        $tokens  = $request->session()->get('kc_tokens', []);
        $idToken = $tokens['id_token'] ?? null;

        // 1️⃣ Laravel local logout
        Auth::logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        // 2️⃣ Silent global logout in Keycloak (NO redirect to Keycloak)
        if ($idToken) {
            $logoutUrl = "{$baseUrl}/realms/{$realm}/protocol/openid-connect/logout";

            try {
                Http::asForm()->post($logoutUrl, [
                    'client_id'     => $clientId,
                    'client_secret' => $clientSecret,
                    'id_token_hint' => $idToken,
                ]);

                Log::info("Keycloak global logout success");
            } catch (\Exception $e) {
                Log::error("Keycloak silent logout error: " . $e->getMessage());
            }
        } else {
            Log::info("No id_token found in session for Keycloak logout");
        }

        // 3️⃣ UI requirement: always redirect to '/' on same domain
        return redirect('/');
    }
}
Enter fullscreen mode Exit fullscreen mode

Ye hi code har domain pe same rahega (.in, .jp, .ca, …) →
Because Keycloak realm + client same hai, global SSO session nikal jayega → sab domain se logout.

6️⃣ Blade: Login & Logout Buttons

Navbar mein:

{{-- Show Login button if guest --}}
@guest
    <a href="{{ route('login') }}" class="nav-link">
        Login / Register
    </a>
@endguest

{{-- Show Logout if logged in --}}
@auth
    <form action="{{ route('logout') }}" method="POST" style="display:inline;">
        @csrf
        <button type="submit" class="nav-link btn btn-link p-0 m-0 align-baseline">
            Logout
        </button>
    </form>
@endauth
Enter fullscreen mode Exit fullscreen mode

Top comments (0)