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

Uncategorized

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)

Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x