feat: auth verification endpoint

This commit is contained in:
Björn Fromme
2026-08-13 11:01:18 +02:00
parent f53775ef4b
commit cc46d8c812
2 changed files with 225 additions and 103 deletions
+149 -91
View File
@@ -68,11 +68,11 @@ Setup in Keycloak:
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.
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` 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.
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. 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.
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`)
@@ -101,100 +101,31 @@ Sessions are stored via Symfony's filesystem cache by default (`config/packages/
## 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:
### Queries to set up
```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');
}
}
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}}
```
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.
| 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` |
On the login page add this js to be executed onLoad:
`<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.
```js
export default {
onLoad() {
return Bff.checkReturningFromLogin({
loginPage: 'Login',
protectedPage: 'ProtectedPage',
});
}
}
**`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"] }
```
Bind the login button's onClick to `{{ Bff.login({ app: 'app-sandbox' }) }}` — the same app key the `Bff_Me` query sends as `?app=`.
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.
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:
**`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
{
@@ -209,12 +140,139 @@ navigateTo('LoginPage', {}, 'REPLACE');
}
```
Reference via `{{appsmith.store.user.email}}` etc. Gate UI on permissions with
**`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
{{ appsmith.store.user.permissions.includes('write') }}
{{ 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
+76 -12
View File
@@ -6,6 +6,7 @@ use App\Security\AccessTokenRoles;
use App\Security\AppRegistry;
use App\Security\IdTokenDecoder;
use App\Session\BffSessionStore;
use App\Session\TokenRefresher;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -19,6 +20,7 @@ class AuthController extends AbstractController
public function __construct(
private readonly HttpClientInterface $client,
private readonly BffSessionStore $store,
private readonly TokenRefresher $refresher,
private readonly IdTokenDecoder $idTokenDecoder,
private readonly AppRegistry $apps,
private readonly AccessTokenRoles $tokenRoles,
@@ -157,6 +159,33 @@ class AuthController extends AbstractController
return new RedirectResponse($returnUrl . $separator . http_build_query(['sid' => $kcSid]));
}
/**
* Guard endpoint apps call on page load: is this sid a live session, and
* may it enter *this* app? Deliberately returns no profile data, so it
* stays cheap enough to run on every page load.
*
* This exists because an app's own login state (a `sid` in client-side
* storage) is trivially forgeable — checking it client-side proves
* nothing. It is still UI gating, not an authorization boundary: the
* backend authorizes off the access token the proxy injects.
*/
#[Route('/auth/verify', methods: ['GET'])]
public function verify(Request $request): JsonResponse
{
$appKey = (string) $request->query->get('app', '');
$session = $this->resolveAppSession($request, $appKey);
if ($session instanceof JsonResponse) {
return $session;
}
return new JsonResponse([
'valid' => true,
'app' => $appKey,
'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []),
]);
}
/**
* 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
@@ -166,7 +195,8 @@ class AuthController extends AbstractController
*
* 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.
* caller says which app is asking — and so it can check the caller may
* enter that app at all (same gate as /auth/verify).
*
* Permissions here are for the app's own UI gating. They are not an
* authorization boundary — anything that matters must be enforced by
@@ -175,19 +205,11 @@ class AuthController extends AbstractController
#[Route('/auth/me', methods: ['GET'])]
public function me(Request $request): JsonResponse
{
$kcSid = $this->extractBearer($request);
if (!$kcSid) {
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);
$session = $this->resolveAppSession($request, $appKey);
if ($session instanceof JsonResponse) {
return $session;
}
return new JsonResponse(($session['profile'] ?? []) + [
@@ -226,6 +248,48 @@ class AuthController extends AbstractController
]);
}
/**
* Bearer sid -> a session that is live *and* authorized for $appKey.
*
* Returns the session array, or the JsonResponse the caller should
* return instead of a payload. Liveness goes through TokenRefresher
* rather than a bare store lookup, so a session killed on the Keycloak
* side (admin logout, revoked refresh token) fails here immediately
* instead of lingering until the cache TTL — and so the roles checked
* below are the ones the refresh just re-read.
*
* @return array<string,mixed>|JsonResponse
*/
private function resolveAppSession(Request $request, string $appKey): array|JsonResponse
{
$kcSid = $this->extractBearer($request);
if (!$kcSid) {
return new JsonResponse(['error' => 'unauthenticated'], 401);
}
if (!$this->apps->isValidApp($appKey)) {
return new JsonResponse(['error' => 'unknown or missing app key'], 400);
}
if ($this->refresher->ensureFresh($kcSid) === null) {
return new JsonResponse(['error' => 'session expired'], 401);
}
$session = $this->store->get($kcSid);
if ($session === null) {
return new JsonResponse(['error' => 'session expired'], 401);
}
// Same gate as callback(), re-run per call: one session is shared by
// every app the user opens, so a sid on its own says nothing about
// which app its holder may enter.
if (!in_array($this->apps->accessRole($appKey), $session['roles'] ?? [], true)) {
return new JsonResponse(['error' => 'access_denied'], 403);
}
return $session;
}
private function extractBearer(Request $request): ?string
{
$header = $request->headers->get('Authorization', '');