3 Commits
Author SHA1 Message Date
Björn Fromme f53775ef4b chore(release): 0.2.0 2026-08-12 09:24:35 +02:00
Björn Fromme b9edc98074 chore: update readme 2026-08-12 09:23:44 +02:00
Björn Fromme b62abb1801 feat: additional roles per app 2026-08-12 08:57:11 +02:00
9 changed files with 182 additions and 45 deletions
+9
View File
@@ -4,6 +4,15 @@
All notable changes to this project will be documented in this file.
<!--- END HEADER -->
## [0.2.0](https://git.fromme.dev/ep/appsmith-bff/compare/v0.1.0...v0.2.0) (2026-08-12)
### Features
* Additional roles per app ([b62abb](https://git.fromme.dev/ep/appsmith-bff/commit/b62abb180118124bd2fb9ae654bedcf84a1dd0c6))
---
## [0.1.0](https://git.fromme.dev/ep/appsmith-bff/compare/790d5bc15ff570b0aae93da83fdf62b81734a9c4...v0.1.0) (2026-08-10)
### Features
+52 -20
View File
@@ -37,27 +37,42 @@ Register **one** confidential client for the BFF (Appsmith apps never get their
### Apps (`config/services.yaml` → `appsmith.apps`)
One entry per Appsmith app: its login-return URL and the Keycloak client role required to use it.
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: 'app-crm-access'
role_prefix: 'app-crm-'
access_role: 'app-crm-access'
helpdesk:
url: '%env(APP_HELPDESK_LOGIN_URL)%'
role: 'app-helpdesk-access'
role_prefix: 'app-helpdesk-'
access_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**.
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).
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.
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`)
@@ -86,36 +101,36 @@ Sessions are stored via Symfony's filesystem cache by default (`config/packages/
## Appsmith app integration
Create a shared module with this js:
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(appKey) {
navigateTo(`https://bff.ep-reisen.net/auth/login?app=${appKey}`, {}, 'SAME_WINDOW');
login({ app }) {
navigateTo(`https://bff.ep-reisen.net/auth/login?app=${app}`, {}, 'SAME_WINDOW');
},
async checkReturningFromLogin(loginPageName, protectedPageName) {
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(loginPageName, {}, 'SAME_WINDOW');
navigateTo(loginPage, {}, 'SAME_WINDOW');
return;
}
if (sid) {
await storeValue('sid', sid);
navigateTo(protectedPageName, {}, 'SAME_WINDOW');
navigateTo(protectedPage, {}, 'SAME_WINDOW');
}
if (appsmith.store.sid && !appsmith.store.user) {
const profile = await Bff_Me.run();
await storeValue('user', profile);
}
},
guardPage(loginPageName) {
guardPage({ loginPage }) {
if (!this.isLoggedIn()) {
navigateTo(loginPageName, {}, 'SAME_WINDOW');
navigateTo(loginPage, {}, 'SAME_WINDOW');
}
},
async logout() {
@@ -127,26 +142,35 @@ export default {
}
```
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('Login', 'ProtectedPage');
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('Login');
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
@@ -170,7 +194,7 @@ storeValue('sid', null);
navigateTo('LoginPage', {}, 'REPLACE');
```
**User profile** — one query `Bff_Me`: `GET /auth/me`, header `Authorization: Bearer {{appsmith.store.sid}}`. Returns:
**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
{
@@ -179,11 +203,19 @@ navigateTo('LoginPage', {}, 'REPLACE');
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"preferred_username": "jdoe"
"preferred_username": "jdoe",
"app": "crm",
"permissions": ["access", "view", "write"]
}
```
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.
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
+1 -1
View File
@@ -66,5 +66,5 @@
"require": "7.*"
}
},
"version": "0.1.0"
"version": "0.2.0"
}
Generated
+1 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9de423c4119a1cf08f60c604ac523189",
"content-hash": "b64b308ac18c4c43f9465c097be4d120",
"packages": [
{
"name": "firebase/php-jwt",
+9 -3
View File
@@ -7,14 +7,20 @@ parameters:
keycloak.redirect_uri: '%env(KEYCLOAK_REDIRECT_URI)%'
keycloak.post_logout_redirect: '%env(KEYCLOAK_POST_LOGOUT_REDIRECT)%'
# Allowlist of Appsmith apps this BFF will redirect back to, each with
# the Keycloak client role (on the bff client) required to enter it.
# Allowlist of Appsmith apps this BFF will redirect back to. An app's
# permissions are the Keycloak roles on the bff client sharing its
# `role_prefix`; the prefix is stripped when they're reported to the app,
# so `app-sandbox-write` becomes "write". Adding a permission means
# creating the role in Keycloak — nothing to change here. `access_role`
# is the single role required to enter the app at all.
# Keep prefixes non-overlapping across apps, or roles bleed between them.
# Add one entry per app you build. Never accept a return URL from the
# request itself — always resolve through this map.
appsmith.apps:
app-sandbox:
url: '%env(APP_SANDBOX_LOGIN_URL)%'
role: 'app-sandbox-access'
role_prefix: 'app-sandbox-'
access_role: 'app-sandbox-access'
# Named backends the proxy can forward to. A request picks one via the
# X-Backend header; omitting it uses "default". Add BACKEND_<KEY>_URL to
+32 -15
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Security\AccessTokenRoles;
use App\Security\AppRegistry;
use App\Security\IdTokenDecoder;
use App\Session\BffSessionStore;
@@ -20,6 +21,7 @@ class AuthController extends AbstractController
private readonly BffSessionStore $store,
private readonly IdTokenDecoder $idTokenDecoder,
private readonly AppRegistry $apps,
private readonly AccessTokenRoles $tokenRoles,
private readonly string $kcBaseUrl,
private readonly string $kcRealm,
private readonly string $kcClientId,
@@ -81,7 +83,6 @@ class AuthController extends AbstractController
$appKey = (string) $session->get('oauth_app');
$returnUrl = $this->apps->resolveReturnUrl($appKey);
$requiredRole = $this->apps->requiredRole($appKey);
$response = $this->client->request(
'POST',
@@ -111,8 +112,14 @@ class AuthController extends AbstractController
// This is the only enforcement point — the proxy trusts any
// already-established session, by design, since "may this user
// use app X" is a login-time question, not a per-request one.
//
// The full role list is kept on the session so /auth/me can report
// the app's finer-grained permissions. Those are display data for
// the app's UI only; they are never re-checked per request here —
// the backend authorizes off the forwarded access token.
$accessClaims = $this->idTokenDecoder->decode($tokens['access_token']);
if (!$this->hasRole($accessClaims, $requiredRole)) {
$roles = $this->tokenRoles->extract($accessClaims);
if (!in_array($this->apps->accessRole($appKey), $roles, true)) {
$session->remove('pkce_verifier');
$session->remove('oauth_state');
$session->remove('oauth_app');
@@ -134,6 +141,7 @@ class AuthController extends AbstractController
'refresh_token' => $tokens['refresh_token'],
'id_token' => $tokens['id_token'],
'expires_at' => time() + (int) $tokens['expires_in'],
'roles' => $roles,
'profile' => [
'email' => $idClaims['email'] ?? null,
'email_verified' => $idClaims['email_verified'] ?? null,
@@ -149,19 +157,20 @@ class AuthController extends AbstractController
return new RedirectResponse($returnUrl . $separator . http_build_query(['sid' => $kcSid]));
}
/** @param array<string,mixed> $accessTokenClaims */
private function hasRole(array $accessTokenClaims, string $role): bool
{
$roles = $accessTokenClaims['resource_access'][$this->kcClientId]['roles'] ?? [];
return in_array($role, $roles, true);
}
/**
* Returns the logged-in user's profile (email, name, etc). Called by
* an app as a normal API query with `Authorization: Bearer <sid>` —
* same pattern as /api/*, but served directly by the BFF since this
* data comes from the ID token, not the backend.
* Returns the logged-in user's profile (email, name, etc) plus the
* permissions they hold for the calling app. Called by an app as a
* normal API query with `Authorization: Bearer <sid>` — same pattern as
* /api/*, but served directly by the BFF since this data comes from the
* tokens, not the backend.
*
* The `app` query parameter is required: one session is shared by every
* app the user opens, so the BFF can only scope permissions if the
* caller says which app is asking.
*
* Permissions here are for the app's own UI gating. They are not an
* authorization boundary — anything that matters must be enforced by
* the backend off the access token the proxy injects.
*/
#[Route('/auth/me', methods: ['GET'])]
public function me(Request $request): JsonResponse
@@ -171,12 +180,20 @@ class AuthController extends AbstractController
return new JsonResponse(['error' => 'unauthenticated'], 401);
}
$appKey = (string) $request->query->get('app', '');
if (!$this->apps->isValidApp($appKey)) {
return new JsonResponse(['error' => 'unknown or missing app key'], 400);
}
$session = $this->store->get($kcSid);
if ($session === null) {
return new JsonResponse(['error' => 'session expired'], 401);
}
return new JsonResponse($session['profile'] ?? []);
return new JsonResponse(($session['profile'] ?? []) + [
'app' => $appKey,
'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []),
]);
}
/**
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Security;
/**
* The one place that knows where an app's roles live in a Keycloak access
* token: as client roles on the BFF's own confidential client. Both the
* login gate and the token refresh read them through here.
*/
class AccessTokenRoles
{
public function __construct(private readonly string $kcClientId)
{
}
/**
* @param array<string,mixed> $accessTokenClaims decoded (and signature-verified) access token
*
* @return list<string>
*/
public function extract(array $accessTokenClaims): array
{
return array_values($accessTokenClaims['resource_access'][$this->kcClientId]['roles'] ?? []);
}
}
+34 -5
View File
@@ -4,14 +4,18 @@ namespace App\Security;
/**
* Allowlist of Appsmith apps this BFF will redirect back to after login,
* each paired with the Keycloak client role required to access it.
* each paired with the Keycloak client role required to access it and the
* prefix identifying that app's permission roles.
*
* Never redirect to an arbitrary caller-supplied URL — always resolve
* through this registry to prevent open-redirect abuse.
*/
class AppRegistry
{
/** @param array<string,array{url:string,role:string}> $apps app key => ['url' => ..., 'role' => ...] */
/**
* @param array<string,array{url:string,role_prefix:string,access_role:string}> $apps
* app key => ['url' => ..., 'role_prefix' => ..., 'access_role' => ...]
*/
public function __construct(private readonly array $apps)
{
}
@@ -26,12 +30,37 @@ class AppRegistry
return $this->requireApp($appKey)['url'];
}
public function requiredRole(string $appKey): string
public function accessRole(string $appKey): string
{
return $this->requireApp($appKey)['role'];
return $this->requireApp($appKey)['access_role'];
}
/** @return array{url:string,role:string} */
/**
* Narrows every role the user holds on the bff client down to the ones
* belonging to this app, reported without the app's prefix. The access
* role shares the prefix, so it shows up here as "access" — intentional,
* the prefix has no exceptions and apps are free to ignore it.
*
* @param list<string> $clientRoles every bff-client role the user holds
*
* @return list<string> this app's permissions, prefix stripped
*/
public function permissions(string $appKey, array $clientRoles): array
{
$prefix = $this->requireApp($appKey)['role_prefix'];
$permissions = [];
foreach ($clientRoles as $role) {
if (str_starts_with($role, $prefix)) {
$permissions[] = substr($role, strlen($prefix));
}
}
sort($permissions);
return $permissions;
}
/** @return array{url:string,role_prefix:string,access_role:string} */
private function requireApp(string $appKey): array
{
if (!isset($this->apps[$appKey])) {
+19
View File
@@ -2,6 +2,8 @@
namespace App\Session;
use App\Security\AccessTokenRoles;
use App\Security\IdTokenDecoder;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -12,6 +14,8 @@ class TokenRefresher
public function __construct(
private readonly HttpClientInterface $client,
private readonly BffSessionStore $store,
private readonly IdTokenDecoder $idTokenDecoder,
private readonly AccessTokenRoles $tokenRoles,
private readonly LoggerInterface $logger,
private readonly string $kcBaseUrl,
private readonly string $kcRealm,
@@ -64,6 +68,21 @@ class TokenRefresher
$session['refresh_token'] = $tokens['refresh_token'] ?? $session['refresh_token'];
$session['expires_at'] = time() + $tokens['expires_in'];
// Re-read the roles so /auth/me reflects permissions granted or
// revoked in Keycloak mid-session. The access gate itself is not
// re-evaluated here — "may this user use app X" stays a login-time
// question, so losing the access role won't kill a live session.
try {
$session['roles'] = $this->tokenRoles->extract(
$this->idTokenDecoder->decode($tokens['access_token'])
);
} catch (\Throwable $e) {
// Keep the previous list — a decode hiccup must not drop the session.
$this->logger->warning('Could not re-read roles from refreshed access token', [
'exception' => $e,
]);
}
$this->store->put($kcSid, $session);
return $session['access_token'];