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

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