fed905edc52121129ac2ca2d531881ffba15f339
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5bbe2304ff |
feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF (#122)
## Summary Phase-2 security baseline that the `main.ts` placeholder note has been advertising since the auth/session work began. Three independent middlewares + their SPA counterparts, all mounted in a single PR because they only become meaningful together. ### Helmet on the BFF `helmet()` with three overrides matching our specific shape: - **HSTS only in production** — dev runs on plain HTTP, HSTS is just noise. - **`crossOriginResourcePolicy: 'cross-origin'`** — the SPA on its own origin reads JSON from the BFF; the default `same-origin` would block it. - **CSP disabled in non-production** — the BFF doesn't render HTML, so CSP on JSON responses is mostly inert, but Helmet's default CSP triggers noisy `connect-src` violations in browser devtools that we don't need. Everything else is Helmet defaults: `X-Frame-Options=SAMEORIGIN`, `X-Content-Type-Options=nosniff`, `Referrer-Policy=no-referrer`, `X-Powered-By` removed, etc. ### CORS allowlist, env-driven `CORS_ALLOWED_ORIGINS` env (comma-separated) is now **mandatory** at boot. The BFF refuses to start without it via `readCorsAllowlist()` — same boot-time validator family as `assertSessionSecret` etc. The previous hardcoded `http://localhost:4200` fallback is gone; getting CORS wrong silently is the kind of "works in dev, breaks in prod" trap the validator is specifically designed to catch. `X-CSRF-Token` is now in the allowed headers. ### Double-submit CSRF - BFF mints a 256-bit `csrfToken` at session creation (`/auth/callback`), stored on `req.session.csrfToken` and mirrored to a JS-readable cookie (`__Host-portal_csrf` prod / `portal_csrf` dev). The cookie is the SPA's read-only view; the server-side session is the source of truth. - `createCsrfMiddleware` (mounted after the session middleware in `main.ts`) compares the `X-CSRF-Token` header with `req.session.csrfToken` using `crypto.timingSafeEqual`. Skips: - safe methods (`GET / HEAD / OPTIONS`), - anonymous requests (no `req.session.user`), - `/api/auth/login` and `/api/auth/callback` (those mint the token themselves). - Mismatch → `403 {"error":"csrf"}` with a structured Pino warn. - SPA's `csrfInterceptor` reads the cookie via `document.cookie` and copies its value into `X-CSRF-Token` on every mutating BFF request. The header is omitted on `GET / HEAD / OPTIONS` (BFF skips them anyway) and on non-BFF origins. - Logout and the absolute-timeout middleware both clear the CSRF cookie alongside the session cookie. ## Notable choices **Session-bound double-submit, not pure cookie-vs-header.** A naive "compare cookie with header" check is defeated when an attacker can plant a cookie (subdomain takeover, etc.). Comparing the header to the server-side session-stored token instead means the attacker would also need to be the authenticated user — which is what CSRF defense is supposed to prevent in the first place. **No CSRF for anonymous mutating routes (v1).** None exist today; we don't have an unauthenticated POST endpoint anywhere. Generating a CSRF token for anonymous sessions would conflict with `saveUninitialized: false` on express-session and add complexity we don't need yet. Anonymous public-form CSRF defenses (site-key, captcha) land if and when those routes ship. **`SameSite=Lax`, not `Strict`, on the CSRF cookie.** Matches the session cookie's policy so the two travel together on the SPA→BFF cross-origin same-site fetch (different ports = different origin, same registrable domain). The double-submit pattern is what gives the protection; `SameSite=Lax` is a belt-and-braces layer. **`csrfInterceptor` runs after `bffCredentialsInterceptor` and before `bffUnauthorizedInterceptor` in the chain.** Order: credentials first (set `withCredentials`), then CSRF (set the header), then unauthorized handling (catch 401s). Forward order, no surprises. **`CORS_ALLOWED_ORIGINS` has no localhost fallback.** I considered keeping the fallback for ergonomics but it makes the BFF silently misconfigured if someone forgets the env. The error message points straight at the file to edit. ## Out of scope (next PRs) - Rate limiting + structured error filter (still in the phase-2 to-do). - CSP fine-tuning when we have actual HTML pages (portal-shell + portal-admin static serving). - CSRF token rotation on idle-extension (today the token lives the session's lifetime; refreshing on each request would invalidate in-flight mutations). ## Test plan - [x] `pnpm nx run-many -t test --projects=portal-bff,feature-auth,portal-shell` clean env → **177 + 28 + 34 = 239/239 pass** (was 144 + 19 + 34 = 197 before; +42 specs across CSRF middleware, CSRF cookie helpers, CORS allowlist parser, csrfInterceptor, and extended auth.controller / absolute-timeout coverage). - [x] `pnpm nx run-many -t lint build --projects=portal-bff,feature-auth,portal-shell` → clean. - [x] **CI clean-env repro** (lesson from prior PRs): every env var unset (including new `CORS_ALLOWED_ORIGINS`) → tests still pass. The BFF refuses to boot without `CORS_ALLOWED_ORIGINS`, which is the intended behaviour. - [x] Prettier-clean. - [ ] Manual smoke against running BFF: - [ ] Sign in → `__Host-portal_csrf` (prod) / `portal_csrf` (dev) cookie set, value matches `audit.events.payload->>actorIdHash`-style traceability via `req.session.csrfToken` in Redis. - [ ] Hit a future POST route from the SPA → request carries `X-CSRF-Token`, BFF accepts. - [ ] Forge a POST without the header (curl) → 403 `{"error":"csrf"}`. - [ ] Sign out → both cookies cleared. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #122 |
||
|
|
177f2f20c0 |
feat(portal-shell): authGuard + BFF http interceptors + /profile demo route (#117)
## Summary Brings the SPA auth track to the level of polish the BFF surface deserves. After #113/#114, the header reflects sign-in state — but the SPA had no protected routes and no global handling of session-state drift. This PR adds three building blocks (one guard, two interceptors) plus one demo consumer. - **`authGuard`** (`CanActivateFn`) — gates routes on `AuthService.state`. Waits out the bootstrap `loading` state, allows when `authenticated`, redirects through `auth.login()` (full-page navigation to the BFF's `/auth/login` → Entra round-trip) when `anonymous` or `error`. - **`bffCredentialsInterceptor`** — flips `withCredentials: true` on every request whose URL starts with `AUTH_BFF_BASE_URL`. Replaces the per-call flag we had on `/me` (#114) with a single point of truth. Future BFF calls inherit it automatically — no chance of forgetting it. - **`bffUnauthorizedInterceptor`** — calls `AuthService.refresh()` when a BFF route (other than `/auth/me` itself) answers 401. Keeps the SPA's auth state in sync after server-side session destruction (absolute-timeout, manual revoke, idle-TTL expiry). - **`/profile`** demo route — first real consumer of the guard. Lazy-loaded component that renders the curated `CurrentUser` payload (display name, username, oid, tid). Exercises the full loop end-to-end: guard waits on /me → BFF answers → SPA renders. ## Notable choices **Lazy `AuthService` resolution in the 401 interceptor.** A naive `inject(AuthService)` at the top of the interceptor caused a circular-construction error: `AuthService`'s own constructor fires the bootstrap `/me`, which goes through the interceptor chain, which tries to inject `AuthService` while it's still being constructed. The fix is to inject the parent `Injector` and resolve `AuthService` lazily inside `catchError` — by the time a 401 actually fires, construction is done. Standard Angular pattern for "interceptor depends on a service that uses HttpClient". **`/auth/me` is excluded from the 401 refresh trigger.** The interceptor's whole job is to catch session-state drift; `/me` is the probe `AuthService.refresh()` itself uses. Without the exclusion, a 401 from /me would call `refresh()` → another /me → another 401 → infinite loop. **On `error` state, the guard still redirects to `/auth/login`.** Could have shown a "can't reach the server" page on the protected route, but the BFF-side login screen surfaces diagnostics more usefully (Entra's own error path) than a generic SPA outage page would. **Per-call `withCredentials: true` removed from `AuthService.refresh()`.** The interceptor now applies it uniformly. The spec that pinned the per-call flag is also gone — that contract moved to `bff-credentials.interceptor.spec.ts` where it belongs. **`profileTitle` + 6 new i18n message ids.** `route.profile.title`, `profile.heading`, `profile.intro`, `profile.field.{displayName,username,oid,tid}` shipped in `messages.fr.xlf` with FR translations. ## Out of scope (next PRs) - A real user-profile feature (settings, preferences, etc.) — `/profile` is just an auth-loop fixture today. - Showing the auth-loading state on protected routes (currently the guard blocks navigation; the user sees the previous route until /me resolves). Acceptable for v1. ## Test plan - [x] `pnpm nx test feature-auth` → **19/19 pass** (was 8; +11 across `auth.guard.spec.ts`, `bff-credentials.interceptor.spec.ts`, `bff-unauthorized.interceptor.spec.ts`). - [x] `pnpm nx test portal-shell` → **34/34 pass** (was 32; +2 for the Profile component). - [x] `pnpm nx lint feature-auth portal-shell` → clean. - [x] `pnpm nx build portal-shell` → clean. Bundle: main 492 kB raw / 131 kB transfer (well under the 300 KB gzip budget per ADR-0017). - [x] **CI clean-env repro** (lesson from #115/#116): `env -u REDIS_URL -u SESSION_* ... pnpm exec nx run-many -t test` → 123 + 19 + 34 = **176/176 pass**. - [ ] Manual smoke against running BFF: - [ ] Anonymous → visit `/profile` → redirect to `/auth/login` → Entra → callback → SPA lands at `/profile` with identity card filled in. - [ ] Trigger an absolute-timeout (set `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` in BFF `.env`, wait) → next BFF call returns 401 → header flips to "Sign in". --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #117 |
||
|
|
0e4f0fc611 |
fix(portal-shell): send withCredentials on /me so the session cookie crosses SPA→BFF in dev (#114)
## Summary Manual smoke after PR #113 surfaced a dev-only bug: after `/auth/callback` the BFF correctly sets the `portal_session` cookie and redirects to the SPA, but the SPA's next call to `/api/auth/me` comes back **401 with no `cookie:` header at all**. The user lands "back at the portal" but the header still shows "Sign in". **Root cause.** Angular's `HttpClient` via `withFetch()` inherits `fetch`'s default `credentials: 'same-origin'`. In dev, `localhost:4200` (SPA) → `localhost:3000` (BFF) is cross-origin (different ports), so the browser drops the session cookie on the way out. SameSite=Lax is a red herring: both URLs share the registrable domain, so the cookie is still same-site — what was missing was opting the fetch into credentials. **Fix.** Per-call `withCredentials: true` on the /me request. Only /me needs cookies today; login/logout are full-page navigations through `window.location`, which the browser hydrates with cookies regardless. A global `HttpInterceptor` will be the right abstraction once other authenticated BFF endpoints exist — premature for one consumer. **BFF side was already correct.** `enableCors({ credentials: true })` in `main.ts`. Nothing to change. A new spec pins `withCredentials === true` on the /me request so a future refactor can't silently drop the flag and reintroduce the bug. ## Test plan - [x] `pnpm nx test feature-auth` → **9/9 pass** (was 8 before; +1 spec pinning the credentials flag). - [x] `pnpm nx test portal-shell` → **32/32 pass**. - [x] `pnpm nx lint feature-auth portal-shell` → clean. - [x] `pnpm nx build portal-shell` → clean. - [ ] Manual smoke against the running BFF: anonymous landing → click "Sign in" → Entra → callback → SPA lands with avatar + display name in the header (the very last step that failed before this fix). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #114 |
||
|
|
9a9faf9a31 |
feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget (#113)
## Summary First user-visible piece of the auth track. The portal-shell SPA now consumes the BFF auth surface (`/api/auth/me`, `/api/auth/login`, `/api/auth/logout`) and the header reflects sign-in state. - `libs/feature/auth` ships an `AuthService` that fetches `/auth/me` on first injection, holds a signal-backed `AuthState` (`loading` / `anonymous` / `authenticated` / `error`) and exposes `currentUser` + `isLoading` computed signals plus `login()` / `logout()` / `refresh()` methods. - The header's right-side widget renders four states: a sign-in button when anonymous, the user avatar (initials, `JD` for "Jane Doe") + display name + sign-out button when authenticated, a loading dot before `/me` resolves, and a "Can't reach the server" chip on non-401 failures. - `login()` / `logout()` go through an injected `AUTH_NAVIGATOR` token whose default calls `window.location.assign(url)`. Specs override it with `vi.fn()` — no `window.location` mocking required. ## Notable choices **Auto-bootstrap on construction, not via `provideAppInitializer`.** The service fires `/me` from its constructor (unawaited) so consuming components transition through the explicit `loading` state. Blocking app boot on the round-trip would push the first paint behind the network call — bad for TTFB, especially on slow links. The header handles `loading` as a first-class state. **Discriminated `AuthState` over flat fields.** A single source of truth (`state()`) with four `kind`s lets templates `switch` and narrow automatically. `currentUser` and `isLoading` are computed conveniences but never out of sync with `state`. **`AUTH_BFF_BASE_URL` + `AUTH_NAVIGATOR` injection tokens.** Decouples the lib from the host's `environment.ts` shape and keeps tests free of `window.location` redefinition (which jsdom resists across multiple specs in the same file — first redefine works, second throws "Cannot redefine property"). The host wires both in `app.config.ts`. **Curated public user type.** `CurrentUser` mirrors the BFF's `/me` response (`oid`, `tid`, `username`, `displayName`) — no `amr`, no internal claims. The shape lives in `auth.types.ts` so feature code can import it without depending on Angular HTTP details. **Distinct `error` state.** A network failure / 5xx surfaces a different UI than "not signed in" — "Can't reach the server" chip vs. sign-in button. Avoids the trap of treating any `/me` failure as "anonymous". ## Out of scope (next PRs) - Route guards (protecting routes from anonymous users). For now the header is the only consumer. - Auto-refresh of the session before idle timeout. - HTTP interceptor that redirects to `/auth/login` on a 401 from any other BFF call. - Per-locale styling polish on the new header strings. ## Test plan - [x] `pnpm nx test feature-auth` → **8/8 pass**. - [x] `pnpm nx test portal-shell` → **32/32 pass** (was 27 before). - [x] `pnpm nx lint portal-shell feature-auth` → clean. - [x] `pnpm nx build portal-shell` → main bundle 488 kB raw / 129.94 kB transfer; well under the 300 kB gzip budget from ADR-0017. - [ ] Manual smoke once the BFF is up: - [ ] Anonymous landing → header shows "Sign in". - [ ] Click "Sign in" → BFF /login → Entra → callback → SPA lands with avatar + display name in the header. - [ ] Click "Sign out" → BFF /logout → Entra logout → back at SPA, header back to "Sign in". --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #113 |
||
|
|
8329fa133d |
refactor(libs): graduate Icon / LayoutStateService / brand tokens to libs/shared/* (#99)
## Summary
Per ADR-0020 §"Shared-libs graduation": the three primitives that `portal-shell` (today) and `portal-admin` (next) will both share move out of `apps/portal-shell/src/` and into the workspace libs. Mechanical move — no behaviour change, prepares the ground for the admin-app PR.
## Graduated
| Primitive | Old location | New location |
|---|---|---|
| `Icon` component (+ `IconName` type) | `apps/portal-shell/src/app/components/icon/` | [`libs/shared/ui/src/lib/icon/`](libs/shared/ui/src/lib/icon/) |
| `LayoutStateService` (+ `ThemeMode` type) | `apps/portal-shell/src/app/state/` | [`libs/shared/state/src/lib/`](libs/shared/state/src/lib/) |
| Brand-palette `@theme` block | `apps/portal-shell/src/styles.css` | [`libs/shared/tokens/src/brand-tokens.css`](libs/shared/tokens/src/brand-tokens.css) |
## Notable changes
- **Icon selector renamed `app-icon` → `lib-icon`.** Required by the lib's ESLint `@angular-eslint/component-selector` rule (prefix `lib` for shared libs). All ~20 usages in `portal-shell` templates updated in one sweep.
- **New `shared-state` library** generated via `nx g @nx/angular:library libs/shared/state`. Mirrors the existing `feature-auth` / `shared-ui` shape (vite + Angular + spec setup). `tsconfig.base.json` path alias `shared-state` added by the generator.
- **Lib tags rebalanced** to satisfy the module-boundary lint rule:
- `shared-state`: new lib → `scope:shared, type:shared`.
- `shared-ui`: was `scope:portal-shell` (scaffold default) → `scope:shared, type:shared`.
- **`shared-tokens` is now CSS-only.** The placeholder TS function is gone; `index.ts` is an empty `export {}` with a TODO note. Vitest config flips `passWithNoTests: true` so the test target stops failing on the empty lib.
- **`portal-shell`'s `styles.css`** drops the inline `@theme {}` block and instead `@import`s `../../../libs/shared/tokens/src/brand-tokens.css`. Both apps will read the same source.
- Placeholder scaffolded files in `shared-ui/src/lib/shared-ui/` removed.
## Verification
- `nx run-many -t lint test build` across `portal-shell, shared-ui, shared-state, shared-tokens` — green.
- Tests redistributed: **portal-shell 28**, **shared-state 9**, **shared-ui 3**, **shared-tokens 0** = 40 total (same as before the move).
- Production build: **128.7 KB gzip** initial per locale (was 128.5 KB on `main` — noise-level delta).
- Brand-color CSS variables (`--color-brand-primary-500: #12546c`, …) still inlined in the produced `styles-*.css`.
- `dist/.../browser/{en,fr}/` emitted as expected.
## What this PR explicitly does NOT do
- Move other components (Header, Sidebar, Footer, ThemeSwitcher, LocaleSwitcher). Those are app-specific shell concerns; if `portal-admin` ends up needing them, they graduate in their own PR.
- Set up `portal-admin`. That's the next PR on the admin track — and now imports from `shared-ui` / `shared-state` / `shared-tokens` will Just Work.
- Refactor `feature-auth`'s tagging. It still says `scope:portal-shell`; revisit when the auth implementation actually lands and the dual-audience design (per ADR-0008) makes the right scope obvious.
## Test plan
- [x] Per-project run-many — green.
- [x] Production build emits both locales with brand tokens applied.
- [ ] Manual: `pnpm exec nx serve portal-shell` — the dev experience is unchanged.
- [ ] Manual: serve-static + locale switcher / theme switcher / sidebar collapse / navigation — all the behaviours the moved primitives drive still work in production.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #99
|
||
|
|
bd8eefb44a |
chore: configure Tailwind 4 and align lib tsconfigs
Wire Tailwind CSS 4 in the portal-shell app per ADR-0016 (the future host of spartan-ng components in libs/shared/ui will read from the Tailwind tokens via the shared-tokens lib). - pnpm add -D tailwindcss @tailwindcss/postcss postcss - apps/portal-shell/postcss.config.js declares @tailwindcss/postcss - apps/portal-shell/src/styles.scss renamed to styles.css and now contains a single @import 'tailwindcss' directive. Plain CSS for the global file avoids the Sass @import deprecation warning that fires when Tailwind directives sit inside SCSS. Component-level styles can still use SCSS. - apps/portal-shell/project.json styles entry updated accordingly. Side fix: align libs/shared/tokens and libs/shared/util tsconfigs from module: commonjs to module: esnext. The Nx @nx/js:library --bundler= tsc generator emits commonjs by default, but tsconfig.base.json specifies moduleResolution: bundler, which TS only allows alongside esnext or es2015+ modules. Without the alignment, those libs failed to build (TS5095). All apps and libs now build green. spartan-ng wiring is intentionally NOT in this commit. spartan-ng is currently at 0.0.1-alpha.681 - clearly pre-1.0, which trips the project rule against pre-1.0 dependencies. ADR-0016 chose spartan-ng with the copy-paste mitigation, but the alpha state warrants an explicit go/no-go decision before committing the workspace to it. |
||
|
|
8de19320c5 |
chore: generate shared and feature libs with module boundaries per ADR-0003
Generate the four phase-4 libraries: - libs/shared/tokens (project name shared-tokens) - plain TS lib via @nx/js:library; will host the a11y design tokens (palette, contrast tiers, spacing, motion) once Tailwind lands in phase 5; consumable by both apps; tagged scope:shared, type:shared. - libs/shared/util (shared-util) - plain TS lib for cross-cutting utility code; tagged scope:shared, type:shared. - libs/shared/ui (shared-ui) - Angular standalone library that will host the spartan-ng components copy-pasted in phase 5; Angular-only so tagged scope:portal-shell, type:shared. unitTestRunner= vitest-analog because vitest-angular requires a buildable lib. - libs/feature/auth (feature-auth) - placeholder Angular standalone feature lib to demonstrate the type:feature pattern; tagged scope:portal-shell, type:feature. @nx/enforce-module-boundaries depConstraints replaced (root eslint.config.mjs) with the rules from ADR-0003: scope:portal-shell -> scope:portal-shell, scope:shared scope:portal-bff -> scope:portal-bff, scope:shared scope:shared -> scope:shared type:app -> type:feature, type:shared type:feature -> type:feature, type:shared type:shared -> type:shared This forbids portal-shell from importing portal-bff code (and vice versa) and prevents shared libs from depending on feature libs. Project names follow the convention of ADR-0003 (feature-<name> / shared-<scope>) by passing --name explicitly to the generator; the Nx 22 default takes only the last directory segment. Sanity check: pnpm nx run-many -t lint and -t test pass for the 8 projects (4 apps/e2e + 4 libs). Side effects from the generators: tsconfig.base.json paths populated with the lib import aliases; nx.json gains vite/playwright plugin entries; .gitignore picks up vitest.config.*.timestamp* (Vitest temp files); package.json gains @analogjs/vitest-angular and related devDeps; pnpm-lock.yaml regenerated. Two eslint-disable comments in portal-bff-e2e support files were trimmed by lint --fix - those files already lint clean without the directive. |