223 lines
8.7 KiB
Markdown
223 lines
8.7 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 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.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. 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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
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
|
|
|
|
```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 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:
|
|
|
|
```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"]
|
|
}
|
|
```
|
|
|
|
Reference via `{{appsmith.store.user.email}}` etc. Gate UI on permissions with
|
|
|
|
```js
|
|
{{ 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.
|