6 Commits
Author SHA1 Message Date
Björn Fromme e2f2ba7d1b chore(release): 0.3.0 2026-08-14 08:44:02 +02:00
Björn Fromme e28b789af2 feat: dedicated logging for auth and api calls 2026-08-14 08:16:54 +02:00
Björn Fromme cc46d8c812 feat: auth verification endpoint 2026-08-13 11:01:18 +02:00
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
12 changed files with 659 additions and 120 deletions
+19
View File
@@ -4,6 +4,25 @@
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.3.0](https://git.fromme.dev/ep/appsmith-bff/compare/v0.2.0...v0.3.0) (2026-08-14)
### Features
* Auth verification endpoint ([cc46d8](https://git.fromme.dev/ep/appsmith-bff/commit/cc46d8c812abaf2fce879fc5d0a8d8f39a3d2043))
* Dedicated logging for auth and api calls ([e28b78](https://git.fromme.dev/ep/appsmith-bff/commit/e28b789af200d2b4cf368db4626cb25735e70206))
---
## [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
+166 -56
View File
@@ -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 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` 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. 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`) ### Backends (`config/services.yaml` → `backends`)
@@ -84,68 +99,173 @@ Production: point your webserver at `public/` as document root, `APP_ENV=prod`,
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`. 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`.
## Logging
Two dedicated Monolog channels write to their own rotating files in `var/log`, in every environment:
| File | Channel | Kept | Records |
| --- | --- | --- | --- |
| `auth.<env>.log` | `auth` | 30 days | The login/logout/authorization trail |
| `api.<env>.log` | `api` | 14 days | One record per `/api/*` call |
**`auth`** — `auth.login.start` and `auth.login.unknown_app` (login redirect), `auth.callback.state_mismatch` and `auth.callback.failed` (a failed code exchange or token decode, previously an anonymous 500), `auth.login.denied` (missing access role), `auth.login.success`, `auth.logout`, and the per-call rejections from `/auth/verify` and `/auth/me`: `auth.session.unauthenticated`, `auth.session.unknown_app`, `auth.session.expired`, `auth.session.access_denied`. Server-side token refreshes add `auth.session.refreshed`, `auth.session.missing`, `auth.session.refresh_failed` and `auth.token.decode_failed`. Successful `verify`/`me` calls are deliberately *not* logged — they run on every page load and would bury the rest.
**`api`** — `api.request` for every completed proxy call (`info` below status 400, `warning` from 400 up) with method, path, backend, upstream status and `duration_ms`, plus `api.request.unauthenticated`, `api.request.session_expired`, `api.request.unknown_backend` and `api.request.upstream_unreachable`.
Both channels are excluded from the `main` handler, so records are never duplicated and — importantly in prod, where `main` is `fingers_crossed` — never buffered away just because the request succeeded.
Two things to know about the content:
- **It contains PII.** Records carry `user_id` (Keycloak `sub`), `email`, `preferred_username` and the user's role list. Treat `var/log` accordingly: it is a personal-data store, not just diagnostics.
- **It never contains credentials.** Sessions appear only as `sid_hash`, a 12-character SHA-256 prefix of the `sid` — enough to follow one session across both files, useless as a bearer token. Access, refresh and ID tokens, request/response bodies, query strings and forwarded headers are never logged.
## Appsmith app integration ## Appsmith app integration
Create a shared module with this js: ### Queries to set up
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}}
```
| 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` |
`<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.
**`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"] }
```
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.
**`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
{
"email": "[email protected]",
"email_verified": true,
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"preferred_username": "jdoe",
"app": "crm",
"permissions": ["access", "view", "write"]
}
```
**`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
{{ 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 ```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'); 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 checkReturningFromLogin(loginPageName, protectedPageName) { async clearSession() {
const sid = appsmith.URL.queryParams.sid; await storeValue('sid', null);
const error = appsmith.URL.queryParams.error; await storeValue('user', null);
if (error === 'access_denied') { 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'); showAlert('You do not have access to this app.', 'error');
navigateTo(loginPageName, {}, 'SAME_WINDOW'); 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; return;
} }
if (sid) { if (sid) {
await storeValue('sid', sid); navigateTo(protectedPage, {}, 'SAME_WINDOW');
navigateTo(protectedPageName, {}, 'SAME_WINDOW');
}
if (appsmith.store.sid && !appsmith.store.user) {
const profile = await Bff_Me.run();
await storeValue('user', profile);
} }
}, },
guardPage(loginPageName) { async guardPage(loginPage) {
if (!loginPage) {
showAlert('Bff.guardPage was called without a login page name.', 'error');
return;
}
if (!this.isLoggedIn()) { if (!this.isLoggedIn()) {
navigateTo(loginPageName, {}, 'SAME_WINDOW'); 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() { async logout() {
const res = await Bff_Logout.run(); const res = await Bff_Logout.run();
await storeValue('sid', null); await this.clearSession();
await storeValue('user', null);
navigateTo(res.logout_url, {}, 'SAME_WINDOW'); navigateTo(res.logout_url, {}, 'SAME_WINDOW');
} }
} }
``` ```
On the login page add this js to be executed onLoad: 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 ```js
export default { {{ Bff.login('app-sandbox') }}
onLoad() {
return Bff.checkReturningFromLogin('Login', 'ProtectedPage');
}
}
``` ```
On the protected page add this js to be execute onLoad: using the same app key the `Bff_Me` and `Bff_Verify` queries send as `?app=`.
```js 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.
export default {
onLoad() { `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.
return Bff.guardPage('Login');
}
}
```
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
@@ -156,34 +276,24 @@ Login state can be used to toggle visibility of UI elements in Appsmith with
{{ !!appsmith.store.sid }} {{ !!appsmith.store.sid }}
``` ```
and user info is accessible via and user info — whatever `Bff_Me` returned — is accessible via
```js ```js
// Full user object // Full user object
{{ appsmith.store.user }} {{ appsmith.store.user }}
// Individual fields
{{ appsmith.store.user.email }}
``` ```
**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: Gate UI on permissions with
```js ```js
storeValue('sid', null); {{ (appsmith.store.permissions || []).includes('write') }}
navigateTo('LoginPage', {}, 'REPLACE');
``` ```
**User profile** — one query `Bff_Me`: `GET /auth/me`, header `Authorization: Bearer {{appsmith.store.sid}}`. Returns: `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.)
```json This is client-visible display data, not an authorization signal — the backend must still authorize off the real access token the BFF injects.
{
"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 ## Known limitation
+1 -1
View File
@@ -66,5 +66,5 @@
"require": "7.*" "require": "7.*"
} }
}, },
"version": "0.1.0" "version": "0.3.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", "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": "0890c5626a188b13a42af972c3d45cfd",
"packages": [ "packages": [
{ {
"name": "firebase/php-jwt", "name": "firebase/php-jwt",
+49 -3
View File
@@ -1,6 +1,13 @@
monolog: monolog:
channels: channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
# Audit channels, each with its own file in every env (see below).
# "auth" is the login/logout/authorization trail, "api" one record per
# proxied backend call. Both are excluded from "main" everywhere, so
# they never get duplicated — and, in prod, never get swallowed by the
# fingers_crossed buffer that only flushes on an error.
- auth
- api
when@dev: when@dev:
monolog: monolog:
@@ -9,11 +16,23 @@ when@dev:
type: stream type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug level: debug
channels: ["!event"] channels: ["!event", "!auth", "!api"]
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"] channels: ["!event", "!doctrine", "!console"]
auth:
type: rotating_file
channels: [auth]
path: "%kernel.logs_dir%/auth.%kernel.environment%.log"
max_files: 30
level: info
api:
type: rotating_file
channels: [api]
path: "%kernel.logs_dir%/api.%kernel.environment%.log"
max_files: 14
level: info
when@test: when@test:
monolog: monolog:
@@ -23,11 +42,23 @@ when@test:
action_level: error action_level: error
handler: nested handler: nested
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
channels: ["!event"] channels: ["!event", "!auth", "!api"]
nested: nested:
type: stream type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug level: debug
auth:
type: rotating_file
channels: [auth]
path: "%kernel.logs_dir%/auth.%kernel.environment%.log"
max_files: 30
level: info
api:
type: rotating_file
channels: [api]
path: "%kernel.logs_dir%/api.%kernel.environment%.log"
max_files: 14
level: info
when@prod: when@prod:
monolog: monolog:
@@ -37,7 +68,7 @@ when@prod:
action_level: error action_level: error
handler: nested handler: nested
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
channels: ["!deprecation"] channels: ["!deprecation", "!auth", "!api"]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested: nested:
# Plain rsync/Deployer shared hosting (see deploy.php), not a # Plain rsync/Deployer shared hosting (see deploy.php), not a
@@ -56,3 +87,18 @@ when@prod:
channels: [deprecation] channels: [deprecation]
path: "%kernel.logs_dir%/%kernel.environment%.deprecation.log" path: "%kernel.logs_dir%/%kernel.environment%.deprecation.log"
max_files: 14 max_files: 14
auth:
# The audit trail: kept longer than everything else, and never
# routed through fingers_crossed — these info records must land
# even on a request where nothing went wrong.
type: rotating_file
channels: [auth]
path: "%kernel.logs_dir%/auth.%kernel.environment%.log"
max_files: 30
level: info
api:
type: rotating_file
channels: [api]
path: "%kernel.logs_dir%/api.%kernel.environment%.log"
max_files: 14
level: info
+9 -3
View File
@@ -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
+227 -39
View File
@@ -2,9 +2,13 @@
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;
use App\Session\TokenRefresher;
use Monolog\Attribute\WithMonologChannel;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -13,13 +17,17 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
#[WithMonologChannel('auth')]
class AuthController extends AbstractController class AuthController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly BffSessionStore $store, private readonly BffSessionStore $store,
private readonly TokenRefresher $refresher,
private readonly IdTokenDecoder $idTokenDecoder, private readonly IdTokenDecoder $idTokenDecoder,
private readonly AppRegistry $apps, private readonly AppRegistry $apps,
private readonly AccessTokenRoles $tokenRoles,
private readonly LoggerInterface $logger,
private readonly string $kcBaseUrl, private readonly string $kcBaseUrl,
private readonly string $kcRealm, private readonly string $kcRealm,
private readonly string $kcClientId, private readonly string $kcClientId,
@@ -39,6 +47,11 @@ class AuthController extends AbstractController
{ {
$appKey = (string) $request->query->get('app', ''); $appKey = (string) $request->query->get('app', '');
if (!$this->apps->isValidApp($appKey)) { if (!$this->apps->isValidApp($appKey)) {
$this->logger->warning('auth.login.unknown_app', [
'app' => $appKey,
'ip' => $request->getClientIp(),
]);
throw $this->createNotFoundException('Unknown or missing app key'); throw $this->createNotFoundException('Unknown or missing app key');
} }
@@ -63,6 +76,11 @@ class AuthController extends AbstractController
'code_challenge_method' => 'S256', 'code_challenge_method' => 'S256',
]); ]);
$this->logger->info('auth.login.start', [
'app' => $appKey,
'ip' => $request->getClientIp(),
]);
return new RedirectResponse( return new RedirectResponse(
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/auth?{$params}" "{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/auth?{$params}"
); );
@@ -76,33 +94,54 @@ class AuthController extends AbstractController
$expectedState = (string) $session->get('oauth_state', ''); $expectedState = (string) $session->get('oauth_state', '');
$givenState = (string) $request->query->get('state', ''); $givenState = (string) $request->query->get('state', '');
if ($expectedState === '' || !hash_equals($expectedState, $givenState)) { if ($expectedState === '' || !hash_equals($expectedState, $givenState)) {
$this->logger->warning('auth.callback.state_mismatch', [
'ip' => $request->getClientIp(),
'had_expected_state' => $expectedState !== '',
]);
return new Response('Invalid or missing state', 401); return new Response('Invalid or missing state', 401);
} }
$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( // Everything from here to the role check either succeeds or throws
'POST', // (Keycloak unreachable, code rejected, bad signature, expired token,
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/token", // no sid claim) and ends as an anonymous 500. Log the cause, then let
[ // it through untouched — the response behaviour is deliberately
'body' => [ // unchanged.
'grant_type' => 'authorization_code', try {
'client_id' => $this->kcClientId, $response = $this->client->request(
'client_secret' => $this->kcClientSecret, 'POST',
'code' => $request->query->get('code'), "{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/token",
'redirect_uri' => $this->kcRedirectUri, [
'code_verifier' => $session->get('pkce_verifier'), 'body' => [
], 'grant_type' => 'authorization_code',
] 'client_id' => $this->kcClientId,
); 'client_secret' => $this->kcClientSecret,
$tokens = $response->toArray(); 'code' => $request->query->get('code'),
'redirect_uri' => $this->kcRedirectUri,
'code_verifier' => $session->get('pkce_verifier'),
],
]
);
$tokens = $response->toArray();
$idClaims = $this->idTokenDecoder->decode($tokens['id_token']); $idClaims = $this->idTokenDecoder->decode($tokens['id_token']);
$kcSid = $idClaims['sid'] ?? null; $kcSid = $idClaims['sid'] ?? null;
if (!$kcSid) { if (!$kcSid) {
throw new \RuntimeException('Keycloak did not issue a "sid" claim on the ID token'); throw new \RuntimeException('Keycloak did not issue a "sid" claim on the ID token');
}
$accessClaims = $this->idTokenDecoder->decode($tokens['access_token']);
} catch (\Throwable $e) {
$this->logger->error('auth.callback.failed', [
'app' => $appKey,
'ip' => $request->getClientIp(),
'exception' => $e,
]);
throw $e;
} }
// Authorization gate: does this user hold the role required for // Authorization gate: does this user hold the role required for
@@ -111,8 +150,21 @@ 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.
$accessClaims = $this->idTokenDecoder->decode($tokens['access_token']); //
if (!$this->hasRole($accessClaims, $requiredRole)) { // 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.
$roles = $this->tokenRoles->extract($accessClaims);
if (!in_array($this->apps->accessRole($appKey), $roles, true)) {
$this->logger->warning('auth.login.denied', [
'app' => $appKey,
'required_role' => $this->apps->accessRole($appKey),
'roles' => $roles,
'user_id' => $idClaims['sub'] ?? null,
'email' => $idClaims['email'] ?? null,
]);
$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 +186,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,
@@ -144,39 +197,78 @@ class AuthController extends AbstractController
], ],
]); ]);
$this->logger->info('auth.login.success', [
'app' => $appKey,
'user_id' => $idClaims['sub'],
'email' => $idClaims['email'] ?? null,
'preferred_username' => $idClaims['preferred_username'] ?? null,
'roles' => $roles,
'sid_hash' => $this->sidHash($kcSid),
'expires_at' => time() + (int) $tokens['expires_in'],
]);
$separator = str_contains($returnUrl, '?') ? '&' : '?'; $separator = str_contains($returnUrl, '?') ? '&' : '?';
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 * 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
{ {
$roles = $accessTokenClaims['resource_access'][$this->kcClientId]['roles'] ?? []; $appKey = (string) $request->query->get('app', '');
return in_array($role, $roles, true); $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). 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 — 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
* 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
{ {
$kcSid = $this->extractBearer($request); $appKey = (string) $request->query->get('app', '');
if (!$kcSid) {
return new JsonResponse(['error' => 'unauthenticated'], 401); $session = $this->resolveAppSession($request, $appKey);
if ($session instanceof JsonResponse) {
return $session;
} }
$session = $this->store->get($kcSid); return new JsonResponse(($session['profile'] ?? []) + [
if ($session === null) { 'app' => $appKey,
return new JsonResponse(['error' => 'session expired'], 401); 'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []),
} ]);
return new JsonResponse($session['profile'] ?? []);
} }
/** /**
@@ -190,6 +282,8 @@ class AuthController extends AbstractController
{ {
$kcSid = $this->extractBearer($request); $kcSid = $this->extractBearer($request);
if (!$kcSid) { if (!$kcSid) {
$this->logger->info('auth.logout.no_session');
return new JsonResponse(['error' => 'missing session'], 401); return new JsonResponse(['error' => 'missing session'], 401);
} }
@@ -199,6 +293,15 @@ class AuthController extends AbstractController
// One delete kills the session for every app that shared it. // One delete kills the session for every app that shared it.
$this->store->revoke($kcSid); $this->store->revoke($kcSid);
$this->logger->info('auth.logout', [
'sid_hash' => $this->sidHash($kcSid),
'user_id' => $data['user_id'] ?? null,
'email' => $data['profile']['email'] ?? null,
// false when the sid was already dead (expired, or a second
// logout from another tab) — the response is the same either way.
'was_live' => $data !== null,
]);
$params = http_build_query(array_filter([ $params = http_build_query(array_filter([
'id_token_hint' => $idToken, 'id_token_hint' => $idToken,
'post_logout_redirect_uri' => $this->postLogoutRedirect, 'post_logout_redirect_uri' => $this->postLogoutRedirect,
@@ -209,6 +312,81 @@ 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) {
// Routine on first page load, before an app has a sid to send.
$this->logger->info('auth.session.unauthenticated', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
]);
return new JsonResponse(['error' => 'unauthenticated'], 401);
}
if (!$this->apps->isValidApp($appKey)) {
$this->logger->warning('auth.session.unknown_app', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
]);
return new JsonResponse(['error' => 'unknown or missing app key'], 400);
}
if ($this->refresher->ensureFresh($kcSid) === null) {
$this->logger->info('auth.session.expired', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
'sid_hash' => $this->sidHash($kcSid),
]);
return new JsonResponse(['error' => 'session expired'], 401);
}
$session = $this->store->get($kcSid);
if ($session === null) {
$this->logger->info('auth.session.expired', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
'sid_hash' => $this->sidHash($kcSid),
]);
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)) {
$this->logger->warning('auth.session.access_denied', [
'app' => $appKey,
'endpoint' => $request->getPathInfo(),
'required_role' => $this->apps->accessRole($appKey),
'roles' => $session['roles'] ?? [],
'user_id' => $session['user_id'] ?? null,
'email' => $session['profile']['email'] ?? null,
'sid_hash' => $this->sidHash($kcSid),
]);
return new JsonResponse(['error' => 'access_denied'], 403);
}
return $session;
}
private function extractBearer(Request $request): ?string private function extractBearer(Request $request): ?string
{ {
$header = $request->headers->get('Authorization', ''); $header = $request->headers->get('Authorization', '');
@@ -216,6 +394,16 @@ class AuthController extends AbstractController
return str_starts_with($header, 'Bearer ') ? substr($header, 7) : null; return str_starts_with($header, 'Bearer ') ? substr($header, 7) : null;
} }
/**
* A sid is a live bearer credential, so it never goes into a log file.
* This short digest is enough to correlate records of one session
* without being replayable if the logs leak.
*/
private function sidHash(string $kcSid): string
{
return substr(hash('sha256', $kcSid), 0, 12);
}
private function base64UrlEncode(string $bytes): string private function base64UrlEncode(string $bytes): string
{ {
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
+58 -1
View File
@@ -3,7 +3,9 @@
namespace App\Controller; namespace App\Controller;
use App\Security\BackendRegistry; use App\Security\BackendRegistry;
use App\Session\BffSessionStore;
use App\Session\TokenRefresher; use App\Session\TokenRefresher;
use Monolog\Attribute\WithMonologChannel;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -11,6 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
#[WithMonologChannel('api')]
class ProxyController extends AbstractController class ProxyController extends AbstractController
{ {
private const string DEFAULT_BACKEND_KEY = 'default'; private const string DEFAULT_BACKEND_KEY = 'default';
@@ -25,6 +28,7 @@ class ProxyController extends AbstractController
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly TokenRefresher $refresher, private readonly TokenRefresher $refresher,
private readonly BackendRegistry $backends, private readonly BackendRegistry $backends,
private readonly BffSessionStore $store,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
} }
@@ -37,22 +41,51 @@ class ProxyController extends AbstractController
#[Route('/api/{path}', requirements: ['path' => '.+'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])] #[Route('/api/{path}', requirements: ['path' => '.+'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])]
public function proxy(Request $request, string $path): Response public function proxy(Request $request, string $path): Response
{ {
$start = microtime(true);
$kcSid = $this->extractBearer($request); $kcSid = $this->extractBearer($request);
if (!$kcSid) { if (!$kcSid) {
$this->logger->info('api.request.unauthenticated', [
'method' => $request->getMethod(),
'path' => $path,
]);
return $this->jsonError('unauthenticated', 401); return $this->jsonError('unauthenticated', 401);
} }
$accessToken = $this->refresher->ensureFresh($kcSid); $accessToken = $this->refresher->ensureFresh($kcSid);
if (!$accessToken) { if (!$accessToken) {
$this->logger->info('api.request.session_expired', [
'method' => $request->getMethod(),
'path' => $path,
'sid_hash' => $this->sidHash($kcSid),
]);
return $this->jsonError('session expired', 401); return $this->jsonError('session expired', 401);
} }
$backendKey = $request->headers->get('X-Backend', self::DEFAULT_BACKEND_KEY); $backendKey = $request->headers->get('X-Backend', self::DEFAULT_BACKEND_KEY);
if (!$this->backends->isValidBackend($backendKey)) { if (!$this->backends->isValidBackend($backendKey)) {
$this->logger->warning('api.request.unknown_backend', [
'backend' => $backendKey,
'method' => $request->getMethod(),
'path' => $path,
'sid_hash' => $this->sidHash($kcSid),
]);
return $this->jsonError('unknown backend', 400); return $this->jsonError('unknown backend', 400);
} }
$backendBaseUrl = $this->backends->resolveBaseUrl($backendKey); $backendBaseUrl = $this->backends->resolveBaseUrl($backendKey);
// ensureFresh() has just read (and possibly rewritten) this entry, so
// this is a cache hit — it costs nothing and gives the log an identity.
$session = $this->store->get($kcSid);
$caller = [
'user_id' => $session['user_id'] ?? null,
'email' => $session['profile']['email'] ?? null,
'sid_hash' => $this->sidHash($kcSid),
];
$forwardHeaders = []; $forwardHeaders = [];
foreach ($request->headers->all() as $name => $values) { foreach ($request->headers->all() as $name => $values) {
$lower = strtolower($name); $lower = strtolower($name);
@@ -72,18 +105,42 @@ class ProxyController extends AbstractController
$body = $upstream->getContent(false); $body = $upstream->getContent(false);
$contentType = $upstream->getHeaders(false)['content-type'][0] ?? 'application/json'; $contentType = $upstream->getHeaders(false)['content-type'][0] ?? 'application/json';
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('Proxy request to backend failed', [ $this->logger->error('api.request.upstream_unreachable', $caller + [
'backend' => $backendKey, 'backend' => $backendKey,
'method' => $request->getMethod(),
'path' => $path, 'path' => $path,
'duration_ms' => $this->elapsedMs($start),
'exception' => $e, 'exception' => $e,
]); ]);
return $this->jsonError('upstream unreachable', 502); return $this->jsonError('upstream unreachable', 502);
} }
// Never log bodies, query strings or headers: the forwarded headers
// carry the real Keycloak access token, and both bodies and query
// strings can carry anything the backend deals in.
$this->logger->log($status >= 400 ? 'warning' : 'info', 'api.request', $caller + [
'backend' => $backendKey,
'method' => $request->getMethod(),
'path' => $path,
'status' => $status,
'duration_ms' => $this->elapsedMs($start),
]);
return new Response($body, $status, ['Content-Type' => $contentType]); return new Response($body, $status, ['Content-Type' => $contentType]);
} }
private function elapsedMs(float $start): float
{
return round((microtime(true) - $start) * 1000, 1);
}
/** Never log a raw sid — see AuthController::sidHash(). */
private function sidHash(string $kcSid): string
{
return substr(hash('sha256', $kcSid), 0, 12);
}
private function extractBearer(Request $request): ?string private function extractBearer(Request $request): ?string
{ {
$header = $request->headers->get('Authorization', ''); $header = $request->headers->get('Authorization', '');
+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, * 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])) {
+25 -10
View File
@@ -4,7 +4,9 @@ namespace App\Security;
use Firebase\JWT\JWK; use Firebase\JWT\JWK;
use Firebase\JWT\JWT; use Firebase\JWT\JWT;
use Monolog\Attribute\WithMonologChannel;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -14,11 +16,13 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
* trusting any claims from it (in particular the "sid" and "sub" claims we * trusting any claims from it (in particular the "sid" and "sub" claims we
* key sessions on). Requires firebase/php-jwt. * key sessions on). Requires firebase/php-jwt.
*/ */
#[WithMonologChannel('auth')]
class IdTokenDecoder class IdTokenDecoder
{ {
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
private readonly string $kcBaseUrl, private readonly string $kcBaseUrl,
private readonly string $kcRealm, private readonly string $kcRealm,
) { ) {
@@ -29,18 +33,29 @@ class IdTokenDecoder
*/ */
public function decode(string $idToken): array public function decode(string $idToken): array
{ {
$jwks = $this->cache->get('keycloak_jwks_' . $this->kcRealm, function (ItemInterface $item) { // A bad signature, an expired token or an unreachable JWKS endpoint all
$item->expiresAfter(3600); // surface to the caller as a 500. Record why, then rethrow unchanged.
$response = $this->client->request( try {
'GET', $jwks = $this->cache->get('keycloak_jwks_' . $this->kcRealm, function (ItemInterface $item) {
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/certs" $item->expiresAfter(3600);
); $response = $this->client->request(
'GET',
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/certs"
);
return $response->toArray(); return $response->toArray();
}); });
$keys = JWK::parseKeySet($jwks); $keys = JWK::parseKeySet($jwks);
$claims = JWT::decode($idToken, $keys); $claims = JWT::decode($idToken, $keys);
} catch (\Throwable $e) {
$this->logger->error('auth.token.decode_failed', [
'realm' => $this->kcRealm,
'exception' => $e,
]);
throw $e;
}
/** @var array<string,mixed> $decoded */ /** @var array<string,mixed> $decoded */
$decoded = json_decode((string) json_encode($claims), true); $decoded = json_decode((string) json_encode($claims), true);
+45 -1
View File
@@ -2,9 +2,13 @@
namespace App\Session; namespace App\Session;
use App\Security\AccessTokenRoles;
use App\Security\IdTokenDecoder;
use Monolog\Attribute\WithMonologChannel;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
#[WithMonologChannel('auth')]
class TokenRefresher class TokenRefresher
{ {
private const int EXPIRY_LEEWAY_SECONDS = 30; private const int EXPIRY_LEEWAY_SECONDS = 30;
@@ -12,6 +16,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,
@@ -30,6 +36,12 @@ class TokenRefresher
{ {
$session = $this->store->get($kcSid); $session = $this->store->get($kcSid);
if ($session === null) { if ($session === null) {
// Expired out of the cache, revoked by a logout, or simply never
// existed (a forged sid) — indistinguishable from here.
$this->logger->info('auth.session.missing', [
'sid_hash' => $this->sidHash($kcSid),
]);
return null; return null;
} }
@@ -53,7 +65,9 @@ class TokenRefresher
$tokens = $response->toArray(); $tokens = $response->toArray();
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Refresh token expired/revoked (e.g. Keycloak-side admin logout) — kill the session. // Refresh token expired/revoked (e.g. Keycloak-side admin logout) — kill the session.
$this->logger->warning('Keycloak token refresh failed, revoking session', [ $this->logger->warning('auth.session.refresh_failed', [
'sid_hash' => $this->sidHash($kcSid),
'user_id' => $session['user_id'] ?? null,
'exception' => $e, 'exception' => $e,
]); ]);
$this->store->revoke($kcSid); $this->store->revoke($kcSid);
@@ -64,8 +78,38 @@ 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('auth.session.role_reread_failed', [
'sid_hash' => $this->sidHash($kcSid),
'user_id' => $session['user_id'] ?? null,
'exception' => $e,
]);
}
$this->store->put($kcSid, $session); $this->store->put($kcSid, $session);
$this->logger->info('auth.session.refreshed', [
'sid_hash' => $this->sidHash($kcSid),
'user_id' => $session['user_id'] ?? null,
'roles' => $session['roles'] ?? [],
'expires_at' => $session['expires_at'],
]);
return $session['access_token']; return $session['access_token'];
} }
/** Never log a raw sid — see AuthController::sidHash(). */
private function sidHash(string $kcSid): string
{
return substr(hash('sha256', $kcSid), 0, 12);
}
} }