feat(portal): /api/me/capabilities + cross-app menu links + real role label
PR 3 of 3 — final piece of the user-menu / profile / cross-app
chantier.
BFF
* New `MeModule` ships `GET /api/me/capabilities`, a curated view
derived from the active user-portal session. Returns
`{ canAccessAdmin: boolean }` today; the shape is deliberately
binary so the SPA cannot reconstruct the raw `roles` claim. 401
on missing session, consistent posture with `/auth/me`.
* ADR-0009 amended: new "Curated public view" section codifies
the "no raw roles on `/auth/me`, capabilities is the release
valve" stance; the routes table grows a row for the endpoint.
portal-shell
* New `CapabilitiesService` (app-local) calls the BFF on auth-state
flip and exposes `canAccessAdmin` as a signal. Anonymous
sessions short-circuit to the all-false default without firing
a request.
* Header's `userMenuItems` is now a `computed` — the "Open Portal
Admin" entry appears in the dropdown only when
`canAccessAdmin()` is true, pointing at
`environment.adminAppUrl`.
* Sidebar role widget reads the auth + capabilities signals to
render one of three labels ("Anonymous" / "User" /
"Administrator") in place of the hardcoded "Anonymous".
portal-admin
* Symmetric: unconditional "Open Portal Shell" entry in the admin
user menu, pointing at `environment.shellAppUrl`. Anyone who
can reach portal-admin can reach portal-shell, so no
capabilities check needed.
i18n
* `header.userMenu.openAdmin`, `sidebar.role.{administrator,user}`,
`sidebar.role.aria` reshaped to take the role as a placeholder.
This commit is contained in:
@@ -120,15 +120,18 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
||||
|
||||
**Routes.** All authentication endpoints live under the `/auth` prefix on the BFF:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| ------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
|
||||
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
|
||||
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
|
||||
| `GET` | `/auth/me` | returns the current user's id, audience, and a curated subset of claims for the SPA to display (no tokens) |
|
||||
| Method | Path | Purpose |
|
||||
| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
|
||||
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
|
||||
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
|
||||
| `GET` | `/auth/me` | returns the current user's id, audience, and a curated subset of claims for the SPA to display (no tokens, no raw `roles` claim — see "curated public view" below) |
|
||||
| `GET` | `/me/capabilities` | curated capabilities view derived from the session (currently `{ canAccessAdmin: boolean }`); drives binary SPA gates without exposing the raw `roles` claim — see "curated public view" below |
|
||||
|
||||
**Authorization layer.** A NestJS `AuthGuard` checks for a valid session and rejects with 401 otherwise. A `@CurrentUser()` decorator extracts `{ id, audience, claims }` from the request scope. Every controller (other than `/auth/*` itself, `/health`, etc.) is protected by `AuthGuard` by default — the framework default is "denied", explicit allow-listing is required.
|
||||
|
||||
**Curated public view.** The `/auth/me` payload exposes a deliberately narrow projection of the session: `oid`, `tid`, `username`, `displayName`. **The raw `roles` claim is _not_ part of `/auth/me`** — it stays server-side, consumed by the BFF's own guards (`@RequireAdmin`, downstream-API gates, etc.). The SPA derives binary UX hints from a dedicated companion endpoint, `GET /me/capabilities`, which returns a curated structure (today `{ canAccessAdmin: boolean }`; further keys land as conditional UI requires). The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles (auditor, redactor, …) does not widen the SPA-side surface. The authoritative gate remains BFF-side via `@RequireAdmin` & co.; capabilities is UX-only. The admin-portal `/api/admin/auth/me` exposes `roles` explicitly because it _is_ the admin surface — see ADR-0020.
|
||||
|
||||
**Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing.
|
||||
|
||||
| Variable | Purpose |
|
||||
|
||||
Reference in New Issue
Block a user