Flarum + Keycloak SSO: Front-Channel Logout, Local Logout, Silent Probe, and Token Cleanup

1) What this extension solves

Flarum is a SPA, so logging out in one tab or at the IdP can leave other tabs “half-logged-in.” This extension gives you:

  • Front-channel logout: Browser goes to Keycloak /logout and returns to your forum.
  • Local logout route: Deletes Flarum server session, auth cookies, and access_tokens rows so all devices/tabs are out.
  • Silent probe (prompt=none): Background check that detects when the Keycloak session is gone and triggers local logout.
  • SPA cleanup: Clears localStorage/sessionStorage so the UI matches the real auth state.

Result: correct, predictable SSO behavior across tabs and devices.

2) Folder layout

Create a local extension in your Flarum root:

3) Installation

From your Flarum root:

composer config repositories.wizbrand-kc-slo-addon path extensions/kc-slo-addon
composer require wizbrand/kc-slo-addon:dev-main --no-interaction --with-all-dependencies
php flarum extension:enable wizbrand-kc-slo-addon

4) Key settings to line up in Keycloak

In your Keycloak client (e.g., wizbrand-forum):

  • Valid Redirect URIs:
    https://<your-forum-domain>/kc/probe-return
  • Web Origins:
    Your forum origin (e.g., https://<your-forum-domain>)
  • Post Logout Redirect URI (what your code uses):
    https://<your-forum-domain>/?logged_out=1

If these don’t match exactly (including scheme and trailing slashes), silent probe or logout return won’t work.

5) How the PHP side is structured (why each part exists)

5.1 Dependency helpers

  • db() gives you the DB connection (to wipe access_tokens).
  • flCfg() gives you Flarum config (cookie profile).
  • forumUrl() returns the forum’s base URL.

Why: makes the rest of the code shorter and consistent.

5.2 Cookie normalization and expirers

  • cookieConf() reads cookie name/path/domain/secure/samesite from Flarum config.
  • expireCookieHeader() generates complete Set-Cookie headers to immediately expire cookies (both domainless and domain-specific variants).

Why: some environments require both domain and domainless expires to truly remove cookies.

5.3 Killing tokens in DB

  • killRememberFromRequest() reads the _remember cookie value and deletes the corresponding access_tokens row. If a user_id is known, it deletes all tokens for that user.

Why: if you only delete the browser cookie, other devices/tabs could stay logged in. Deleting DB rows kills all outstanding tokens.

5.4 Controllers

  • FrontChannelLogout:
    1. Removes tokens/cookies.
    2. Clears server session.
    3. Redirects to Keycloak logout with post_logout_redirect_uri.
    4. Sets short “bypass” cookies so the next silent probe doesn’t auto-relogin immediately.
  • LocalLogout:
    Hard local logout without contacting Keycloak. Deletes tokens/cookies/server session, then returns to your forum with ?logged_out=1.

Why: you need both. Front-channel is the “proper” RP-initiated global logout, while local is a fallback or forced cleanup.

5.5 Silent probe return

  • ProbeReturn:
    Registered as the redirect_uri for prompt=none checks.
    • If Keycloak returns id_token, SSO is alive → go back to where the user came from.
    • If Keycloak returns error=login_required, SSO is gone → go to /slo/local.

Why: proves the SSO session without any UI.

5.6 Frontend script injection

  • Rewires the SPA logout button to go through /slo/fc so you always end IdP session.
  • On ?logged_out=1 it clears SPA caches and reloads.
  • Runs a throttled silent probe in the top window to detect expired Keycloak sessions and act accordingly.

Why: Flarum’s SPA state must be cleared or the UI will still think you’re logged in.

6) Step-by-step testing

  1. Normal logout (front-channel):
    Click “Log out” in the forum. You should hit /slo/fc, then Keycloak, then return to your forum with ?logged_out=1. The page reloads as a guest.
  2. Local logout route:
    Open /slo/local directly. You should be logged out with tokens and cookies expired.
  3. Silent probe:
    While a forum tab is logged in, log out at Keycloak in another tab. Your forum tab should run the silent probe soon and redirect to /slo/local.
  4. Multi-tab check:
    Open two forum tabs. Logout in one; the other should also become guest after the redirect/cleanup.
  5. Database check:
    Inspect access_tokens before and after logout. Rows should be deleted for that user.

7) Common problems and quick fixes

  • Still logged in after logout:
    • Confirm response headers included Set-Cookie: <name>=; Expires=...; Max-Age=0.
    • Verify cookie domain/path match your actual cookie.
  • Silent probe loops or doesn’t return:
    • Ensure /kc/probe-return is in Keycloak Valid Redirect URIs.
    • The script throttles to ~8 seconds; reducing this too far can create rapid redirects.
    • Check the short-lived kc_probe_skip=1 after logout; it avoids immediate re-login.
  • Keycloak refuses post-logout redirect:
    • Add that exact URL into Valid Redirect URIs for the client.
  • Logout link still the default:
    • Some themes/extensions alter the markup. The script hooks app.session.logout() and also patches anchor links. Inspect DOM and tweak the selector if needed.

All code in one place

Put composer.json at extensions/kc-slo-addon/composer.json and extend.php at extensions/kc-slo-addon/extend.php, then run the three commands under “Installation”.

{
  "name": "wizbrand/kc-slo-addon",
  "description": "Keycloak RP logout helper (/slo) for Flarum",
  "type": "flarum-extension",
  "license": "MIT",
  "autoload": {
    "psr-4": {
      "Wizbrand\\KcSloAddon\\": "src/"
    }
  },
  "extra": {
    "flarum-extension": {
      "title": "KC SLO Addon",
      "icon": {
        "name": "fas fa-sign-out-alt",
        "backgroundColor": "#3a6df0",
        "color": "#fff"
      }
    }
  },
  "require": {
    "flarum/core": "^1.8"
  },
  "minimum-stability": "dev",
  "prefer-stable": true
}
<?php
namespace Wizbrand\KcSloAddon;

use Flarum\Extend;
use Flarum\Foundation\Config;
use Illuminate\Database\ConnectionInterface;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\HtmlResponse;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

/* ---------- helpers ---------- */
function db(): ConnectionInterface { return resolve(ConnectionInterface::class); }
function flCfg(): Config { return resolve(Config::class); }
function forumUrl(): string { return resolve('flarum.forum')->baseUrl(); } // e.g. https://www.wizbrand.com/forum

/** normalize cookie config (no nulls in headers) */
function cookieConf(): array
{
    $raw = flCfg()['cookie'] ?? 'flarum';
    $name = 'flarum';
    $domain = null;
    $path = '/';
    $secure = false;
    $samesite = 'Lax';
    if (is_string($raw) && $raw !== '') $name = $raw;
    elseif (is_array($raw)) {
        if (!empty($raw['name']))    $name = (string)$raw['name'];
        if (array_key_exists('domain', $raw) && is_string($raw['domain'])) $domain = $raw['domain'] ?: null;
        if (!empty($raw['path']))    $path = (string)$raw['path'];
        if (array_key_exists('secure', $raw)) $secure = (bool)$raw['secure'];
        if (!empty($raw['samesite'])) {
            $ss = ucfirst(strtolower((string)$raw['samesite']));
            if (in_array($ss, ['Lax', 'Strict', 'None'], true)) $samesite = $ss;
        }
    }
    return compact('name', 'domain', 'path', 'secure', 'samesite');
}
function expireCookieHeader(string $name, ?string $domain = null, ?string $path = null): string
{
    $cc = cookieConf();
    $exp = gmdate('D, d M Y H:i:s', time() - 3600) . ' GMT';
    $p = [];
    $p[] = $name . '=';
    $p[] = 'Expires=' . $exp;
    $p[] = 'Max-Age=0';
    $p[] = 'Path=' . ($path ?: $cc['path'] ?: '/');
    if ($domain) $p[] = 'Domain=' . $domain;
    elseif (!empty($cc['domain'])) $p[] = 'Domain=' . $cc['domain'];
    if (!empty($cc['secure'])) $p[] = 'Secure';
    $p[] = 'HttpOnly';
    if (!empty($cc['samesite'])) $p[] = 'SameSite=' . $cc['samesite'];
    return implode('; ', $p);
}
/** delete remember token (and optionally all tokens for that user) */
function killRememberFromRequest(ServerRequestInterface $req, string $rememberCookieName): void
{
    try {
        $rem = $req->getCookieParams()[$rememberCookieName] ?? null;
        if (!$rem) return;
        $row = db()->table('access_tokens')->where('token', $rem)->first();
        if ($row && isset($row->user_id)) {
            db()->table('access_tokens')->where('user_id', $row->user_id)->delete();
        } else {
            db()->table('access_tokens')->where('token', $rem)->delete();
        }
    } catch (\Throwable $e) {
    }
}

/* ---------- controllers ---------- */
final class FrontChannelLogout implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        // Keycloak config
        $kcBase = 'https://auth.holidaylandmark.com';
        $realm  = 'wizbrand';
        $client = 'wizbrand-forum';
        $post   = 'https://www.wizbrand.com/forum?logged_out=1'; // add Web Origin + Valid redirect in KC

        $base  = cookieConf()['name'];
        $cSess = $base . '_session';
        $cRem  = $base . '_remember';
        $cCsrf = $base . '_csrf';

        // DB cleanup
        killRememberFromRequest($request, $cRem);

        // server session cleanup (avoid null headers)
        $sess = $request->getAttribute('session');
        if ($sess) {
            try {
                $sess->clear();
                $sess->migrate(true);
                if (method_exists($sess, 'regenerateToken')) $sess->regenerateToken();
                else $sess->put('_token', bin2hex(random_bytes(32)));
            } catch (\Throwable $e) {
            }
        }

        $kcLogout = $kcBase . '/realms/' . rawurlencode($realm)
            . '/protocol/openid-connect/logout'
            . '?client_id=' . rawurlencode($client)
            . '&post_logout_redirect_uri=' . rawurlencode($post);

        $res = new RedirectResponse($kcLogout, 302);

        // expire cookies (with domain/path and domainless fallbacks)
        foreach ([$cSess, $cRem, $cCsrf] as $n) {
            $res = $res->withAddedHeader('Set-Cookie', expireCookieHeader($n));
            $res = $res->withAddedHeader('Set-Cookie', expireCookieHeader($n, null, '/'));
        }
        // prevent immediate probe-triggered re-login
        $res = $res->withAddedHeader('Set-Cookie', 'kc_probe_skip=1; Max-Age=20; Path=/; HttpOnly; SameSite=Lax');
        $res = $res->withAddedHeader('Set-Cookie', 'fo_forced_guest=1; Max-Age=20; Path=/; SameSite=Lax');

        return $res;
    }
}
final class LocalLogout implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $base  = cookieConf()['name'];
        $cSess = $base . '_session';
        $cRem  = $base . '_remember';
        $cCsrf = $base . '_csrf';

        // Also delete tokens for the logged-in actor (if present)
        try {
            $actor = $request->getAttribute('actor'); // Flarum\User\User or null
            if ($actor && isset($actor->id)) {
                db()->table('access_tokens')->where('user_id', $actor->id)->delete();
            }
        } catch (\Throwable $e) {
        }

        // Still try cookie-based cleanup (covers remember-me)
        killRememberFromRequest($request, $cRem);

        $res = new RedirectResponse('https://www.wizbrand.com/forum/?logged_out=1', 302);

        foreach ([$cSess, $cRem, $cCsrf] as $n) {
            $res = $res->withAddedHeader('Set-Cookie', expireCookieHeader($n));
            $res = $res->withAddedHeader('Set-Cookie', expireCookieHeader($n, null, '/'));
        }

        // Block probe and any auto-SSO for a short window
        $res = $res->withAddedHeader('Set-Cookie', 'kc_probe_skip=1; Max-Age=20; Path=/; HttpOnly; SameSite=Lax');
        $res = $res->withAddedHeader('Set-Cookie', 'fo_forced_guest=1; Max-Age=20; Path=/; SameSite=Lax');

        return $res;
    }
}

/** KC probe return page: checks hash/query; reads "from" from state (base64url JSON) */
final class ProbeReturn implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $html = <<<'HTML'
<!doctype html><meta charset="utf-8">
<script>
(function(){
  function b64urlDecode(s){
    try{
      s = s.replace(/-/g,'+').replace(/_/g,'/');
      while (s.length % 4) s += '=';
      return decodeURIComponent(escape(atob(s)));
    }catch(e){ return ''; }
  }
  var hash = location.hash || '';
  var query = location.search || '';
  // KC says "no active session"
  if (/[#&]error=/.test(hash) || /[?&]error=/.test(query)) {
    var forumBase = (window.app && app.forum) ? app.forum.attribute('baseUrl') : 'https://www.wizbrand.com/forum';
    location.replace(forumBase + '/slo/local');
    return;
  }
  // id_token present -> session alive, go back to "from"
  if (/[#&]id_token=/.test(hash)) {
    var stateMatch = hash.match(/(?:^|[&#])state=([^&#]+)/) || query.match(/(?:[?&])state=([^&#]+)/);
    var to = 'https://www.wizbrand.com/forum/';
    if (stateMatch && stateMatch[1]) {
      try {
        var st = JSON.parse(b64urlDecode(stateMatch[1]));
        if (st && st.from && typeof st.from === 'string') to = st.from;
      } catch(e){}
    }
    location.replace(to);
    return;
  }
  // fallback -> no session
  location.replace(window.app.forum.attribute('baseUrl') + '/slo/local');
})();
</script>
HTML;
        return new HtmlResponse($html, 200);
    }
}

/* ---------- wiring ---------- */
return [
    // Inject JS: reroute menu logout, clear SPA after KC logout, and run top-level probe
    (new Extend\Frontend('forum'))->content(function ($view) {
        $view->foot[] = <<<'HTML'
<script>
(function () {
  function hookLogoutMenu() {
    try {
      const forumBase = window.app && app.forum ? app.forum.attribute('baseUrl') : '';
      if (window.app && app.session && typeof app.session.logout === 'function') {
        app.session.logout = function () { window.location.href = forumBase + '/slo/fc'; return Promise.resolve(); };
      }
      var a = document.querySelector('a[data-item="logOut"], a[aria-label="Log Out"]');
      if (a) {
        a.href = forumBase + '/slo/fc';
        a.addEventListener('click', function(e){ e.preventDefault(); window.location.href = forumBase + '/slo/fc'; });
      }
    } catch (e) {}
  }
  function clearSpaAfterKcLogout() {
    try {
      if (location.search.indexOf('logged_out=1') !== -1) {
        try { localStorage.removeItem('token'); } catch(e){}
        try { localStorage.removeItem('flarum_user_id'); } catch(e){}
        try { sessionStorage.clear(); } catch(e){}
        try { if (window.app && app.session) { app.session.token = null; app.session.user = null; if (typeof m !== 'undefined' && m.redraw) m.redraw(); } } catch(e){}
        var u = new URL(location.href); u.searchParams.delete('logged_out');
        history.replaceState({}, '', u.pathname + (u.search ? u.search : '') + u.hash);
        setTimeout(function(){ window.location.reload(); }, 20);
      }
    } catch (e) {}
  }
  // Top-level probe (only when logged-in user is present and skip flag not set)
  function runKcProbeTopLevel() {
    try {
      if (document.cookie.indexOf('kc_probe_skip=1') !== -1) return;
      if (!(window.app && app.session && app.session.user)) return;
      // throttle: once every 8s
      var now = Date.now();
      var last = parseInt(localStorage.getItem('kc_probe_ts') || '0', 10);
      if (now - last < 8000) return;
      localStorage.setItem('kc_probe_ts', String(now));
      var payload = { from: location.href, ts: now };
      var state = btoa(unescape(encodeURIComponent(JSON.stringify(payload))))
        .replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
      var redirect = 'https://www.wizbrand.com/forum/kc/probe-return'; // must be in KC Valid Redirect URIs
      var auth = 'https://auth.holidaylandmark.com/realms/' + encodeURIComponent('wizbrand') +
        '/protocol/openid-connect/auth' +
        '?client_id=' + encodeURIComponent('wizbrand-forum') +
        '&redirect_uri=' + encodeURIComponent(redirect) +
        '&response_type=id_token' +
        '&response_mode=fragment' +
        '&scope=openid' +
        '&prompt=none' +
        '&nonce=' + Math.random().toString(36).slice(2) +
        '&state=' + state;
      if (!/\/kc\/probe-return\b/.test(location.pathname)) {
        location.replace(auth);
      }
    } catch(e){}
  }
  function boot(){ hookLogoutMenu(); clearSpaAfterKcLogout(); runKcProbeTopLevel(); }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
})();
</script>
HTML;
    }),
    (new Extend\Routes('forum'))
        ->get('/slo/fc',    'kc.slo.front',   FrontChannelLogout::class)
        ->get('/slo/local', 'kc.slo.local',   LocalLogout::class)
        ->get('/kc/probe-return', 'kc.probe', ProbeReturn::class),
];

My Blog on Integrating Flarum with Keycloak:-

How to Integrate Flarum with Keycloak and Enable SSO Login from a Laravel Project (Step-by-Step)

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