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

Elevating DevSecOps and SRE Efficiency with a Software Delivery Governance Platform

Introduction Enterprise software engineering has reached a tipping point where systemic complexity threatens structural delivery stability. Modern engineering organizations routinely support highly fragmented ecosystems populated by hundreds…

Read More

Best Hospitals in India for International Patients and Affordable Surgery Costs

Introduction Global healthcare costs are rising rapidly, forcing many families to look for alternative solutions when facing serious medical diagnoses. In countries like the United States, the…

Read More

A Beginner Guide to Data Analytics Automation using Enterprise DataOps Workflows

Organizations rely heavily on fast, accurate, and reliable business intelligence to make critical commercial decisions. Whether it is predicting customer churn or managing real-time inventory levels, business…

Read More

Integrating AI Tools in DataOps Pipelines: A Comprehensive Guide

Introduction Modern organizations deal with a massive influx of data from applications, IoT devices, and cloud services. Managing these data volumes requires speed, accuracy, and agility. Traditional…

Read More

Modern Cloud DataOps Platforms for Reliable Data Pipelines

Introduction Modern organizations depend heavily on data. Every department, from finance and sales to healthcare, manufacturing, marketing, and customer support, needs reliable data to make better decisions….

Read More

Advanced DataOps Monitoring Tools for Enterprises: A Comprehensive Implementation Guide

Introduction Enterprise data environments are becoming more complex as organizations depend on cloud platforms, data lakes, data warehouses, real-time pipelines, analytics tools, and automated workflows. When one…

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