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

Top DataOps Pipeline Testing Tools for Modern Data Teams

Introduction Modern data pipelines are complex distributed systems. A standard production workflow connects transactional databases, third-party APIs, object storage, streaming queues, transformation layers, cloud data warehouses, and…

Read More

Accelerating Pipeline Root Cause Analysis Using Telemetry and Graph Intelligence

Modern enterprise data architectures rely on an intricate web of batch jobs, streaming brokers, cloud warehouses, and SaaS APIs where standard task monitoring often falls short—a pipeline…

Read More

AI Agents and the Future of Intelligent Business Automation

Introduction From an engineering leadership perspective, the primary friction in enterprise software today is not model intelligence—it is operational determinism. Software teams can rapidly configure a conversational…

Read More

Building Reliable Software Delivery with DevOps Consulting Services

Introduction Modern engineering organizations face mounting pressure to accelerate deployment frequency without compromising production stability or data security. However, transitioning from fragmented release processes to automated, cloud-native…

Read More

AI Software Development Guide for Startups and Enterprise Teams

Introduction Engineering executives, CTOs, and technical directors face a common operational dilemma: engineering headcount grows, but feature velocity steadily drops. As application architectures expand into distributed services,…

Read More

Website Development Services: What Businesses Should Evaluate Before Hiring

Introduction Most website failures do not start in the design mockups; they originate deep within the backend infrastructure. When leadership teams treat web builds as purely visual…

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