Files
apf_portal/apps/portal-bff/src/config/check-entra-config.spec.ts
T
Julien Gautier f50d2d66c0
CI / commits (pull_request) Successful in 2m15s
CI / scan (pull_request) Successful in 2m15s
CI / check (pull_request) Successful in 2m24s
CI / a11y (pull_request) Successful in 1m7s
CI / perf (pull_request) Successful in 2m29s
feat(portal-bff): distinct admin session + /api/admin/auth flow
Phase-3a step per ADR-0020 §"Sessions — distinct from `portal-shell`".
Wires a second `express-session` middleware on `/api/admin/*` carrying
`__Host-portal_admin_session` over Redis prefix `session:admin:` and
ships the parallel `/api/admin/auth/{login,callback,me,logout}` flow
that populates it. Signing in to one surface no longer signs the user
into the other — Entra SSO at the IdP level still preserves the
click-through experience.

What lands

- `session/admin-session-cookie.ts`: `adminSessionCookieName()` mirrors
  the existing user-portal pattern (`__Host-` prefix in prod, plain
  name in dev).

- `SessionModule` provides two parallel `express-session` instances
  via a shared `buildSessionMiddleware()` factory:
    SESSION_MIDDLEWARE       cookie portal_session         prefix session:
    ADMIN_SESSION_MIDDLEWARE cookie portal_admin_session   prefix session:admin:
  The TTL policy, encryption key, signing secret, and session-id
  entropy are unchanged — only the cookie name + Redis key prefix
  differ.

- `main.ts` mounts a tiny path-routed dispatch: requests under
  `/api/admin` get the admin session, everything else gets the user
  one. Running both middlewares unconditionally would have the second
  overwrite `req.session` from the first, collapsing the two surfaces.

- `EntraConfig` gains `adminRedirectUri` + `adminPostLogoutRedirectUri`,
  validated at boot. The validator refuses to start when admin and
  user redirect URIs collide (would silently fuse the two surfaces).
  Both URIs must be registered on the same Entra app registration.

- `AuthService.{beginAuthCodeFlow,completeAuthCodeFlow,buildLogoutUrl}`
  now take their redirect / post-logout URI as a parameter. Callers
  pick which set to pass.

- New shared service `SessionEstablisher`:
    establish(user, req, res, surface) — full sign-in recipe: mint
      CSRF, populate session fields, save, register in
      user_sessions index, emit auth.sign_in audit, log.
    destroy(actor | undefined, req)   — sign-out recipe: when actor
      is set, remove from index + emit auth.sign_out audit; always
      destroy the session (with Redis-hiccup tolerance).
  Both `AuthController` and the new `AdminAuthController` call it —
  no duplication of the 150-LOC session lifecycle logic.

- `AdminAuthController` mounts `/api/admin/auth/{login,callback,me,logout}`.
  Structurally identical to `AuthController` but passes
  `adminRedirectUri` / `adminPostLogoutRedirectUri` and clears the
  admin session cookie on logout. `me` exposes the `roles` claim
  (the SPA needs it for conditional admin UI); the user-portal `me`
  intentionally still doesn't.

New env vars (mandatory at boot)

- ENTRA_ADMIN_REDIRECT_URI
- ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI

Tests: +25 specs (admin cookie 3, session-establisher 11, admin auth
controller 9, entra config 2). Existing AuthController tests
preserved through the refactor by passing a real `SessionEstablisher`
constructed with the same audit / index / logger mocks.
2026-05-14 02:00:54 +02:00

99 lines
4.1 KiB
TypeScript

import { assertEntraConfig } from './check-entra-config';
const VALID = {
ENTRA_INSTANCE_URL: 'https://login.microsoftonline.com/',
ENTRA_TENANT_ID: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
ENTRA_CLIENT_ID: '11111111-2222-3333-4444-555555555555',
ENTRA_CLIENT_SECRET: 's3cret-value-from-entra',
ENTRA_REDIRECT_URI: 'http://localhost:3000/api/auth/callback',
ENTRA_POST_LOGOUT_REDIRECT_URI: 'http://localhost:4200/',
ENTRA_ADMIN_REDIRECT_URI: 'http://localhost:3000/api/admin/auth/callback',
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI: 'http://localhost:4201/',
};
describe('assertEntraConfig', () => {
const originalEnv: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of Object.keys(VALID) as Array<keyof typeof VALID>) {
originalEnv[key] = process.env[key];
process.env[key] = VALID[key];
}
});
afterEach(() => {
for (const key of Object.keys(VALID) as Array<keyof typeof VALID>) {
const saved = originalEnv[key];
if (saved === undefined) {
delete process.env[key];
} else {
process.env[key] = saved;
}
}
});
it('returns the parsed config when every key is present and well-formed', () => {
const config = assertEntraConfig();
expect(config.instanceUrl).toBe(VALID.ENTRA_INSTANCE_URL);
expect(config.tenantId).toBe(VALID.ENTRA_TENANT_ID);
expect(config.clientId).toBe(VALID.ENTRA_CLIENT_ID);
expect(config.clientSecret).toBe(VALID.ENTRA_CLIENT_SECRET);
expect(config.redirectUri).toBe(VALID.ENTRA_REDIRECT_URI);
expect(config.postLogoutRedirectUri).toBe(VALID.ENTRA_POST_LOGOUT_REDIRECT_URI);
expect(config.adminRedirectUri).toBe(VALID.ENTRA_ADMIN_REDIRECT_URI);
expect(config.adminPostLogoutRedirectUri).toBe(VALID.ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI);
expect(config.authority).toBe(`${VALID.ENTRA_INSTANCE_URL}${VALID.ENTRA_TENANT_ID}`);
});
it('throws when ENTRA_ADMIN_REDIRECT_URI equals ENTRA_REDIRECT_URI (would collapse the two surfaces)', () => {
process.env['ENTRA_ADMIN_REDIRECT_URI'] = VALID.ENTRA_REDIRECT_URI;
expect(() => assertEntraConfig()).toThrow(/must differ from ENTRA_REDIRECT_URI/);
});
it('rejects ENTRA_ADMIN_REDIRECT_URI when not a valid URL', () => {
process.env['ENTRA_ADMIN_REDIRECT_URI'] = 'nope';
expect(() => assertEntraConfig()).toThrow(/ENTRA_ADMIN_REDIRECT_URI is not a valid URL/);
});
it('throws listing every missing required key', () => {
delete process.env['ENTRA_CLIENT_ID'];
delete process.env['ENTRA_CLIENT_SECRET'];
expect(() => assertEntraConfig()).toThrow(/ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET/);
});
it('throws when ENTRA_INSTANCE_URL is not https', () => {
process.env['ENTRA_INSTANCE_URL'] = 'http://login.microsoftonline.com/';
expect(() => assertEntraConfig()).toThrow(/ENTRA_INSTANCE_URL must use one of \[https:\]/);
});
it('throws when ENTRA_INSTANCE_URL is missing the trailing slash', () => {
process.env['ENTRA_INSTANCE_URL'] = 'https://login.microsoftonline.com';
expect(() => assertEntraConfig()).toThrow(/must end with a trailing "\/"/);
});
it('throws when ENTRA_TENANT_ID is not a UUID', () => {
process.env['ENTRA_TENANT_ID'] = 'not-a-uuid';
expect(() => assertEntraConfig()).toThrow(/ENTRA_TENANT_ID must be a UUID/);
});
it('throws when ENTRA_CLIENT_ID is still the .env.example placeholder', () => {
process.env['ENTRA_CLIENT_ID'] = '00000000-0000-0000-0000-000000000000';
expect(() => assertEntraConfig()).toThrow(/placeholder/);
});
it('throws when ENTRA_CLIENT_SECRET is still the .env.example placeholder', () => {
process.env['ENTRA_CLIENT_SECRET'] = 'replace_with_real_value';
expect(() => assertEntraConfig()).toThrow(/placeholder/);
});
it('accepts http for the redirect URIs (local dev case)', () => {
process.env['ENTRA_REDIRECT_URI'] = 'http://localhost:3000/api/auth/callback';
expect(() => assertEntraConfig()).not.toThrow();
});
it('rejects a redirect URI that is not a valid URL', () => {
process.env['ENTRA_REDIRECT_URI'] = 'not-a-url';
expect(() => assertEntraConfig()).toThrow(/ENTRA_REDIRECT_URI is not a valid URL/);
});
});