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

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