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

Best Free Programming Ebooks to Learn Software Development

Learning modern technology can feel overwhelming when tutorials are scattered across dozens of websites. Many software developers, engineering students, and IT professionals struggle to find comprehensive study…

Read More

FinOps Training: Understanding Cost Visibility and Optimization

Introduction Moving workloads to the cloud can simplify infrastructure management, but it can also introduce a new financial challenge. Teams can provision resources whenever they need them,…

Read More

The Architecture of Intelligent DataOps: From Ingestion to Automation

Introduction Modern organizations run on distributed data ecosystems. On any given day, an enterprise environment ingests, transforms, and serves petabytes of records sourced from transactional databases, external…

Read More

Transforming Data Reliability: How DataOps Platforms Drive Proactive Monitoring

Introduction Traditional monitoring focuses almost entirely on infrastructure availability and binary job execution states—whether a server is up or whether a task completed. However, modern distributed environments…

Read More

Navigating Pipeline Risks with Expert DevSecOps Consulting Services

Software delivery moves faster today than at any point in technological history. High-performing engineering organizations push code changes to production multiple times a day using automated deployment…

Read More

DevOps Support Services: Key Practices for Stable Production Environments

Introduction Running modern software infrastructure is an ongoing responsibility. A development team may successfully launch an application, but keeping that application reliable in production requires continuous attention….

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