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

How DataOps and MLOps Work Together for Scalable AI Pipelines

Introduction In the current landscape of artificial intelligence, building a model is only the beginning. The real challenge for enterprise teams lies in the transition from a…

Read More

Evaluating Modern DataOps Tools Across Business Analytics Infrastructure

Introduction Managing data pipelines used to be a straightforward task for single analytics teams. Today, data ecosystems are complex, fast-moving, and frequently fragmented across multiple cloud environments….

Read More

Essential Guide To Choosing And Mastering Modern Enterprise DataOps Platforms

Introduction DataOps platforms represent the modern standard for orchestrating the entire data lifecycle, from initial ingestion to final analytics delivery. By applying agile engineering and automated DevOps…

Read More

Exploring Financial Operations Workflows in Modern Cloud Environments

Introduction The Certified FinOps Professional is the definitive benchmark for experts looking to master the intersection of finance, engineering, and business. As organizations transition from traditional data…

Read More

Strategic Certified FinOps Engineer integrates governance with cloud operations

Introduction The shift to cloud computing has fundamentally altered how businesses manage infrastructure, but it has also introduced significant financial complexities that many engineering teams struggle to…

Read More

Certified FinOps Manager Knowledge for Cloud Financial Governance

Introduction The shift toward cloud-native infrastructure has brought undeniable speed, but it has also introduced significant financial complexity. The Certified FinOps Manager is a professional designation designed…

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