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

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