Files
appsmith-bff/README.md
T

191 lines
6.2 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 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_<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:
```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`.
## 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://<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:
```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": "[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.