Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f53775ef4b | ||
|
|
b9edc98074 | ||
|
|
b62abb1801 |
@@ -4,6 +4,15 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
<!--- END HEADER -->
|
<!--- 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)
|
## [0.1.0](https://git.fromme.dev/ep/appsmith-bff/compare/790d5bc15ff570b0aae93da83fdf62b81734a9c4...v0.1.0) (2026-08-10)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -37,27 +37,42 @@ Register **one** confidential client for the BFF (Appsmith apps never get their
|
|||||||
|
|
||||||
### Apps (`config/services.yaml` → `appsmith.apps`)
|
### 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
|
```yaml
|
||||||
appsmith.apps:
|
appsmith.apps:
|
||||||
crm:
|
crm:
|
||||||
url: '%env(APP_CRM_LOGIN_URL)%'
|
url: '%env(APP_CRM_LOGIN_URL)%'
|
||||||
role: 'app-crm-access'
|
role_prefix: 'app-crm-'
|
||||||
|
access_role: 'app-crm-access'
|
||||||
helpdesk:
|
helpdesk:
|
||||||
url: '%env(APP_HELPDESK_LOGIN_URL)%'
|
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.
|
Add the matching `APP_<KEY>_LOGIN_URL` in `.env.local`. No code changes needed.
|
||||||
|
|
||||||
Role setup in Keycloak:
|
Keep prefixes non-overlapping — with `app-crm-` and `app-crm-admin-` as two apps, the first would swallow the second's roles.
|
||||||
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**.
|
### 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.
|
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).
|
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`)
|
### Backends (`config/services.yaml` → `backends`)
|
||||||
|
|
||||||
@@ -86,36 +101,36 @@ Sessions are stored via Symfony's filesystem cache by default (`config/packages/
|
|||||||
|
|
||||||
## Appsmith app integration
|
## 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
|
```js
|
||||||
export default {
|
export default {
|
||||||
isLoggedIn() {
|
isLoggedIn() {
|
||||||
return !!appsmith.store.sid;
|
return !!appsmith.store.sid;
|
||||||
},
|
},
|
||||||
login(appKey) {
|
login({ app }) {
|
||||||
navigateTo(`https://bff.ep-reisen.net/auth/login?app=${appKey}`, {}, 'SAME_WINDOW');
|
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 sid = appsmith.URL.queryParams.sid;
|
||||||
const error = appsmith.URL.queryParams.error;
|
const error = appsmith.URL.queryParams.error;
|
||||||
if (error === 'access_denied') {
|
if (error === 'access_denied') {
|
||||||
showAlert('You do not have access to this app.', 'error');
|
showAlert('You do not have access to this app.', 'error');
|
||||||
navigateTo(loginPageName, {}, 'SAME_WINDOW');
|
navigateTo(loginPage, {}, 'SAME_WINDOW');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sid) {
|
if (sid) {
|
||||||
await storeValue('sid', sid);
|
await storeValue('sid', sid);
|
||||||
navigateTo(protectedPageName, {}, 'SAME_WINDOW');
|
navigateTo(protectedPage, {}, 'SAME_WINDOW');
|
||||||
}
|
}
|
||||||
if (appsmith.store.sid && !appsmith.store.user) {
|
if (appsmith.store.sid && !appsmith.store.user) {
|
||||||
const profile = await Bff_Me.run();
|
const profile = await Bff_Me.run();
|
||||||
await storeValue('user', profile);
|
await storeValue('user', profile);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
guardPage(loginPageName) {
|
guardPage({ loginPage }) {
|
||||||
if (!this.isLoggedIn()) {
|
if (!this.isLoggedIn()) {
|
||||||
navigateTo(loginPageName, {}, 'SAME_WINDOW');
|
navigateTo(loginPage, {}, 'SAME_WINDOW');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async logout() {
|
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:
|
On the login page add this js to be executed onLoad:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
export default {
|
export default {
|
||||||
onLoad() {
|
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:
|
On the protected page add this js to be execute onLoad:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
export default {
|
export default {
|
||||||
onLoad() {
|
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
|
Login state can be used to toggle visibility of UI elements in Appsmith with
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -170,7 +194,7 @@ storeValue('sid', null);
|
|||||||
navigateTo('LoginPage', {}, 'REPLACE');
|
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
|
```json
|
||||||
{
|
{
|
||||||
@@ -179,11 +203,19 @@ navigateTo('LoginPage', {}, 'REPLACE');
|
|||||||
"name": "Jane Doe",
|
"name": "Jane Doe",
|
||||||
"given_name": "Jane",
|
"given_name": "Jane",
|
||||||
"family_name": "Doe",
|
"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
|
## Known limitation
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -66,5 +66,5 @@
|
|||||||
"require": "7.*"
|
"require": "7.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.1.0"
|
"version": "0.2.0"
|
||||||
}
|
}
|
||||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "9de423c4119a1cf08f60c604ac523189",
|
"content-hash": "b64b308ac18c4c43f9465c097be4d120",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "firebase/php-jwt",
|
"name": "firebase/php-jwt",
|
||||||
|
|||||||
@@ -7,14 +7,20 @@ parameters:
|
|||||||
keycloak.redirect_uri: '%env(KEYCLOAK_REDIRECT_URI)%'
|
keycloak.redirect_uri: '%env(KEYCLOAK_REDIRECT_URI)%'
|
||||||
keycloak.post_logout_redirect: '%env(KEYCLOAK_POST_LOGOUT_REDIRECT)%'
|
keycloak.post_logout_redirect: '%env(KEYCLOAK_POST_LOGOUT_REDIRECT)%'
|
||||||
|
|
||||||
# Allowlist of Appsmith apps this BFF will redirect back to, each with
|
# Allowlist of Appsmith apps this BFF will redirect back to. An app's
|
||||||
# the Keycloak client role (on the bff client) required to enter it.
|
# 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
|
# Add one entry per app you build. Never accept a return URL from the
|
||||||
# request itself — always resolve through this map.
|
# request itself — always resolve through this map.
|
||||||
appsmith.apps:
|
appsmith.apps:
|
||||||
app-sandbox:
|
app-sandbox:
|
||||||
url: '%env(APP_SANDBOX_LOGIN_URL)%'
|
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
|
# 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
|
# X-Backend header; omitting it uses "default". Add BACKEND_<KEY>_URL to
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Security\AccessTokenRoles;
|
||||||
use App\Security\AppRegistry;
|
use App\Security\AppRegistry;
|
||||||
use App\Security\IdTokenDecoder;
|
use App\Security\IdTokenDecoder;
|
||||||
use App\Session\BffSessionStore;
|
use App\Session\BffSessionStore;
|
||||||
@@ -20,6 +21,7 @@ class AuthController extends AbstractController
|
|||||||
private readonly BffSessionStore $store,
|
private readonly BffSessionStore $store,
|
||||||
private readonly IdTokenDecoder $idTokenDecoder,
|
private readonly IdTokenDecoder $idTokenDecoder,
|
||||||
private readonly AppRegistry $apps,
|
private readonly AppRegistry $apps,
|
||||||
|
private readonly AccessTokenRoles $tokenRoles,
|
||||||
private readonly string $kcBaseUrl,
|
private readonly string $kcBaseUrl,
|
||||||
private readonly string $kcRealm,
|
private readonly string $kcRealm,
|
||||||
private readonly string $kcClientId,
|
private readonly string $kcClientId,
|
||||||
@@ -81,7 +83,6 @@ class AuthController extends AbstractController
|
|||||||
|
|
||||||
$appKey = (string) $session->get('oauth_app');
|
$appKey = (string) $session->get('oauth_app');
|
||||||
$returnUrl = $this->apps->resolveReturnUrl($appKey);
|
$returnUrl = $this->apps->resolveReturnUrl($appKey);
|
||||||
$requiredRole = $this->apps->requiredRole($appKey);
|
|
||||||
|
|
||||||
$response = $this->client->request(
|
$response = $this->client->request(
|
||||||
'POST',
|
'POST',
|
||||||
@@ -111,8 +112,14 @@ class AuthController extends AbstractController
|
|||||||
// This is the only enforcement point — the proxy trusts any
|
// This is the only enforcement point — the proxy trusts any
|
||||||
// already-established session, by design, since "may this user
|
// already-established session, by design, since "may this user
|
||||||
// use app X" is a login-time question, not a per-request one.
|
// 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']);
|
$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('pkce_verifier');
|
||||||
$session->remove('oauth_state');
|
$session->remove('oauth_state');
|
||||||
$session->remove('oauth_app');
|
$session->remove('oauth_app');
|
||||||
@@ -134,6 +141,7 @@ class AuthController extends AbstractController
|
|||||||
'refresh_token' => $tokens['refresh_token'],
|
'refresh_token' => $tokens['refresh_token'],
|
||||||
'id_token' => $tokens['id_token'],
|
'id_token' => $tokens['id_token'],
|
||||||
'expires_at' => time() + (int) $tokens['expires_in'],
|
'expires_at' => time() + (int) $tokens['expires_in'],
|
||||||
|
'roles' => $roles,
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'email' => $idClaims['email'] ?? null,
|
'email' => $idClaims['email'] ?? null,
|
||||||
'email_verified' => $idClaims['email_verified'] ?? 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]));
|
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
|
* Returns the logged-in user's profile (email, name, etc) plus the
|
||||||
* an app as a normal API query with `Authorization: Bearer <sid>` —
|
* permissions they hold for the calling app. Called by an app as a
|
||||||
* same pattern as /api/*, but served directly by the BFF since this
|
* normal API query with `Authorization: Bearer <sid>` — same pattern as
|
||||||
* data comes from the ID token, not the backend.
|
* /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'])]
|
#[Route('/auth/me', methods: ['GET'])]
|
||||||
public function me(Request $request): JsonResponse
|
public function me(Request $request): JsonResponse
|
||||||
@@ -171,12 +180,20 @@ class AuthController extends AbstractController
|
|||||||
return new JsonResponse(['error' => 'unauthenticated'], 401);
|
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);
|
$session = $this->store->get($kcSid);
|
||||||
if ($session === null) {
|
if ($session === null) {
|
||||||
return new JsonResponse(['error' => 'session expired'], 401);
|
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'] ?? []),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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'] ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,18 @@ namespace App\Security;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowlist of Appsmith apps this BFF will redirect back to after login,
|
* 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
|
* Never redirect to an arbitrary caller-supplied URL — always resolve
|
||||||
* through this registry to prevent open-redirect abuse.
|
* through this registry to prevent open-redirect abuse.
|
||||||
*/
|
*/
|
||||||
class AppRegistry
|
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)
|
public function __construct(private readonly array $apps)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -26,12 +30,37 @@ class AppRegistry
|
|||||||
return $this->requireApp($appKey)['url'];
|
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
|
private function requireApp(string $appKey): array
|
||||||
{
|
{
|
||||||
if (!isset($this->apps[$appKey])) {
|
if (!isset($this->apps[$appKey])) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Session;
|
namespace App\Session;
|
||||||
|
|
||||||
|
use App\Security\AccessTokenRoles;
|
||||||
|
use App\Security\IdTokenDecoder;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
@@ -12,6 +14,8 @@ class TokenRefresher
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly HttpClientInterface $client,
|
private readonly HttpClientInterface $client,
|
||||||
private readonly BffSessionStore $store,
|
private readonly BffSessionStore $store,
|
||||||
|
private readonly IdTokenDecoder $idTokenDecoder,
|
||||||
|
private readonly AccessTokenRoles $tokenRoles,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly string $kcBaseUrl,
|
private readonly string $kcBaseUrl,
|
||||||
private readonly string $kcRealm,
|
private readonly string $kcRealm,
|
||||||
@@ -64,6 +68,21 @@ class TokenRefresher
|
|||||||
$session['refresh_token'] = $tokens['refresh_token'] ?? $session['refresh_token'];
|
$session['refresh_token'] = $tokens['refresh_token'] ?? $session['refresh_token'];
|
||||||
$session['expires_at'] = time() + $tokens['expires_in'];
|
$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);
|
$this->store->put($kcSid, $session);
|
||||||
|
|
||||||
return $session['access_token'];
|
return $session['access_token'];
|
||||||
|
|||||||
Reference in New Issue
Block a user