Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84b37bbaef | ||
|
|
7f0e739db3 | ||
|
|
6cdbca9c08 | ||
|
|
5a6e751673 | ||
|
|
0335f48c48 | ||
|
|
e2f2ba7d1b | ||
|
|
e28b789af2 | ||
|
|
cc46d8c812 | ||
|
|
f53775ef4b | ||
|
|
b9edc98074 | ||
|
|
b62abb1801 |
@@ -26,5 +26,5 @@ BACKEND_BASE_URL=https://backend.corp.internal/api
|
|||||||
###> appsmith apps ###
|
###> appsmith apps ###
|
||||||
# One line per Appsmith app you register — add more as you build more apps,
|
# One line per Appsmith app you register — add more as you build more apps,
|
||||||
# and add a matching line in config/services.yaml under appsmith.apps.
|
# and add a matching line in config/services.yaml under appsmith.apps.
|
||||||
APP_SANDBOX_LOGIN_URL=https://appsmith.corp.internal/app/sandbox/login-abc123
|
APP_SANDBOX_URL=https://appsmith.corp.internal/app/app-sandbox/login-1234567890abcdef
|
||||||
###< appsmith apps ###
|
###< appsmith apps ###
|
||||||
|
|||||||
@@ -4,6 +4,43 @@
|
|||||||
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.2](https://git.fromme.dev/ep/appsmith-bff/compare/v0.3.1...v0.3.2) (2026-09-03)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Adjust deployer config to new hetzner host ([6cdbca](https://git.fromme.dev/ep/appsmith-bff/commit/6cdbca9c086aa08a1ad96a579134d877d7891251))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.1](https://git.fromme.dev/ep/appsmith-bff/compare/v0.3.0...v0.3.1) (2026-08-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Adjust config to prod environment ([0335f4](https://git.fromme.dev/ep/appsmith-bff/commit/0335f48c485b48ada293a4824163e0580dd7a3f8))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
@@ -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
@@ -66,5 +66,5 @@
|
|||||||
"require": "7.*"
|
"require": "7.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.1.0"
|
"version": "0.3.2"
|
||||||
}
|
}
|
||||||
Generated
+138
-137
@@ -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": "e1fa8d3da5778b26157258ab27f56d5d",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "firebase/php-jwt",
|
"name": "firebase/php-jwt",
|
||||||
@@ -74,16 +74,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.11.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/Seldaek/monolog.git",
|
"url": "https://github.com/Seldaek/monolog.git",
|
||||||
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
|
"reference": "147f303310f06334f03f409e49d7ad1e275ff05a"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a",
|
||||||
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
"reference": "147f303310f06334f03f409e49d7ad1e275ff05a",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/Seldaek/monolog/issues",
|
"issues": "https://github.com/Seldaek/monolog/issues",
|
||||||
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
|
"source": "https://github.com/Seldaek/monolog/tree/3.11.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-01-02T08:56:05+00:00"
|
"time": "2026-09-02T12:39:56+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/cache",
|
"name": "psr/cache",
|
||||||
@@ -379,16 +379,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/cache",
|
"name": "symfony/cache",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/cache.git",
|
"url": "https://github.com/symfony/cache.git",
|
||||||
"reference": "23a2c5298ca72c4d26b04611ed966c86762ffd49"
|
"reference": "6c521e19a99e8ae57e22aa85b9271534513ee850"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/cache/zipball/23a2c5298ca72c4d26b04611ed966c86762ffd49",
|
"url": "https://api.github.com/repos/symfony/cache/zipball/6c521e19a99e8ae57e22aa85b9271534513ee850",
|
||||||
"reference": "23a2c5298ca72c4d26b04611ed966c86762ffd49",
|
"reference": "6c521e19a99e8ae57e22aa85b9271534513ee850",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -413,7 +413,7 @@
|
|||||||
"symfony/cache-implementation": "1.1|2.0|3.0"
|
"symfony/cache-implementation": "1.1|2.0|3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"cache/integration-tests": "dev-master",
|
"cache/integration-tests": "^1.0.3",
|
||||||
"doctrine/dbal": "^3.6|^4",
|
"doctrine/dbal": "^3.6|^4",
|
||||||
"predis/predis": "^1.1|^2.0",
|
"predis/predis": "^1.1|^2.0",
|
||||||
"psr/simple-cache": "^1.0|^2.0|^3.0",
|
"psr/simple-cache": "^1.0|^2.0|^3.0",
|
||||||
@@ -458,7 +458,7 @@
|
|||||||
"psr6"
|
"psr6"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/cache/tree/v7.4.16"
|
"source": "https://github.com/symfony/cache/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -478,7 +478,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-31T19:35:04+00:00"
|
"time": "2026-08-30T20:10:52+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/cache-contracts",
|
"name": "symfony/cache-contracts",
|
||||||
@@ -562,16 +562,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/config",
|
"name": "symfony/config",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.17",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/config.git",
|
"url": "https://github.com/symfony/config.git",
|
||||||
"reference": "e896f5e874e6983d0a098f554ca82a08a924c298"
|
"reference": "696e12da8eea497a1a3808d714b43ff67a7df43e"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/config/zipball/e896f5e874e6983d0a098f554ca82a08a924c298",
|
"url": "https://api.github.com/repos/symfony/config/zipball/696e12da8eea497a1a3808d714b43ff67a7df43e",
|
||||||
"reference": "e896f5e874e6983d0a098f554ca82a08a924c298",
|
"reference": "696e12da8eea497a1a3808d714b43ff67a7df43e",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -617,7 +617,7 @@
|
|||||||
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
|
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/config/tree/v7.4.16"
|
"source": "https://github.com/symfony/config/tree/v7.4.17"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -637,20 +637,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-30T15:04:16+00:00"
|
"time": "2026-08-20T09:55:18+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/console",
|
"name": "symfony/console",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/console.git",
|
"url": "https://github.com/symfony/console.git",
|
||||||
"reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d"
|
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/console/zipball/f4c69c9aed03abf933b294257d618bdd9b30a06d",
|
"url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
|
||||||
"reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d",
|
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -715,7 +715,7 @@
|
|||||||
"terminal"
|
"terminal"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/console/tree/v7.4.16"
|
"source": "https://github.com/symfony/console/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -735,20 +735,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-31T12:37:14+00:00"
|
"time": "2026-08-25T14:18:37+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/dependency-injection",
|
"name": "symfony/dependency-injection",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.17",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/dependency-injection.git",
|
"url": "https://github.com/symfony/dependency-injection.git",
|
||||||
"reference": "7f59a843c1fbcceafecdf6863d3e38ea6ecd5061"
|
"reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/7f59a843c1fbcceafecdf6863d3e38ea6ecd5061",
|
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f318ac9da5aba0be2cf43ecdd562c05a33bc11c0",
|
||||||
"reference": "7f59a843c1fbcceafecdf6863d3e38ea6ecd5061",
|
"reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -799,7 +799,7 @@
|
|||||||
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
|
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.16"
|
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.17"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -819,7 +819,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-06T09:45:22+00:00"
|
"time": "2026-08-21T17:40:08+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/deprecation-contracts",
|
"name": "symfony/deprecation-contracts",
|
||||||
@@ -894,7 +894,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/dotenv",
|
"name": "symfony/dotenv",
|
||||||
"version": "v7.4.15",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/dotenv.git",
|
"url": "https://github.com/symfony/dotenv.git",
|
||||||
@@ -948,7 +948,7 @@
|
|||||||
"environment"
|
"environment"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/dotenv/tree/v7.4.15"
|
"source": "https://github.com/symfony/dotenv/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -972,16 +972,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/error-handler",
|
"name": "symfony/error-handler",
|
||||||
"version": "v7.4.15",
|
"version": "v7.4.17",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/error-handler.git",
|
"url": "https://github.com/symfony/error-handler.git",
|
||||||
"reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261"
|
"reference": "8373921e231e190a88e2ad526951bbaa791576fa"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/error-handler/zipball/d49f6a19f326db41ae7103bdc38e3eb35a791261",
|
"url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa",
|
||||||
"reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261",
|
"reference": "8373921e231e190a88e2ad526951bbaa791576fa",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1030,7 +1030,7 @@
|
|||||||
"description": "Provides tools to manage errors and ease debugging PHP code",
|
"description": "Provides tools to manage errors and ease debugging PHP code",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/error-handler/tree/v7.4.15"
|
"source": "https://github.com/symfony/error-handler/tree/v7.4.17"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1050,20 +1050,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-21T15:13:06+00:00"
|
"time": "2026-08-21T17:40:08+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/event-dispatcher",
|
"name": "symfony/event-dispatcher",
|
||||||
"version": "v7.4.15",
|
"version": "v7.4.17",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/event-dispatcher.git",
|
"url": "https://github.com/symfony/event-dispatcher.git",
|
||||||
"reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9"
|
"reference": "d269974ee93c61d03620ffee358355bfdb471d66"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/336e7f3b9e95aba04f93ea9143920c2186abfbb9",
|
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66",
|
||||||
"reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9",
|
"reference": "d269974ee93c61d03620ffee358355bfdb471d66",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1115,7 +1115,7 @@
|
|||||||
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
|
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/event-dispatcher/tree/v7.4.15"
|
"source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1135,7 +1135,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-21T15:13:06+00:00"
|
"time": "2026-08-21T17:40:08+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/event-dispatcher-contracts",
|
"name": "symfony/event-dispatcher-contracts",
|
||||||
@@ -1219,16 +1219,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/filesystem",
|
"name": "symfony/filesystem",
|
||||||
"version": "v7.4.15",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/filesystem.git",
|
"url": "https://github.com/symfony/filesystem.git",
|
||||||
"reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79"
|
"reference": "90d412aa5277c6819db39e7605aa46b1019e3232"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/ff16a16bf87fdf264638b8f6995b3515975e3c79",
|
"url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232",
|
||||||
"reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79",
|
"reference": "90d412aa5277c6819db39e7605aa46b1019e3232",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1265,7 +1265,7 @@
|
|||||||
"description": "Provides basic utilities for the filesystem",
|
"description": "Provides basic utilities for the filesystem",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/filesystem/tree/v7.4.15"
|
"source": "https://github.com/symfony/filesystem/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1285,20 +1285,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-22T07:36:05+00:00"
|
"time": "2026-08-23T10:03:40+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/finder",
|
"name": "symfony/finder",
|
||||||
"version": "v7.4.14",
|
"version": "v7.4.17",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/finder.git",
|
"url": "https://github.com/symfony/finder.git",
|
||||||
"reference": "13b38720174286f55d1761152b575a8d1436fc25"
|
"reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25",
|
"url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6",
|
||||||
"reference": "13b38720174286f55d1761152b575a8d1436fc25",
|
"reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1333,7 +1333,7 @@
|
|||||||
"description": "Finds files and directories via an intuitive fluent interface",
|
"description": "Finds files and directories via an intuitive fluent interface",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/finder/tree/v7.4.14"
|
"source": "https://github.com/symfony/finder/tree/v7.4.17"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1353,7 +1353,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-27T08:31:18+00:00"
|
"time": "2026-08-21T12:09:28+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/flex",
|
"name": "symfony/flex",
|
||||||
@@ -1430,16 +1430,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/framework-bundle",
|
"name": "symfony/framework-bundle",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/framework-bundle.git",
|
"url": "https://github.com/symfony/framework-bundle.git",
|
||||||
"reference": "fa9c81699911e0eb9986306d5a5694f470f5aaff"
|
"reference": "45d6d66b6ea1ef7a3c4193dfe606370f0f80c9bf"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/fa9c81699911e0eb9986306d5a5694f470f5aaff",
|
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/45d6d66b6ea1ef7a3c4193dfe606370f0f80c9bf",
|
||||||
"reference": "fa9c81699911e0eb9986306d5a5694f470f5aaff",
|
"reference": "45d6d66b6ea1ef7a3c4193dfe606370f0f80c9bf",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1473,7 +1473,7 @@
|
|||||||
"symfony/form": "<7.4",
|
"symfony/form": "<7.4",
|
||||||
"symfony/http-client": "<6.4",
|
"symfony/http-client": "<6.4",
|
||||||
"symfony/lock": "<6.4",
|
"symfony/lock": "<6.4",
|
||||||
"symfony/mailer": "<6.4",
|
"symfony/mailer": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5",
|
||||||
"symfony/messenger": "<7.4",
|
"symfony/messenger": "<7.4",
|
||||||
"symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9",
|
"symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9",
|
||||||
"symfony/property-access": "<6.4",
|
"symfony/property-access": "<6.4",
|
||||||
@@ -1564,7 +1564,7 @@
|
|||||||
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
|
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.16"
|
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1584,20 +1584,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-06T09:41:15+00:00"
|
"time": "2026-08-30T20:10:52+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-client",
|
"name": "symfony/http-client",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/http-client.git",
|
"url": "https://github.com/symfony/http-client.git",
|
||||||
"reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41"
|
"reference": "68d81f78d127984ff2e741f0e0d9cb1078f8f3ea"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/c513ed0ba5d1784a6b55fc84190dbe4451b12f41",
|
"url": "https://api.github.com/repos/symfony/http-client/zipball/68d81f78d127984ff2e741f0e0d9cb1078f8f3ea",
|
||||||
"reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41",
|
"reference": "68d81f78d127984ff2e741f0e0d9cb1078f8f3ea",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1665,7 +1665,7 @@
|
|||||||
"http"
|
"http"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/http-client/tree/v7.4.16"
|
"source": "https://github.com/symfony/http-client/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1685,20 +1685,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-29T16:20:51+00:00"
|
"time": "2026-08-30T13:49:59+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-client-contracts",
|
"name": "symfony/http-client-contracts",
|
||||||
"version": "v3.7.1",
|
"version": "v3.7.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||||
"reference": "41fc42d276aeff21192465331ebbab7d83a743c0"
|
"reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0",
|
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f",
|
||||||
"reference": "41fc42d276aeff21192465331ebbab7d83a743c0",
|
"reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1747,7 +1747,7 @@
|
|||||||
"standards"
|
"standards"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1"
|
"source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1767,20 +1767,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-05T06:23:12+00:00"
|
"time": "2026-08-04T08:41:16+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-foundation",
|
"name": "symfony/http-foundation",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/http-foundation.git",
|
"url": "https://github.com/symfony/http-foundation.git",
|
||||||
"reference": "b676451bb638e99a7d34d8a2be90406822e301eb"
|
"reference": "d070b716a32fbe3bf04204db0f58ace73b86d133"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/b676451bb638e99a7d34d8a2be90406822e301eb",
|
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133",
|
||||||
"reference": "b676451bb638e99a7d34d8a2be90406822e301eb",
|
"reference": "d070b716a32fbe3bf04204db0f58ace73b86d133",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1829,7 +1829,7 @@
|
|||||||
"description": "Defines an object-oriented layer for the HTTP specification",
|
"description": "Defines an object-oriented layer for the HTTP specification",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.16"
|
"source": "https://github.com/symfony/http-foundation/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1849,20 +1849,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-07T11:50:27+00:00"
|
"time": "2026-08-30T20:10:52+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-kernel",
|
"name": "symfony/http-kernel",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/http-kernel.git",
|
"url": "https://github.com/symfony/http-kernel.git",
|
||||||
"reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c"
|
"reference": "275d2d2d24530f2a0eaf17704a3a93860a036351"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/f5e728670fa2218ae8be8ea91f2b44b7d6e5304c",
|
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351",
|
||||||
"reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c",
|
"reference": "275d2d2d24530f2a0eaf17704a3a93860a036351",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1948,7 +1948,7 @@
|
|||||||
"description": "Provides a structured process for converting a Request into a Response",
|
"description": "Provides a structured process for converting a Request into a Response",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/http-kernel/tree/v7.4.16"
|
"source": "https://github.com/symfony/http-kernel/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1968,20 +1968,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-07T18:00:13+00:00"
|
"time": "2026-08-30T21:24:29+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/monolog-bridge",
|
"name": "symfony/monolog-bridge",
|
||||||
"version": "v7.4.15",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/monolog-bridge.git",
|
"url": "https://github.com/symfony/monolog-bridge.git",
|
||||||
"reference": "007907c5c537c4e0ba9bb10ed196a1e4287379a4"
|
"reference": "b72ab77337167f2a545c35e624141df68c274ad8"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/007907c5c537c4e0ba9bb10ed196a1e4287379a4",
|
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/b72ab77337167f2a545c35e624141df68c274ad8",
|
||||||
"reference": "007907c5c537c4e0ba9bb10ed196a1e4287379a4",
|
"reference": "b72ab77337167f2a545c35e624141df68c274ad8",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2002,6 +2002,7 @@
|
|||||||
"symfony/mailer": "^6.4|^7.0|^8.0",
|
"symfony/mailer": "^6.4|^7.0|^8.0",
|
||||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||||
"symfony/mime": "^6.4|^7.0|^8.0",
|
"symfony/mime": "^6.4|^7.0|^8.0",
|
||||||
|
"symfony/notifier": "^6.4|^7.0|^8.0",
|
||||||
"symfony/security-core": "^6.4|^7.0|^8.0",
|
"symfony/security-core": "^6.4|^7.0|^8.0",
|
||||||
"symfony/var-dumper": "^6.4|^7.0|^8.0"
|
"symfony/var-dumper": "^6.4|^7.0|^8.0"
|
||||||
},
|
},
|
||||||
@@ -2031,7 +2032,7 @@
|
|||||||
"description": "Provides integration for Monolog with various Symfony components",
|
"description": "Provides integration for Monolog with various Symfony components",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/monolog-bridge/tree/v7.4.15"
|
"source": "https://github.com/symfony/monolog-bridge/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2051,7 +2052,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-27T13:51:00+00:00"
|
"time": "2026-08-30T00:47:26+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/monolog-bundle",
|
"name": "symfony/monolog-bundle",
|
||||||
@@ -2216,16 +2217,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-intl-normalizer",
|
"name": "symfony/polyfill-intl-normalizer",
|
||||||
"version": "v1.38.0",
|
"version": "v1.42.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
|
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
|
||||||
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
|
"reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
|
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502",
|
||||||
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
|
"reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2277,7 +2278,7 @@
|
|||||||
"shim"
|
"shim"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
|
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2297,7 +2298,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-25T13:48:31+00:00"
|
"time": "2026-08-07T06:33:24+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-mbstring",
|
"name": "symfony/polyfill-mbstring",
|
||||||
@@ -2626,16 +2627,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/routing",
|
"name": "symfony/routing",
|
||||||
"version": "v7.4.15",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/routing.git",
|
"url": "https://github.com/symfony/routing.git",
|
||||||
"reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b"
|
"reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/routing/zipball/80c0a93d3f8e7499f716204a1fb38ead942a7a2b",
|
"url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3",
|
||||||
"reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b",
|
"reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2687,7 +2688,7 @@
|
|||||||
"url"
|
"url"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/routing/tree/v7.4.15"
|
"source": "https://github.com/symfony/routing/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2707,7 +2708,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-21T15:13:06+00:00"
|
"time": "2026-08-17T13:12:36+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/runtime",
|
"name": "symfony/runtime",
|
||||||
@@ -2796,16 +2797,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/service-contracts",
|
"name": "symfony/service-contracts",
|
||||||
"version": "v3.7.1",
|
"version": "v3.7.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/service-contracts.git",
|
"url": "https://github.com/symfony/service-contracts.git",
|
||||||
"reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
|
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
|
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
|
||||||
"reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
|
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2859,7 +2860,7 @@
|
|||||||
"standards"
|
"standards"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
|
"source": "https://github.com/symfony/service-contracts/tree/v3.7.3"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2879,7 +2880,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-16T09:55:08+00:00"
|
"time": "2026-07-27T15:39:01+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/string",
|
"name": "symfony/string",
|
||||||
@@ -2974,16 +2975,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/var-dumper",
|
"name": "symfony/var-dumper",
|
||||||
"version": "v7.4.15",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/var-dumper.git",
|
"url": "https://github.com/symfony/var-dumper.git",
|
||||||
"reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd"
|
"reference": "e088da50b813f32473a76871616cbb8fa54653a8"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/04ba4add636a95ff437af3a5a9499bb1d6c6d4bd",
|
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8",
|
||||||
"reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd",
|
"reference": "e088da50b813f32473a76871616cbb8fa54653a8",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -3037,7 +3038,7 @@
|
|||||||
"dump"
|
"dump"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/var-dumper/tree/v7.4.15"
|
"source": "https://github.com/symfony/var-dumper/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3057,20 +3058,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-21T15:13:06+00:00"
|
"time": "2026-08-30T20:10:52+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/var-exporter",
|
"name": "symfony/var-exporter",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/var-exporter.git",
|
"url": "https://github.com/symfony/var-exporter.git",
|
||||||
"reference": "ca31404415670aa3834809005b529df1b84f0790"
|
"reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/ca31404415670aa3834809005b529df1b84f0790",
|
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/d6a87acbe48cbc707b9aba7f1a6c2215aab02603",
|
||||||
"reference": "ca31404415670aa3834809005b529df1b84f0790",
|
"reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -3118,7 +3119,7 @@
|
|||||||
"serialize"
|
"serialize"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/var-exporter/tree/v7.4.16"
|
"source": "https://github.com/symfony/var-exporter/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3138,20 +3139,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-30T12:37:26+00:00"
|
"time": "2026-08-23T10:03:40+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/yaml",
|
"name": "symfony/yaml",
|
||||||
"version": "v7.4.15",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/yaml.git",
|
"url": "https://github.com/symfony/yaml.git",
|
||||||
"reference": "e101850ded5d2c0d44bf32abb8996404afec2dec"
|
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/e101850ded5d2c0d44bf32abb8996404afec2dec",
|
"url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1",
|
||||||
"reference": "e101850ded5d2c0d44bf32abb8996404afec2dec",
|
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -3194,7 +3195,7 @@
|
|||||||
"description": "Loads and dumps YAML files",
|
"description": "Loads and dumps YAML files",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/yaml/tree/v7.4.15"
|
"source": "https://github.com/symfony/yaml/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3214,7 +3215,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-21T15:13:06+00:00"
|
"time": "2026-08-30T00:47:26+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
@@ -3663,16 +3664,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/process",
|
"name": "symfony/process",
|
||||||
"version": "v7.4.13",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/process.git",
|
"url": "https://github.com/symfony/process.git",
|
||||||
"reference": "f5804be144caceb570f6747519999636b664f24c"
|
"reference": "058d17fc284cce14efb2385783b55014a461b176"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c",
|
"url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176",
|
||||||
"reference": "f5804be144caceb570f6747519999636b664f24c",
|
"reference": "058d17fc284cce14efb2385783b55014a461b176",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -3704,7 +3705,7 @@
|
|||||||
"description": "Executes commands in sub-processes",
|
"description": "Executes commands in sub-processes",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/process/tree/v7.4.13"
|
"source": "https://github.com/symfony/process/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3724,7 +3725,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-23T16:05:06+00:00"
|
"time": "2026-08-21T17:40:08+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+11
-5
@@ -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-hub:
|
||||||
url: '%env(APP_SANDBOX_LOGIN_URL)%'
|
url: '%env(APP_HUB_URL)%'
|
||||||
role: 'app-sandbox-access'
|
role_prefix: 'app-hub-'
|
||||||
|
access_role: 'app-hub-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
|
||||||
|
|||||||
+5
-6
@@ -71,14 +71,13 @@ $rsyncOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
host('prod')
|
host('prod')
|
||||||
->setHostname('185.243.135.29')
|
->setHostname('dedi10193.your-server.de')
|
||||||
->setRemoteUser('p704161')
|
->setRemoteUser('epbffsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(true)
|
->setSshMultiplexing(true)
|
||||||
->setDeployPath('/home/www/p704161/html/epbff')
|
->setDeployPath('/usr/home/epbffsf/public_html/prod')
|
||||||
->set('writable_mode', 'chmod')
|
->set('bin/php', '/usr/bin/php')
|
||||||
->set('http_user', 'p704161')
|
->set('http_user', 'epbffsf')
|
||||||
->set('bin/php', '/usr/local/bin/php')
|
|
||||||
->set('rsync_src', __DIR__)
|
->set('rsync_src', __DIR__)
|
||||||
->set('rsync', $rsyncOptions)
|
->set('rsync', $rsyncOptions)
|
||||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://bff.ep-reisen.net')
|
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://bff.ep-reisen.net')
|
||||||
|
|||||||
@@ -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), '+/', '-_'), '=');
|
||||||
|
|||||||
@@ -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', '');
|
||||||
|
|||||||
@@ -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])) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user