High-Level Architecture
- User logs into Laravel via Keycloak (Socialite or OIDC).
- Laravel optionally ensures a row exists in Flarum’s
userstable (email/username). - Laravel opens a popup to Flarum’s
sso-relay.html. - 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 -WThen 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’;
Step 5 — Add the SSO Launcher in Laravel (popup)
My ForumStep 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,
]);
