2026-08-12 09:24:35 +02:00
2026-08-10 09:13:44 +02:00
2026-08-10 09:13:44 +02:00
2026-08-12 08:57:11 +02:00
2026-08-10 09:13:44 +02:00
2026-08-12 08:57:11 +02:00
2026-08-10 09:15:52 +02:00
2026-08-10 09:13:44 +02:00
2026-08-10 09:13:44 +02:00
2026-08-12 09:24:35 +02:00
2026-08-12 09:24:35 +02:00
2026-08-12 09:24:35 +02:00
2026-08-10 09:13:44 +02:00
2026-08-12 09:23:44 +02:00
2026-08-10 09:13:44 +02:00

Symfony BFF for Keycloak SSO across self-hosted Appsmith apps

For a description of the implemented concept see https://auth0.com/blog/the-backend-for-frontend-pattern-bff/

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.yamlappsmith.apps)

One entry per Appsmith app: its login-return URL, the Keycloak client role required to enter it, and the prefix marking that app's permission roles.

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

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

Keep prefixes non-overlapping — with app-crm- and app-crm-admin- as two apps, the first would swallow the second's roles.

Roles and permissions

All roles live on the single BFF client. Two kinds, distinguished only by name:

  • Access role (access_role) — the gate. Without it, login ends in ?error=access_denied.
  • Permission roles — every other role sharing the app's role_prefix. The prefix is stripped before the app sees them, so app-crm-write is reported as write. Adding a permission is a Keycloak role creation; nothing changes here or in the code.

Setup in Keycloak:

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

Where this is enforced: the access role is checked once, in AuthController::callback, against resource_access.<client_id>.roles on the access token right after login. The /api/* proxy does not re-check anything per request.

Permissions are not enforced by the BFF at all. /auth/me reports them so an app can hide or disable widgets, but anyone holding a sid can call the API directly. The backend is the authorization boundary and must check the roles claim on the access token the proxy injects.

Roles are re-read from Keycloak whenever the BFF refreshes the access token (roughly once per token lifetime), so granting or revoking a permission takes effect without a re-login. The access role is deliberately not re-checked there — revoking it takes effect at the next login, or immediately if you kill the Keycloak session.

Backends (config/services.yamlbackends)

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

Create a shared module with this js. It stays generic — every app key and page name is passed in by the caller, so the same code can be dropped into every app unchanged:

export default {
    isLoggedIn() {
        return !!appsmith.store.sid;
    },
    login({ app }) {
        navigateTo(`https://bff.ep-reisen.net/auth/login?app=${app}`, {}, 'SAME_WINDOW');
    },
    async checkReturningFromLogin({ loginPage, protectedPage }) {
        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(loginPage, {}, 'SAME_WINDOW');
            return;
        }
        if (sid) {
            await storeValue('sid', sid);
            navigateTo(protectedPage, {}, 'SAME_WINDOW');
        }
        if (appsmith.store.sid && !appsmith.store.user) {
            const profile = await Bff_Me.run();
            await storeValue('user', profile);
        }
    },
    guardPage({ loginPage }) {
        if (!this.isLoggedIn()) {
            navigateTo(loginPage, {}, 'SAME_WINDOW');
        }
    },
    async logout() {
        const res = await Bff_Logout.run();
        await storeValue('sid', null);
        await storeValue('user', null);
        navigateTo(res.logout_url, {}, 'SAME_WINDOW');
    }
}

The per-page objects below are where an app's own configuration lives — page names and the app key. They can't be folded into the shared module: JS Objects are page-scoped and "Run on page load" applies to the page the object belongs to, so each page needs its own.

On the login page add this js to be executed onLoad:

export default {
  onLoad() {
    return Bff.checkReturningFromLogin({
      loginPage: 'Login',
      protectedPage: 'ProtectedPage',
    });
  }
}

Bind the login button's onClick to {{ Bff.login({ app: 'app-sandbox' }) }} — the same app key the Bff_Me query sends as ?app=.

On the protected page add this js to be execute onLoad:

export default {
  onLoad() {
    return Bff.guardPage({ loginPage: 'Login' });
  }
}

Note guardPage only checks that a sid exists, not that it's still valid — an expired session surfaces on the first API call's 401 handler.

Login state can be used to toggle visibility of UI elements in Appsmith with

// No sid? Not logged-in
{{ !appsmith.store.sid }}
// Sid? Logged-in
{{ !!appsmith.store.sid }}

and user info is accessible via

// Full user object
{{ appsmith.store.user }}

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 and permissions — one query Bff_Me: GET /auth/me?app=crm, header Authorization: Bearer {{appsmith.store.sid}}. The app key is required (hardcode the app's own key, same value it passes to Bff.login); one session is shared across every app the user has open, so the BFF needs to know who's asking to scope the permissions. Returns:

{
  "email": "[email protected]",
  "email_verified": true,
  "name": "Jane Doe",
  "given_name": "Jane",
  "family_name": "Doe",
  "preferred_username": "jdoe",
  "app": "crm",
  "permissions": ["access", "view", "write"]
}

Reference via {{appsmith.store.user.email}} etc. Gate UI on permissions with

{{ appsmith.store.user.permissions.includes('write') }}

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.

S
Description
No description provided
Readme
256 KiB
Languages
PHP 100%