Files
appsmith-bff/README.md
T
2026-08-09 16:10:05 +02:00

5.4 KiB

Symfony BFF for Keycloak SSO across self-hosted Appsmith apps

Requirements

  • PHP >= 8.1, Composer
  • A Keycloak realm you can register a confidential client in

Configuration

composer install
cp .env .env.local   # keep .env as the checked-in template, override real values in .env.local

Environment variables (.env.local)

Variable Purpose
APP_SECRET Random string (openssl rand -hex 32)
KEYCLOAK_BASE_URL e.g. https://keycloak.corp.internal
KEYCLOAK_REALM Realm name
KEYCLOAK_CLIENT_ID Confidential client id (see below)
KEYCLOAK_CLIENT_SECRET That client's secret
KEYCLOAK_REDIRECT_URI https://<bff-host>/auth/callback
KEYCLOAK_POST_LOGOUT_REDIRECT Where Keycloak sends the browser after end_session
BACKEND_BASE_URL Default backend the proxy forwards to
BACKEND_<KEY>_URL Optional additional backends (see "Backends" below)
APP_<KEY>_LOGIN_URL One per Appsmith app (see "Apps" below)

Keycloak client

Register one confidential client for the BFF (Appsmith apps never get their own):

  • Standard flow (Authorization Code) + PKCE enabled
  • Redirect URI = KEYCLOAK_REDIRECT_URI

Apps (config/services.yaml → appsmith.apps)

One entry per Appsmith app: its login-return URL and the Keycloak client role required to use it.

appsmith.apps:
    crm:
        url: '%env(APP_CRM_LOGIN_URL)%'
        role: 'app-crm-access'
    helpdesk:
        url: '%env(APP_HELPDESK_LOGIN_URL)%'
        role: 'app-helpdesk-access'

Add the matching APP_<KEY>_LOGIN_URL in .env.local. No code changes needed.

Role setup in Keycloak:

  1. On the BFF client → Roles → create one role per app (e.g. app-crm-access).
  2. Create a group per app, assign the matching role under the group's Role mapping.
  3. Put users in whichever groups they need.
  4. Client → Client scopes → <client>-dedicated → Mappers → confirm a "client roles" mapper exists with Add to access token enabled (default for new clients).

Enforcement happens once, in AuthController::callback, by checking resource_access.<client_id>.roles on the access token right after login. The /api/* proxy does not re-check roles per request.

Backends (config/services.yaml → backends)

One default entry plus any number of additional named backends:

backends:
    default:
        url: '%env(BACKEND_BASE_URL)%'
    crm_api:
        url: '%env(BACKEND_CRM_API_URL)%'

Add the matching BACKEND_<KEY>_URL in .env.local. A proxied request uses default unless it sends an X-Backend: <key> header; unknown keys get 400 {"error":"unknown backend"}. Any app may use any backend key.

Running

php -S 127.0.0.1:8000 -t public
# or: symfony server:start

Production: point your webserver at public/ as document root, APP_ENV=prod, serve over TLS (the app sets cookie_secure: true).

Sessions are stored via Symfony's filesystem cache by default (config/packages/cache.yaml) — fine for one instance. For multiple instances behind a load balancer, switch the bff.session_cache pool to cache.adapter.redis and add REDIS_DSN.

Appsmith app integration

Paste into each app (adjust appKey to match the key registered above):

export default {
  appKey: 'crm', // must match a key in appsmith.apps

  async checkReturningFromLogin() {
    const sid = appsmith.URL.queryParams.sid;
    const error = appsmith.URL.queryParams.error;

    if (error === 'access_denied') {
      showAlert('You do not have access to this app.', 'error');
      navigateTo(currentPage.pageName, {}, 'REPLACE');
      return;
    }
    if (sid) {
      await storeValue('sid', sid);
      navigateTo(currentPage.pageName, {}, 'REPLACE'); // strip sid from the visible URL
    }
    if (appsmith.store.sid && !appsmith.store.user) {
      const profile = await Bff_Me.run();
      await storeValue('user', profile);
    }
  },

  isLoggedIn() {
    return !!appsmith.store.sid;
  },

  login() {
    navigateTo(`https://bff.corp.internal/auth/login?app=${this.appKey}`, {}, 'SAME_WINDOW');
  },

  async logout() {
    const res = await Bff_Logout.run(); // POST /auth/logout, header Authorization: Bearer {{appsmith.store.sid}}
    await storeValue('sid', null);
    await storeValue('user', null);
    navigateTo(res.logout_url, {}, 'SAME_WINDOW');
  }
}

Call from onPageLoad of the login page.

API queries — every protected query sets header Authorization: Bearer {{appsmith.store.sid}}, targeting https://<bff-host>/api/<path>. Add X-Backend: <key> to route a specific query to a non-default backend. Add a 401 handler (onError) on each:

storeValue('sid', null);
navigateTo('LoginPage', {}, 'REPLACE');

User profile — one query Bff_Me: GET /auth/me, header Authorization: Bearer {{appsmith.store.sid}}. Returns:

{
  "email": "[email protected]",
  "email_verified": true,
  "name": "Jane Doe",
  "given_name": "Jane",
  "family_name": "Doe",
  "preferred_username": "jdoe"
}

Reference via {{appsmith.store.user.email}} etc. This is client-visible display data, not an authorization signal — the backend must still authorize off the real access token the BFF injects.

Known limitation

Logout is immediate at the BFF but not push-propagated to other open app tabs — they find out on their next API call (401). For centrally-triggered logout (e.g. admin-disabled user), add OIDC back-channel logout separately.