How to Integrate Flarum with Keycloak and Enable SSO Login from a Laravel Project (Step-by-Step)

High-Level Architecture

  1. User logs into Laravel via Keycloak (Socialite or OIDC).
  2. Laravel optionally ensures a row exists in Flarum’s users table (email/username).
  3. Laravel opens a popup to Flarum’s sso-relay.html.
  4. Relay page silently hits /auth/<provider> (FoF OAuth Generic) → Flarum session is created using Keycloak → relay closes and returns control.

Step 1 — Install Flarum Extensions

composer require fof/extend:^1.3.3 fof/oauth:^1.7 blt950/oauth-generic:^1.0 –with-all-dependencies -W

Then enable in Flarum Admin → Extensions:

  • FoF OAuth
  • OAuth Generic (blt950/oauth-generic)
  • (FoF Extend is a dependency layer)
  • Generic Enable


php flarum cache:clear

Step 2 — Create a Keycloak Client for Flarum

In Keycloak (Realm → Clients → Create):

Client Type: OpenID Connect

Client ID: flarum (example)

Access Type: Public (often enough) or Confidential (then keep client secret)

Valid Redirect URIs:

http://forum/auth/generic

http://forum/*

Web Origins:

http://forum

Mappers (recommended):

preferred_username (built-in)

email (built-in; set as “Email” claim)

Ensure standard OIDC scopes include email, profile

Step 3 — Configure Flarum OAuth Provider (OAuth Generic)

Open Flarum Admin → Extensions → OAuth Generic and configure a provider, e.g. Keycloak:

  • Authorization URL:
    http://localhost:8080/realms/<REALM>/protocol/openid-connect/auth
  • Token URL:
    http://localhost:8080/realms/<REALM>/protocol/openid-connect/token
  • User Info URL:
    http://localhost:8080/realms/<REALM>/protocol/openid-connect/userinfo
  • Client ID / Secret: from your Keycloak client
  • Scopes: openid email profile
  • Button text/icon: “Login with Keycloak”

If you use FoF OAuth’s Keycloak recipe, configure that instead. OAuth Generic works universally.

Step 4 — Add the SSO Relay Page in Flarum

Create public/sso-relay.html in the forum host:

When we apply it in code, then remove # from the code
#const FORUM_ORIGIN = location.origin; // e.g., http://forum
#const OAUTH_PATH = ‘/auth/generic’;

Forum SSO Relay

Step 5 — Add the SSO Launcher in Laravel (popup)

My Forum

Step 6 — (Optional) Pre-provision Flarum users from Laravel

'connections' => [
  // ...
  'flarum' => [
    'driver' => 'mysql',
    'host' => env('FLARUM_DB_HOST', 'localhost'),
    'port' => env('FLARUM_DB_PORT', '3306'),
    'database' => env('FLARUM_DB_DATABASE', 'flarum'),
    'username' => env('FLARUM_DB_USERNAME', 'root'),
    'password' => env('FLARUM_DB_PASSWORD', ''),
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
    'strict' => false,
  ],
],

6.2 .env

FLARUM_DB_HOST=localhost
FLARUM_DB_PORT=3306
FLARUM_DB_DATABASE=flarum
FLARUM_DB_USERNAME=root
FLARUM_DB_PASSWORD=

6.3 Service to ensure user in Flarum (app/Services/FlarumBridge.php)

<?php
namespace App\Services;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class FlarumBridge
{
    public function ensureUser(string $email, ?string $username, ?string $displayName = null): int
    {
        $db = DB::connection('flarum');

        if ($existing = $db->table('users')->where('email', $email)->first()) {
            if ($displayName && $this->hasCol($db, 'users', 'nickname')) {
                $db->table('users')->where('id', $existing->id)->update(['nickname' => $displayName]);
            }
            return (int) $existing->id;
        }

        $candidate    = $username ?: (explode('@', $email)[0]);
        $base         = substr(preg_replace('/[^a-z0-9_]/i', '_', $candidate), 0, 30) ?: ('user_'.Str::random(6));
        $safeUsername = $this->unique($db, $base);

        $cols = $this->cols($db, 'users');
        $now  = now()->toDateTimeString();

        $data = [];
        if (in_array('username', $cols, true)) $data['username'] = $safeUsername;
        if (in_array('email', $cols, true))    $data['email']    = $email;
        if ($displayName && in_array('nickname', $cols, true)) $data['nickname'] = $displayName;
        if (in_array('is_email_confirmed', $cols, true)) $data['is_email_confirmed'] = 1;
        if (in_array('password', $cols, true)) $data['password'] = password_hash(Str::random(32), PASSWORD_BCRYPT);
        if (in_array('joined', $cols, true))   $data['joined']   = $now;

        return (int) $db->table('users')->insertGetId($data);
    }

    protected function unique($db, string $base): string {
        $name = $base; $i = 1;
        while ($db->table('users')->where('username', $name)->exists()) {
            $suf = '_'.$i++; $name = substr($base, 0, max(1, 30 - strlen($suf))) . $suf;
        }
        return $name;
    }
    protected function cols($db, string $table): array {
        $rows = $db->select("SHOW COLUMNS FROM `{$table}`");
        return array_map(fn($r) => $r->Field, $rows);
    }
    protected function hasCol($db, string $table, string $col): bool {
        return in_array($col, $this->cols($db, $table), true);
    }
}

6.4 Keycloak helpers (Admin lookup + /userinfo) app/Services/KeycloakService.php

<?php
namespace App\Services;

class KeycloakService
{
    protected function base(): string {
        $b = trim((string) env('KEYCLOAK_BASE_URL', ''));
        if ($b === '') throw new \Exception('KEYCLOAK_BASE_URL missing');
        if (!preg_match('~^https?://~i', $b)) $b = 'http://' . $b;
        return rtrim($b, '/');
    }
    protected function realm(): string {
        $r = trim((string) env('KEYCLOAK_REALM', ''));
        if ($r === '') throw new \Exception('KEYCLOAK_REALM missing');
        return $r;
    }

    public function getAdminAccessToken(): string {
        $url = $this->base()."/realms/".$this->realm()."/protocol/openid-connect/token";
        $post = [
          'client_id'     => env('KEYCLOAK_ADMIN_CLIENT_ID'),
          'client_secret' => env('KEYCLOAK_ADMIN_CLIENT_SECRET'),
          'grant_type'    => 'client_credentials',
        ];
        $ch = curl_init($url);
        curl_setopt_array($ch, [CURLOPT_POST=>true, CURLOPT_POSTFIELDS=>http_build_query($post), CURLOPT_RETURNTRANSFER=>true]);
        $resp = curl_exec($ch); if (curl_errno($ch)) throw new \Exception(curl_error($ch));
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
        if ($code!==200) throw new \Exception("Admin token HTTP {$code}: {$resp}");
        $j = json_decode($resp,true); if (empty($j['access_token'])) throw new \Exception('No admin access_token');
        return $j['access_token'];
    }

    public function findUserByEmail(string $email): ?array {
        $token = $this->getAdminAccessToken();
        $url = $this->base()."/admin/realms/".$this->realm()."/users?email=".urlencode($email)."&exact=true";
        $ch = curl_init($url);
        curl_setopt_array($ch,[CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$token],CURLOPT_RETURNTRANSFER=>true]);
        $resp = curl_exec($ch); if (curl_errno($ch)) throw new \Exception(curl_error($ch));
        $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
        if ($code!==200) throw new \Exception("Users search HTTP {$code}: {$resp}");
        $arr = json_decode($resp,true) ?: [];
        return $arr[0] ?? null;
    }

    public function getUserInfo(string $userAccessToken): array {
        $url = $this->base()."/realms/".$this->realm()."/protocol/openid-connect/userinfo";
        $ch = curl_init($url);
        curl_setopt_array($ch,[CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$userAccessToken],CURLOPT_RETURNTRANSFER=>true]);
        $resp = curl_exec($ch); if (curl_errno($ch)) throw new \Exception(curl_error($ch));
        $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
        if ($code!==200) throw new \Exception("userinfo HTTP {$code}: {$resp}");
        $d = json_decode($resp,true) ?: [];
        return [
          'email' => $d['email'] ?? null,
          'name'  => trim(($d['given_name'] ?? '').' '.($d['family_name'] ?? '')) ?: ($d['name'] ?? ($d['preferred_username'] ?? null)),
          'preferred_username' => $d['preferred_username'] ?? null,
        ];
    }
}

6.5 Controller endpoint routes/web.php

Route::post('/forum/provision', [\App\Http\Controllers\ForumProvisionController::class, 'goToForum'])
  ->middleware('auth')
  ->name('forum.provision');

Controller app/Http/Controllers/ForumProvisionController.php:

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Services\KeycloakService;
use App\Services\FlarumBridge;

class ForumProvisionController extends Controller
{
    public function goToForum(Request $request, KeycloakService $kc, FlarumBridge $flarum)
    {
        $rid = bin2hex(random_bytes(6));
        Log::info('[ForumSSO] start', ['rid'=>$rid,'url'=>$request->fullUrl(),'user_id'=>optional($request->user())->id]);

        // Try user token → /userinfo
        $email=null;$name=null;$pref=null;
        $userAccessToken = session('kc_access_token');
        if ($userAccessToken) {
            try { $info=$kc->getUserInfo($userAccessToken); $email=$info['email']??null; $name=$info['name']??null; $pref=$info['preferred_username']??null; }
            catch (\Throwable $e) { Log::warning('[ForumSSO] userinfo failed', ['rid'=>$rid,'err'=>$e->getMessage()]); }
        }

        // Fallback: admin lookup using local user email
        if (!$email) {
            $authEmail = optional($request->user())->email;
            if (!$authEmail) return response()->json(['ok'=>false,'error'=>'No email'], 422);
            $u = $kc->findUserByEmail($authEmail);
            if (!$u) return response()->json(['ok'=>false,'error'=>'Keycloak user not found'], 404);
            $email = $u['email'] ?? $authEmail;
            $name  = trim(($u['firstName'] ?? '').' '.($u['lastName'] ?? '')) ?: ($u['username'] ?? $authEmail);
            $pref  = $u['username'] ?? null;
        }

        $flarumId = $flarum->ensureUser($email, $pref, $name);
        Log::info('[ForumSSO] flarum ensureUser ok', ['rid'=>$rid,'flarum_user_id'=>$flarumId]);

        return response()->json(['ok'=>true,'flarum_id'=>$flarumId,'next'=>'http://forum/sso-relay.html']);
    }
}

Step 7 — Store Keycloak Tokens on Callback (Laravel)

In your Keycloak callback, after Socialite returns the user, store to session:

session([
  'kc_access_token'  => $keycloakUser->token,
  'kc_refresh_token' => $keycloakUser->refreshToken ?? null,
  'kc_id_token'      => property_exists($keycloakUser, 'idToken') ? $keycloakUser->idToken : null,
  'kc_profile'       => $keycloakUser->user,
]);

Related Posts

Travel eSIM Comparison Guide: Find Cheap International Data Plans for Any Trip

Introduction You land after a long flight. Your phone shows no signal. The airport SIM queue wraps around the corner. Your home carrier wants a huge fee…

Read More

Mobile Network Codes by Country: Complete Global Guide

Introduction Your phone connects to a cell tower in seconds. You never see the hidden work behind that link. Every SIM card carries secret digital ID numbers….

Read More

Restaurant Reservation Software Guide: Online Bookings, Live Table Management, and Front-of-House Control

Introduction Friday night arrives, and the dining room fills fast. Phones ring nonstop. Guests wait at the door. A paper notebook fills with crossed-out names and rushed…

Read More

Real-Time DataOps Monitoring Case Studies That Protect Your Business Dashboards

Introduction Imagine walking into a Monday morning team meeting ready to share great sales numbers. Suddenly, your manager points out that the revenue chart dropped to zero…

Read More

Unifying Data Culture Across Sales, Marketing, and Engineering

Different departments in your company often collect data in separate, isolated silos. Marketing tracks customer clicks in one tool, while sales logs deals in another spreadsheet. When…

Read More

Master Retail Predictive Analytics Using Modern DataOps Workflows

Retail stores face constant pressure to guess what buyers want next week. When shelves go empty, shoppers leave and buy elsewhere. When warehouses hold too much stock,…

Read More
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x