Files

301 lines
15 KiB
Markdown

# 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://<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, the Keycloak client role required to enter it, and the prefix marking that app's permission roles.
```yaml
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>-dedicated`**Mappers** → 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 in `AuthController::callback` against `resource_access.<client_id>.roles` on the access token right after login, and re-checked on every `/auth/verify` and `/auth/me` call against the role list held on the session. Without it those two answer `403 {"error":"access_denied"}` — this is what stops one app's `sid` from being a skeleton key to another app's UI, since one session is shared by every app the user opens. The `/api/*` proxy still does not re-check anything per request (it has no way to know which app is calling).
Permissions are **not** enforced by the BFF at all. `/auth/me` and `/auth/verify` report 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. Revoking the *access* role likewise bounces the user from the app's guard within one token lifetime — or immediately, if you kill the Keycloak session.
### 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_<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
```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`.
## Logging
Two dedicated Monolog channels write to their own rotating files in `var/log`, in every environment:
| File | Channel | Kept | Records |
| --- | --- | --- | --- |
| `auth.<env>.log` | `auth` | 30 days | The login/logout/authorization trail |
| `api.<env>.log` | `api` | 14 days | One record per `/api/*` call |
**`auth`** — `auth.login.start` and `auth.login.unknown_app` (login redirect), `auth.callback.state_mismatch` and `auth.callback.failed` (a failed code exchange or token decode, previously an anonymous 500), `auth.login.denied` (missing access role), `auth.login.success`, `auth.logout`, and the per-call rejections from `/auth/verify` and `/auth/me`: `auth.session.unauthenticated`, `auth.session.unknown_app`, `auth.session.expired`, `auth.session.access_denied`. Server-side token refreshes add `auth.session.refreshed`, `auth.session.missing`, `auth.session.refresh_failed` and `auth.token.decode_failed`. Successful `verify`/`me` calls are deliberately *not* logged — they run on every page load and would bury the rest.
**`api`** — `api.request` for every completed proxy call (`info` below status 400, `warning` from 400 up) with method, path, backend, upstream status and `duration_ms`, plus `api.request.unauthenticated`, `api.request.session_expired`, `api.request.unknown_backend` and `api.request.upstream_unreachable`.
Both channels are excluded from the `main` handler, so records are never duplicated and — importantly in prod, where `main` is `fingers_crossed` — never buffered away just because the request succeeded.
Two things to know about the content:
- **It contains PII.** Records carry `user_id` (Keycloak `sub`), `email`, `preferred_username` and the user's role list. Treat `var/log` accordingly: it is a personal-data store, not just diagnostics.
- **It never contains credentials.** Sessions appear only as `sid_hash`, a 12-character SHA-256 prefix of the `sid` — enough to follow one session across both files, useless as a bearer token. Access, refresh and ID tokens, request/response bodies, query strings and forwarded headers are never logged.
## Appsmith app integration
### Queries to set up
Each app needs three queries against the BFF. Create them once per app (skip any that already exist); the shared module below calls them **by these exact names**, so don't rename them. All three are plain REST API queries — no datasource — and all three send the same auth header:
```
Authorization: Bearer {{appsmith.store.sid}}
```
| Name | Request | Called by |
|---|---|---|
| `Bff_Verify` | `GET https://<bff-host>/auth/verify?app=<key>` | `Bff.guardPage` |
| `Bff_Me` | `GET https://<bff-host>/auth/me?app=<key>` | `Bff.checkReturningFromLogin` |
| `Bff_Logout` | `POST https://<bff-host>/auth/logout` | `Bff.logout` |
`<key>` is the app's own key from `appsmith.apps`, hardcoded in the query URL — the same value the app passes to `Bff.login`. One session is shared across every app the user has open, so the BFF can only scope its answer if the caller says which app is asking. `Bff_Logout` needs no key: it kills the session for *all* apps.
**`Bff_Verify`** — the page guard. Carries no profile data, so it stays cheap enough to run on every page load. Returns
```json
{ "valid": true, "app": "crm", "permissions": ["access", "view", "write"] }
```
or `401 {"error":"unauthenticated"}` / `401 {"error":"session expired"}` for a missing, forged or dead `sid`, `403 {"error":"access_denied"}` for a live session whose user lacks `crm-access`, and `400 {"error":"unknown or missing app key"}` for a bad `?app=`. `guardPage` treats every non-2xx the same way — clear the stored session, go to the login page.
**`Bff_Me`** — profile plus this app's permissions, from the tokens the BFF already holds (no backend call). Applies the same checks as `Bff_Verify`, so it can answer `401`/`403` too. On success:
```json
{
"email": "[email protected]",
"email_verified": true,
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"preferred_username": "jdoe",
"app": "crm",
"permissions": ["access", "view", "write"]
}
```
**`Bff_Logout`** — returns `{"logout_url": "…"}`. The BFF drops the session immediately; the app must then navigate the browser to that URL, since only the browser can clear Keycloak's own SSO cookie. `Bff.logout` does both.
**The app's own data queries** — every protected query targets `https://<bff-host>/api/<path>` and sets the same `Authorization` header. Add `X-Backend: <key>` to route a query to a non-default backend. Give each one an `onError` handler:
```js
{{ Bff.clearSession().then(() => navigateTo('Login', {}, 'REPLACE')) }}
```
### Shared module
Create a shared module with this js. It stays generic — the app key and the page names it needs are supplied by the caller, so the same code can be dropped into every app unchanged:
```js
export default {
isLoggedIn() {
return !!appsmith.store.sid;
},
login(app) {
if (!app) {
// Almost always a binding that isn't a call — see below.
showAlert('Bff.login was called without an app key.', 'error');
return;
}
navigateTo(
`https://bff.ep-reisen.net/auth/login?app=${encodeURIComponent(app)}`,
{},
'SAME_WINDOW',
);
},
async clearSession() {
await storeValue('sid', null);
await storeValue('user', null);
await storeValue('permissions', null);
},
async checkReturningFromLogin(protectedPage) {
if (appsmith.URL.queryParams.error === 'access_denied') {
await this.clearSession();
showAlert('You do not have access to this app.', 'error');
return;
}
const sid = appsmith.URL.queryParams.sid;
if (sid) {
await storeValue('sid', sid);
}
if (!this.isLoggedIn()) {
return;
}
// Profile and permissions land in the store before the protected
// page renders, so its widgets never read an empty user object.
try {
const me = await Bff_Me.run();
await storeValue('user', me);
await storeValue('permissions', me.permissions);
} catch (e) {
await this.clearSession();
return;
}
if (sid) {
navigateTo(protectedPage, {}, 'SAME_WINDOW');
}
},
async guardPage(loginPage) {
if (!loginPage) {
showAlert('Bff.guardPage was called without a login page name.', 'error');
return;
}
if (!this.isLoggedIn()) {
navigateTo(loginPage, {}, 'SAME_WINDOW');
return;
}
try {
const res = await Bff_Verify.run();
await storeValue('permissions', res.permissions);
} catch (e) {
await this.clearSession();
navigateTo(loginPage, {}, 'SAME_WINDOW');
}
},
async logout() {
const res = await Bff_Logout.run();
await this.clearSession();
navigateTo(res.logout_url, {}, 'SAME_WINDOW');
}
}
```
No per-page JS objects are needed. Add the module to each page, then open it from the **JS** tab in the editor: each of its methods is listed there with its own argument fields and a **Run on page load** toggle. (Not the run behavior modal — the argument values are edited per method on the module itself.) Enable page load on the one method that page needs and fill in its argument — this is where the app's own page names live:
| Page | Function to run on page load | Argument |
|---|---|---|
| Login | `checkReturningFromLogin` | `protectedPage` — the page to land on after a successful login |
| Every protected page | `guardPage` | `loginPage` — the page to bounce to when the session doesn't check out |
Note `checkReturningFromLogin` takes no login page name — it only ever runs *on* the login page, so there is nowhere to send the user on failure; it clears the session and stays put. `guardPage` is the one that needs a page name, since it runs on a protected page and has to send the user away.
Bind the login button's onClick to
```js
{{ Bff.login('app-sandbox') }}
```
using the same app key the `Bff_Me` and `Bff_Verify` queries send as `?app=`.
Write that binding by hand in the onClick field. Do **not** use the event dropdown's "Execute a JS function" option to select `Bff.login` — that invokes it with no arguments, so the app key arrives `undefined`, the BFF gets `/auth/login?app=undefined` and answers `404 Unknown or missing app key`. Unlike `guardPage` and `checkReturningFromLogin`, `login` is triggered by a widget event rather than page load, so there is no settings field to put the app key in — it has to be in the binding.
`guardPage` asks the BFF (via `Bff_Verify`) whether the stored `sid` is a live session that may enter *this* app, and bounces to the login page if not — a forged or expired `sid`, or one belonging to a user without the app's access role, never gets past it. Two caveats: Appsmith renders the page before the page-load function resolves, so the page shell can flash briefly before the redirect; and this is UI gating only. Anyone holding a real `sid` can still call `/api/*` with curl, so the backend remains the authorization boundary.
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 — whatever `Bff_Me` returned — is accessible via
```js
// Full user object
{{ appsmith.store.user }}
// Individual fields
{{ appsmith.store.user.email }}
```
Gate UI on permissions with
```js
{{ (appsmith.store.permissions || []).includes('write') }}
```
`appsmith.store.permissions` is the one to bind against: `checkReturningFromLogin` seeds it at login and `guardPage` refreshes it from `Bff_Verify` on every protected page load, so a permission granted or revoked in Keycloak shows up without a re-login. (`appsmith.store.user.permissions` holds the same list as of login time only — bind to the store key, not the nested one.)
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.