feat(portal-bff): /auth/login route — pkce flow start + signed cookie (#105)
## Summary
Third step of ADR-0009 wiring. Adds the first OIDC route, `GET /api/auth/login`: it 302s the browser to Entra's authorize endpoint with a freshly-generated state + PKCE challenge, and stashes the matching `{state, codeVerifier}` payload in a short-lived signed cookie so the next-PR callback can verify the round-trip.
## What lands
- **Cookie infra**: `cookie-parser` + `@types/express` deps; `main.ts` mounts the cookie middleware with the `SESSION_SECRET` signing key. Signed cookies are now available via `req.signedCookies` for the upcoming callback.
- **[`.env.example`](apps/portal-bff/.env.example)** promotes `SESSION_SECRET` from a future-vars comment into an active section, with a one-liner showing how to generate 32 random bytes.
- **[`check-session-secret.ts`](apps/portal-bff/src/config/check-session-secret.ts)** — boot-time guard: refuses to start if `SESSION_SECRET` is unset, still the .env.example placeholder, or decodes below 32 bytes of entropy. Same family as `check-database-url` / `check-entra-config`.
- **[`auth.service.ts`](apps/portal-bff/src/auth/auth.service.ts)** — `beginAuthCodeFlow()` uses MSAL's `CryptoProvider` for canonical PKCE verifier / challenge generation and a fresh GUID state per call, calls `msal.getAuthCodeUrl()` with the configured redirect URI + OIDC scopes (`openid profile email` — no `offline_access` in v1), and returns `{ authUrl, preAuthPayload }`.
- **[`auth.cookie.ts`](apps/portal-bff/src/auth/auth.cookie.ts)** — `portal_pre_auth` name, 5-minute TTL, shared `CookieOptions`: `signed`, `httpOnly`, `sameSite: 'lax'` (lets Entra's cross-site top-level redirect back through), `secure` toggled by `NODE_ENV`.
- **[`auth.controller.ts`](apps/portal-bff/src/auth/auth.controller.ts)** — `@Controller('auth') @Get('login')`: writes the cookie then 302s. Thin shell around the service.
- **AuthModule** registers the new controller + service alongside the existing `ENTRA_CONFIG` and `MSAL_CLIENT` providers.
## Decisions worth flagging
- **Scope deliberately stops before the callback.** It's the next PR. Clicking `/auth/login` today round-trips through Entra and lands on a 404 — bounded mid-state, documented in the commit and here.
- **State + verifier in the cookie, not in Redis.** Keeps `/login` stateless (no server-side store), which means the BFF stays horizontally scalable from day one without sticky-session config. The next-PR callback reads `req.signedCookies` to recover the payload.
- **`portal_pre_auth`, not `__Host-portal_pre_auth`.** `__Host-` mandates `Secure`, and local dev is HTTP. The prefix + `Secure: true` lands together with the production TLS hardening ADR.
- **No `offline_access` scope.** Sessions are short-lived (per ADR-0010); the user re-authenticates through Entra rather than the BFF refreshing tokens behind their back. Smaller token footprint, less code to write, easier to reason about.
- **5-minute cookie TTL.** Enough for the Entra round-trip (including a fresh MFA prompt), short enough that a stale cookie can't be replayed long after the user abandoned the flow.
## Verification
- `nx run-many -t lint test build --projects=portal-bff` — green.
- **39 / 39 specs** (was 30; +9 across `check-session-secret`, `auth.service`, `auth.controller`).
- The service spec mocks `getAuthCodeUrl`, asserts the redirect URI / scopes / S256 method, the state-verifier identity between the cookie payload and what's sent to Entra, and fresh-per-call replay protection.
- The controller spec asserts the cookie name + options + serialized payload and the 302 redirect.
## Manual smoke test (next PR completes the loop)
1. `apps/portal-bff/.env` has real `ENTRA_*` + `SESSION_SECRET`.
2. `nx serve portal-bff`.
3. `curl -i http://localhost:3000/api/auth/login` → 302 with `Set-Cookie: portal_pre_auth=…; HttpOnly; SameSite=Lax; Path=/`, `Location: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize?...`.
4. Open the `Location` in a browser, authenticate, Entra redirects to `http://localhost:3000/api/auth/callback?code=…&state=…` → 404 today, will be the next PR.
## Next PR on the auth track
`GET /api/auth/callback` — reads the signed cookie, verifies `state` matches, calls `acquireTokenByCode` with the stored verifier, validates the ID token (issuer, audience, exp, nonce, `amr` per ADR-0011), clears the pre-auth cookie, logs the resolved user identity, redirects to `/` (SPA). Still no session — that's the PR after.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #105
This commit was merged in pull request #105.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import type { ConfidentialClientApplication } from '@azure/msal-node';
|
||||
import { AuthService } 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',
|
||||
};
|
||||
|
||||
describe('AuthService', () => {
|
||||
let getAuthCodeUrl: jest.Mock<Promise<string>, [unknown]>;
|
||||
let service: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
getAuthCodeUrl = jest.fn().mockResolvedValue('https://entra.example/authorize?…');
|
||||
const msalStub = { getAuthCodeUrl } as unknown as ConfidentialClientApplication;
|
||||
service = new AuthService(msalStub, ENTRA);
|
||||
});
|
||||
|
||||
it('builds the auth URL with the configured redirect, OIDC scopes, S256 challenge', async () => {
|
||||
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((arg['codeChallenge'] as string).length).toBeGreaterThan(20);
|
||||
expect(typeof arg['state']).toBe('string');
|
||||
expect((arg['state'] as string).length).toBeGreaterThan(8);
|
||||
});
|
||||
|
||||
it('returns the pre-auth payload with state + codeVerifier matching what MSAL was called with', async () => {
|
||||
const { authUrl, preAuthPayload } = await service.beginAuthCodeFlow();
|
||||
expect(authUrl).toBe('https://entra.example/authorize?…');
|
||||
|
||||
const arg = getAuthCodeUrl.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(preAuthPayload.state).toBe(arg['state']);
|
||||
// The verifier returned to the caller is the secret the callback
|
||||
// will send back to Entra; the challenge sent to Entra is its
|
||||
// SHA-256-of-verifier transform, so the two must differ.
|
||||
expect(preAuthPayload.codeVerifier).not.toBe(arg['codeChallenge']);
|
||||
expect(typeof preAuthPayload.codeVerifier).toBe('string');
|
||||
expect(preAuthPayload.codeVerifier.length).toBeGreaterThan(40);
|
||||
expect(preAuthPayload.createdAt).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('generates a fresh state + verifier on every call (replay protection)', async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user