Files
apf_portal/apps/portal-bff/src/auth/auth.service.spec.ts
T
Julien Gautier 37a97e2c31
CI / commits (pull_request) Successful in 3m15s
CI / scan (pull_request) Successful in 3m24s
CI / check (pull_request) Successful in 3m33s
CI / a11y (pull_request) Successful in 2m26s
CI / perf (pull_request) Successful in 5m48s
feat(portal-bff): /auth/callback route — token exchange + amr check
Fourth step of ADR-0009 wiring. Closes the OIDC round-trip on the
BFF side (modulo session persistence — that's the next PR per
ADR-0010). Entra redirects the user back to `/api/auth/callback`
with a `code` + `state`; the BFF verifies the state, exchanges the
code for tokens via MSAL Node's `acquireTokenByCode`, runs the
ADR-0011 `amr` sanity-check, logs the resolved identity to Pino,
clears the single-use pre-auth cookie, and 302s the user back to
the SPA.

What lands:

- `apps/portal-bff/src/auth/auth.errors.ts` — discriminated-union
  `AuthCodeFlowError` (state-mismatch / flow-expired / amr-missing
  / token-exchange-failed) + `AuthCodeFlowException` wrapper. The
  `kind` field doubles as the `?auth_error=<code>` query param on
  the SPA-bound redirect, so the front-end can render an exact
  message without duplicating the string set.
- `AuthService.completeAuthCodeFlow(code, state, preAuth, now?)` —
  verifies state binding, refuses cookies older than the 5-minute
  flow TTL, calls MSAL, validates `amr` is non-empty (ADR-0011 BFF
  sanity-check; Conditional Access on the org side is the real
  enforcement), extracts the four `oid` / `tid` /
  `preferred_username` / `name` claims plus the `amr` array into
  an `AuthenticatedUser` shape.
- `auth.cookie.ts` gains `clearPreAuthCookieOptions()` mirroring
  the set-options minus `maxAge` so the browser actually drops the
  cookie (cookies match by name + path + secure; getting any of
  those wrong leaves the old cookie in place).
- `AuthController.callback()` — `@Get('callback')`:
  1. Always `res.clearCookie(...)` first. The cookie is single-use
     by design; a parallel /login overlap should not survive.
  2. Bail on Entra-side errors (`?error=`), missing query params,
     missing or malformed pre-auth cookie. Each branch logs a
     structured Pino warning and redirects with the right
     `auth_error` code.
  3. Call the service. On `AuthCodeFlowException`, log + redirect
     with the typed `kind`.
  4. On success, log a `auth.signed_in` event with `oid`, `tid`,
     `username`, `amr` (PII-sensitive bits only; no tokens), then
     302 to `entra.postLogoutRedirectUri` (reused as the SPA root
     URL — a dedicated `SPA_BASE_URL` env var lands if the two
     URLs ever need to diverge).
- The controller now takes `Logger` + `ENTRA_CONFIG` via DI
  alongside `AuthService`.

Verification:

- `nx run-many -t lint test build --projects=portal-bff` — green.
- 52/52 specs (was 39; +13 across the new service-completeFlow spec
  branches and the controller-callback spec branches). Service
  spec covers happy path + 6 failure modes (state mismatch, flow
  expired, amr missing, MSAL throws, MSAL returns null, oid claim
  missing). Controller spec covers happy redirect, Entra error
  branch, missing cookie, AuthCodeFlowException branch, missing
  query, malformed cookie.

What this PR explicitly does NOT do:

- Persist a session. The user is "authenticated" in the BFF's
  point of view (we have their identity) but the next request lands
  anonymous. Closes in the Redis sessions PR (ADR-0010).
- Audit log entry. The audit module is in place; wiring the
  `auth.signed_in` event into it lands alongside sessions so the
  audit row carries a `session_id`.
- Logout / `/me`. Land after sessions.
2026-05-12 12:13:47 +02:00

171 lines
6.7 KiB
TypeScript

import type { AuthenticationResult, ConfidentialClientApplication } from '@azure/msal-node';
import { PRE_AUTH_COOKIE_TTL_MS } from './auth.cookie';
import { AuthCodeFlowException } from './auth.errors';
import { AuthService, type PreAuthPayload } from './auth.service';
import type { EntraConfig } from './entra-config.token';
const ENTRA: EntraConfig = {
instanceUrl: 'https://login.microsoftonline.com/',
tenantId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
clientId: '11111111-2222-3333-4444-555555555555',
clientSecret: 's3cret',
redirectUri: 'http://localhost:3000/api/auth/callback',
postLogoutRedirectUri: 'http://localhost:4200/',
authority: 'https://login.microsoftonline.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
};
interface ServiceFixture {
service: AuthService;
getAuthCodeUrl: jest.Mock;
acquireTokenByCode: jest.Mock;
}
function makeService(overrides?: { acquireTokenByCode?: jest.Mock }): ServiceFixture {
const getAuthCodeUrl = jest.fn().mockResolvedValue('https://entra.example/authorize?…');
const acquireTokenByCode =
overrides?.acquireTokenByCode ??
jest.fn().mockResolvedValue(makeAuthResult({ amr: ['pwd', 'mfa'] }));
const msalStub = {
getAuthCodeUrl,
acquireTokenByCode,
} as unknown as ConfidentialClientApplication;
return { service: new AuthService(msalStub, ENTRA), getAuthCodeUrl, acquireTokenByCode };
}
function makeAuthResult(
claims: Partial<{
amr: string[];
oid: string;
tid: string;
name: string;
preferred_username: string;
}>,
): AuthenticationResult {
return {
idTokenClaims: {
oid: claims.oid ?? 'user-oid',
tid: claims.tid ?? ENTRA.tenantId,
name: claims.name ?? 'Jane Doe',
preferred_username: claims.preferred_username ?? 'jane.doe@apf.example',
amr: claims.amr ?? ['pwd', 'mfa'],
},
account: { username: 'jane.doe@apf.example', name: 'Jane Doe' },
} as unknown as AuthenticationResult;
}
const PRE_AUTH_OK: PreAuthPayload = {
state: 'state-nonce',
codeVerifier: 'verifier-secret',
createdAt: 1_000_000,
};
describe('AuthService.beginAuthCodeFlow', () => {
it('builds the auth URL with the configured redirect, OIDC scopes, S256 challenge', async () => {
const { service, getAuthCodeUrl } = makeService();
await service.beginAuthCodeFlow();
expect(getAuthCodeUrl).toHaveBeenCalledTimes(1);
const arg = getAuthCodeUrl.mock.calls[0]?.[0] as Record<string, unknown>;
expect(arg['redirectUri']).toBe(ENTRA.redirectUri);
expect(arg['scopes']).toEqual(['openid', 'profile', 'email']);
expect(arg['codeChallengeMethod']).toBe('S256');
expect(typeof arg['codeChallenge']).toBe('string');
expect(typeof arg['state']).toBe('string');
});
it('returns the pre-auth payload with state + codeVerifier matching what MSAL was called with', async () => {
const { service, getAuthCodeUrl } = makeService();
const { preAuthPayload } = await service.beginAuthCodeFlow();
const arg = getAuthCodeUrl.mock.calls[0]?.[0] as Record<string, unknown>;
expect(preAuthPayload.state).toBe(arg['state']);
expect(preAuthPayload.codeVerifier).not.toBe(arg['codeChallenge']);
expect(preAuthPayload.createdAt).toBeLessThanOrEqual(Date.now());
});
it('generates a fresh state + verifier on every call', async () => {
const { service } = makeService();
const a = await service.beginAuthCodeFlow();
const b = await service.beginAuthCodeFlow();
expect(a.preAuthPayload.state).not.toBe(b.preAuthPayload.state);
expect(a.preAuthPayload.codeVerifier).not.toBe(b.preAuthPayload.codeVerifier);
});
});
describe('AuthService.completeAuthCodeFlow', () => {
it('returns the authenticated user on the happy path', async () => {
const { service, acquireTokenByCode } = makeService();
const user = await service.completeAuthCodeFlow(
'auth-code',
PRE_AUTH_OK.state,
PRE_AUTH_OK,
PRE_AUTH_OK.createdAt + 1_000,
);
expect(acquireTokenByCode).toHaveBeenCalledWith({
code: 'auth-code',
codeVerifier: PRE_AUTH_OK.codeVerifier,
redirectUri: ENTRA.redirectUri,
scopes: ['openid', 'profile', 'email'],
});
expect(user).toEqual({
oid: 'user-oid',
tid: ENTRA.tenantId,
username: 'jane.doe@apf.example',
displayName: 'Jane Doe',
amr: ['pwd', 'mfa'],
});
});
it('throws state-mismatch when the query state differs from the cookie state', async () => {
const { service } = makeService();
await expect(
service.completeAuthCodeFlow('code', 'other-state', PRE_AUTH_OK, PRE_AUTH_OK.createdAt),
).rejects.toMatchObject({ failure: { kind: 'state-mismatch' } });
});
it('throws flow-expired when the cookie is older than the TTL', async () => {
const { service } = makeService();
const now = PRE_AUTH_OK.createdAt + PRE_AUTH_COOKIE_TTL_MS + 1;
await expect(
service.completeAuthCodeFlow('code', PRE_AUTH_OK.state, PRE_AUTH_OK, now),
).rejects.toMatchObject({ failure: { kind: 'flow-expired' } });
});
it('throws amr-missing when the ID token has no amr claim', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({ amr: [] }));
const { service } = makeService({ acquireTokenByCode });
await expect(
service.completeAuthCodeFlow('code', PRE_AUTH_OK.state, PRE_AUTH_OK, PRE_AUTH_OK.createdAt),
).rejects.toMatchObject({ failure: { kind: 'amr-missing' } });
});
it('throws token-exchange-failed when MSAL throws', async () => {
const acquireTokenByCode = jest.fn().mockRejectedValue(new Error('AADSTS70008'));
const { service } = makeService({ acquireTokenByCode });
await expect(
service.completeAuthCodeFlow('code', PRE_AUTH_OK.state, PRE_AUTH_OK, PRE_AUTH_OK.createdAt),
).rejects.toMatchObject({
failure: { kind: 'token-exchange-failed', cause: 'AADSTS70008' },
});
});
it('throws token-exchange-failed when MSAL returns null', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(null);
const { service } = makeService({ acquireTokenByCode });
await expect(
service.completeAuthCodeFlow('code', PRE_AUTH_OK.state, PRE_AUTH_OK, PRE_AUTH_OK.createdAt),
).rejects.toBeInstanceOf(AuthCodeFlowException);
});
it('throws token-exchange-failed when oid claim is missing', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue({
idTokenClaims: { tid: ENTRA.tenantId, amr: ['mfa'] },
account: { username: '', name: '' },
} as unknown as AuthenticationResult);
const { service } = makeService({ acquireTokenByCode });
await expect(
service.completeAuthCodeFlow('code', PRE_AUTH_OK.state, PRE_AUTH_OK, PRE_AUTH_OK.createdAt),
).rejects.toMatchObject({
failure: { kind: 'token-exchange-failed', cause: /oid/ },
});
});
});