Debug School

rakesh kumar
rakesh kumar

Posted on

Guide :How google login in Keycloak

Steps

User clicks “Sign in with Google”

Browser goes to Keycloak (with kc_idp_hint=google)

Keycloak does Google login and creates Keycloak SSO cookie

Keycloak redirects back to Laravel /auth/callback

Laravel exchanges code → gets tokens

Laravel reads claims (email/name/phone) and auto create/update user

Laravel logs user in and returns your existing JSON redirect_url

Laravel setup

Replace your existing routes
Remove (or keep but stop using) these:

Route::get('auth/google', [AuthenticatedSessionController::class, 'redirectToGoogle'])->name('google.login');
Route::get('auth/google/callback', [AuthenticatedSessionController::class, 'handleGoogleCallback']);
Enter fullscreen mode Exit fullscreen mode

Add these instead (Keycloak-broker Google):

Route::get('auth/google', [AuthenticatedSessionController::class, 'redirectToKeycloakGoogle'])
    ->name('google.login');

Route::get('auth/callback', [AuthenticatedSessionController::class, 'handleKeycloakCallback'])
    ->name('auth.kc.callback');
Enter fullscreen mode Exit fullscreen mode

AuthenticatedSessionController Controller

public function redirectToKeycloakGoogle(Request $request)
    {
        // Preserve where to go after login (you already do something similar)
        $routeName = (string) $request->query('route_name', session('login_from', 'login'));
        session(['login_from' => $routeName]);

        // You said you return JSON redirect_url sometimes:
        // If you need to store intended URL, do it here.
        if ($request->has('post_login_redirect')) {
            session(['post_login_redirect' => (string)$request->query('post_login_redirect')]);
        }

        $kcBase = rtrim(env('KEYCLOAK_BASE_URL'), '/');
        $realm  = env('KEYCLOAK_REALM');
        $client = env('KEYCLOAK_CLIENT_ID');

        $redirectUri = rtrim(env('APP_URL'), '/') . '/auth/callback';

        $state = Str::random(40);
        $nonce = Str::random(40);

        session([
            'kc_state' => $state,
            'kc_nonce' => $nonce,
        ]);

        $params = [
            'client_id'     => $client,
            'redirect_uri'  => $redirectUri,
            'response_type' => 'code',
            'scope'         => 'openid email profile',   // phone comes via mapper claim; keep scope basic
            'state'         => $state,
            'nonce'         => $nonce,

            // ✅ Force Keycloak to use Google IdP and skip KC username/password UI
            'kc_idp_hint'   => 'google',
        ];

        $authUrl = "{$kcBase}/realms/{$realm}/protocol/openid-connect/auth?" . http_build_query($params);

        Log::info('[KC_GOOGLE] Redirecting to Keycloak Google broker', [
            'redirect_uri' => $redirectUri,
        ]);

        return redirect()->away($authUrl);
    }

    /**
     * Step 2:
     * Keycloak -> Laravel callback with ?code=...&state=...
     * Exchange code for tokens, read claims, upsert user, login.
     */
    public function handleKeycloakCallback(Request $request)
    {
        Log::info('[KC_CB] /auth/callback hit', $request->query());

        // Keycloak may return error
        if ($request->has('error')) {
            Log::warning('[KC_CB] Keycloak returned error', [
                'error' => $request->query('error'),
                'desc'  => $request->query('error_description'),
            ]);

            // If user is already logged in locally, continue
            if (Auth::check()) {
                return $this->finalRedirectResponse(Auth::user(), $request);
            }

            // Otherwise send them to login page (your choice)
            return response()->json([
                'success' => false,
                'message' => 'SSO failed: ' . $request->query('error'),
            ], 401);
        }

        $code  = (string) $request->query('code', '');
        $state = (string) $request->query('state', '');

        $expectedState = (string) session()->pull('kc_state', '');

        if ($code === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
            Log::warning('[KC_CB] Missing code or invalid state', [
                'code_present' => $code !== '',
                'state_ok'     => ($expectedState !== '' && $state !== '' && hash_equals($expectedState, $state)),
            ]);

            return response()->json([
                'success' => false,
                'message' => 'Invalid SSO state or missing code',
            ], 400);
        }

        // Exchange code -> tokens
        $tokens = $this->exchangeAuthorizationCodeForTokens($code);

        if (!$tokens || empty($tokens['access_token'])) {
            return response()->json([
                'success' => false,
                'message' => 'Token exchange failed',
            ], 401);
        }

        // Read claims using UserInfo (recommended: no JWT parsing needed)
        $claims = $this->fetchUserInfo((string)$tokens['access_token']);

        if (!$claims || empty($claims['email'])) {
            Log::error('[KC_CB] Userinfo missing email', ['claims' => $claims]);
            return response()->json([
                'success' => false,
                'message' => 'SSO succeeded but email not available',
            ], 422);
        }

        // Upsert local user from claims
        $user = $this->upsertLaravelUserFromKeycloakClaims($claims);

        // Login locally
        Auth::login($user, true);
        $request->session()->regenerate();

        // Store KC tokens in your existing table (encrypted)
        $this->storeKeycloakTokens($user, $tokens);

        // Done -> return redirect_url in JSON (as you want)
        return $this->finalRedirectResponse($user, $request);
    }

    private function exchangeAuthorizationCodeForTokens(string $code): ?array
    {
        $kcBase  = rtrim(env('KEYCLOAK_BASE_URL'), '/');
        $realm   = env('KEYCLOAK_REALM');
        $client  = env('KEYCLOAK_CLIENT_ID');
        $secret  = env('KEYCLOAK_CLIENT_SECRET');

        $redirectUri = rtrim(env('APP_URL'), '/') . '/auth/callback';

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

        $resp = Http::asForm()->post($tokenUrl, [
            'grant_type'    => 'authorization_code',
            'client_id'     => $client,
            'client_secret' => $secret,
            'code'          => $code,
            'redirect_uri'  => $redirectUri,
        ]);

        Log::info('[KC_TOKEN] exchange status', ['status' => $resp->status()]);

        if (!$resp->ok()) {
            Log::error('[KC_TOKEN] exchange failed', ['body' => $resp->body()]);
            return null;
        }

        return $resp->json();
    }

    private function fetchUserInfo(string $accessToken): ?array
    {
        $kcBase = rtrim(env('KEYCLOAK_BASE_URL'), '/');
        $realm  = env('KEYCLOAK_REALM');

        $url = "{$kcBase}/realms/{$realm}/protocol/openid-connect/userinfo";

        $resp = Http::withToken($accessToken)->get($url);

        Log::info('[KC_USERINFO] status', ['status' => $resp->status()]);

        if (!$resp->ok()) {
            Log::error('[KC_USERINFO] failed', ['body' => $resp->body()]);
            return null;
        }

        return $resp->json();
    }

    /**
     * This is where your "phone claim" is used.
     * If your mapper puts phone into claim name "phone", it will be in $claims['phone'].
     */
    private function upsertLaravelUserFromKeycloakClaims(array $claims): User
    {
        $email = (string) ($claims['email'] ?? '');
        $name  = (string) ($claims['name'] ?? $claims['preferred_username'] ?? 'User');

        // Your mapper claim (from screenshot): Token Claim Name = "phone"
        $phone = $claims['phone'] ?? $claims['phone_number'] ?? null;

        // Keycloak subject = user id in KC
        $kcUserId = (string) ($claims['sub'] ?? '');

        $user = User::where('email', $email)->first();

        if (!$user) {
            // Create user
            $user = new User();
            $user->email = $email;
            $user->name  = $name;

            // set any defaults your app needs
            $user->role   = $user->role ?? 'user';
            $user->status = $user->status ?? 'active';

            // password required by Laravel even if you do SSO
            $user->password = bcrypt(Str::random(32));

            $user->kc_user_id = $kcUserId ?: null;

            // Save phone if present and your users table has "number"
            if ($phone) {
                $user->number = $this->normalizePhone($phone);
                $user->number_verified_at = now();
            }

            $user->email_verified_at = now();
            $user->save();

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

        // Update existing user (non-destructive)
        $dirty = false;

        if (!$user->kc_user_id && $kcUserId) {
            $user->kc_user_id = $kcUserId;
            $dirty = true;
        }

        if ($user->name === null || $user->name === '' || $user->name !== $name) {
            $user->name = $name;
            $dirty = true;
        }

        if ($phone) {
            $normalized = $this->normalizePhone($phone);
            if (!empty($normalized) && (string)$user->number !== (string)$normalized) {
                $user->number = $normalized;
                $dirty = true;
            }
        }

        if ($user->email_verified_at === null) {
            $user->email_verified_at = now();
            $dirty = true;
        }

        if ($dirty) {
            $user->save();
            Log::info('[KC_USER] updated', ['user_id' => $user->id, 'email' => $email]);
        }

        return $user;
    }

    private function storeKeycloakTokens(User $user, array $tokens): void
    {
        $accessToken  = (string) ($tokens['access_token'] ?? '');
        $refreshToken = $tokens['refresh_token'] ?? null;
        $expiresIn    = (int) ($tokens['expires_in'] ?? 0);

        if ($accessToken === '' || $expiresIn <= 0) {
            Log::warning('[KC_STORE] tokens missing essentials');
            return;
        }

        $encAccess  = encrypt($accessToken);
        $encRefresh = $refreshToken ? encrypt((string)$refreshToken) : null;

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

        Log::info('[KC_STORE] stored', [
            'user_id' => $user->id,
            'expires_at' => time() + $expiresIn,
        ]);
    }

    private function finalRedirectResponse(User $user, Request $request)
    {
        // Keep your existing JSON redirect style
        $role = (string) $user->role;
        $routeName = (string) session('login_from', $request->input('route_name', 'login'));

        // If you already have a function, keep it
        $redirectUrl = method_exists($this, 'getRedirectUrlForRole')
            ? $this->getRedirectUrlForRole($role, $routeName)
            : (string) session('post_login_redirect', '/');

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

    private function normalizePhone($phone): ?string
    {
        $p = preg_replace('/\D+/', '', (string)$phone);
        return $p !== '' ? $p : null;
    }
Enter fullscreen mode Exit fullscreen mode

Keycloack setup

Step 1) Open your realm

Login to Keycloak Admin Console

Top-left realm dropdown → select motoshare
Enter fullscreen mode Exit fullscreen mode

Step 2) Go to Identity Providers

Left sidebar:
Identity providers → click it

Now you have two cases:

Case A: “Google” already exists

You’ll see Google in the list → click Google

Case B: “Google” not added yet

Click Add provider

Select Google

It will open Google provider settings page

Step 3) Configure Google Provider (most important settings)

On the Google provider page, fill these:

A) Enable it

Enabled = ON
Enter fullscreen mode Exit fullscreen mode

B) Google credentials

Client ID = (from Google Cloud OAuth Client)

Client Secret = (from Google Cloud OAuth Client)
Enter fullscreen mode Exit fullscreen mode

C) Default scopes (recommended)

There is a field like “Default Scopes” (or similar):

Put: openid email profile

D) Redirect URI (you must copy this to Google Console)

Keycloak shows a Redirect URI for this Identity Provider.
It looks like:

https://auth.motoshare.in/realms/motoshare/broker/google/endpoint

Enter fullscreen mode Exit fullscreen mode

Copy it.

Step 4) Configure Google Cloud Console (must be done)

In Google Cloud Console → OAuth Client:

Go to APIs & Services → Credentials

Open your OAuth Client (Web application)

Under Authorized redirect URIs, add the Keycloak redirect:

https://auth.motoshare.in/realms/motoshare/broker/google/endpoint

Save

Without this, Google login will fail.

Step 5) First Login Flow (auto create / auto link)

This is the “Optional but recommended” part you asked.

In the Google provider page (Keycloak):
Find First Login Flow (dropdown)

Recommended option:

first broker login

This flow usually:

Creates user on first login, or

Links account if email matches (depending on settings)

Step 6) Make sure Keycloak will “trust email” for linking (recommended)

Still in Google provider settings:
Look for these options (names may vary slightly by KC version):

Trust Email = ON
(Keycloak will treat Google email as verified)

Store Token = Optional
(not required for your Laravel SSO, but can be ON)

Step 7) Ensure “Account linking / create user” works

Go to:
Authentication → Flows

Look for flow:

first broker login

Click it and verify it includes steps like:

“Create User If Unique”

“Verify Existing Account By Email” / “Confirm Link Existing Account” (depends)

If you want fully automatic linking by email (no UI prompt), that requires customizing the broker flow.
Most people keep default first.

Step 2 — Advanced settings (very important for silent SSO)

Scroll to Advanced settings (your 3rd screenshot).

Set these:

A) Scopes

In Scopes field, put:

openid email profile
Enter fullscreen mode Exit fullscreen mode

(If later you want refresh token from Google, you can add offline_access but not required for normal login.)

B) Accepts prompt=none forward from client = ON ✅

Turn ON:
Enter fullscreen mode Exit fullscreen mode

Accepts prompt=none forward from client

Why: you are doing silent check from Laravel using prompt=none.
If this is OFF, Keycloak may not behave as you expect for silent SSO flows.

C) Trust Email = ON (recommended)

Turn ON:

Trust Email
Enter fullscreen mode Exit fullscreen mode

So Keycloak treats Google email as verified and reduces extra verification steps.

D) Store tokens (optional)

Keep OFF unless you specifically need to store Google tokens inside Keycloak.

E) Hide on login page

Keep OFF if you want “Login with Google” button visible on Keycloak login screen.

Now click Save.

Top comments (0)