# 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 ```bash 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:///auth/callback` | | `KEYCLOAK_POST_LOGOUT_REDIRECT` | Where Keycloak sends the browser after `end_session` | | `BACKEND_BASE_URL` | Default backend the proxy forwards to | | `BACKEND__URL` | Optional additional backends (see "Backends" below) | | `APP__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. ```yaml 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__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** → `-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..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: ```yaml backends: default: url: '%env(BACKEND_BASE_URL)%' crm_api: url: '%env(BACKEND_CRM_API_URL)%' ``` Add the matching `BACKEND__URL` in `.env.local`. A proxied request uses `default` unless it sends an `X-Backend: ` header; unknown keys get `400 {"error":"unknown backend"}`. Any app may use any backend key. ### Running ```bash 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: ```js export default { isLoggedIn() { return !!appsmith.store.sid; }, login(appKey) { navigateTo(`https://bff.ep-reisen.net/auth/login?app=${appKey}`, {}, 'SAME_WINDOW'); }, async checkReturningFromLogin(loginPageName, protectedPageName) { 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(loginPageName, {}, 'SAME_WINDOW'); return; } if (sid) { await storeValue('sid', sid); navigateTo(protectedPageName, {}, 'SAME_WINDOW'); } if (appsmith.store.sid && !appsmith.store.user) { const profile = await Bff_Me.run(); await storeValue('user', profile); } }, guardPage(loginPageName) { if (!this.isLoggedIn()) { navigateTo(loginPageName, {}, 'SAME_WINDOW'); } }, async logout() { const res = await Bff_Logout.run(); await storeValue('sid', null); await storeValue('user', null); navigateTo(res.logout_url, {}, 'SAME_WINDOW'); } } ``` On the login page add this js to be executed onLoad: ```js export default { onLoad() { return Bff.checkReturningFromLogin('Login', 'ProtectedPage'); } } ``` On the protected page add this js to be execute onLoad: ```js export default { onLoad() { return Bff.guardPage('Login'); } } ``` Login state can be used to toggle visibility of UI elements in Appsmith with ```js // No sid? Not logged-in {{ !appsmith.store.sid }} // Sid? Logged-in {{ !!appsmith.store.sid }} ``` and user info is accessible via ```js // Full user object {{ appsmith.store.user }} ``` **API queries** — every protected query sets header `Authorization: Bearer {{appsmith.store.sid}}`, targeting `https:///api/`. Add `X-Backend: ` to route a specific query to a non-default backend. Add a 401 handler (`onError`) on each: ```js storeValue('sid', null); navigateTo('LoginPage', {}, 'REPLACE'); ``` **User profile** — one query `Bff_Me`: `GET /auth/me`, header `Authorization: Bearer {{appsmith.store.sid}}`. Returns: ```json { "email": "jane.doe@corp.example", "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.