feat(portal-bff): close the auth loop — callback persists session, /me, RP-initiated /logout (#112)
## Summary
Closes the OIDC loop end-to-end on the BFF side:
- `/auth/callback` now writes the resolved `AuthenticatedUser` into `req.session.user` and waits for `req.session.save()` before redirecting, so the SPA reaches the landing page with a populated session.
- `GET /auth/me` returns the curated public view of the session user (`oid`, `tid`, `username`, `displayName`) or `401 {"error": "unauthenticated"}`. `amr` and other internal claims stay server-side.
- `GET /auth/logout` destroys the BFF session (Redis `DEL`), clears the session cookie, and 302s to Entra's `/oauth2/v2.0/logout` so the IdP-side session is killed too — RP-initiated logout per ADR-0009.
Scope intentionally stops here: the absolute-timeout interceptor (12 h hard ceiling) and the `user_sessions:{userId}` secondary index land in dedicated follow-ups.
## Notable choices
**`req.session.save()` is awaited before the redirect.** Express-session writes to its store on response end; emitting the 302 closes the response before `connect-redis` finishes the write, so without an explicit await the browser can race the SPA into requesting `/me` against a missing key. Awaiting `save()` is the documented fix.
**Logout via `GET`.** Matches `/login` (also `GET`) and keeps the UX a plain anchor / top-level navigation. The CSRF surface is mitigated by `SameSite=Lax` on the session cookie — cross-site subresource requests (`<img src>`, `fetch`) don't carry it. A dedicated CSRF middleware lands with phase-2 security; if we want POST-only logout earlier, easy follow-up.
**`/me` strips `amr`.** The session payload mirrors `AuthenticatedUser` (used internally by the future `@RequireMfa()` guard, ADR-0011), but the SPA only ever needs the curated subset. Mapping happens in the controller — no leak by default.
**Logout URL skips `id_token_hint`.** ADR-0009 mentions it for single-account logout UX, but v1 doesn't persist the `id_token` in the session yet (the encrypted `tokens` blob lands with downstream API support per ADR-0014). Without `id_token_hint`, Entra shows an account picker — the conservative default until token persistence ships.
**Cookie name in logout.** Uses `sessionCookieName()` from `session/session-cookie.ts` so logout clears the same cookie the middleware sets — `__Host-portal_session` in prod, `portal_session` in dev.
## Out of scope (next PRs)
- Absolute-timeout interceptor (12 h hard ceiling, ADR-0010).
- `user_sessions:{userId}` secondary index for admin "logout everywhere".
- Persisting the `id_token` / `access_token` / `refresh_token` blob in the encrypted session (ADR-0014 dependency).
- CSRF middleware (phase-2 security).
- Renaming `ENTRA_POST_LOGOUT_REDIRECT_URI` if we want a distinct post-login redirect target — for now both flows land on the same SPA URL.
## Test plan
- [x] `pnpm nx test portal-bff` → **110/110 pass** (was 99 before this PR; +11 specs across `auth.controller.spec.ts` and `auth.service.spec.ts`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → webpack compiled successfully.
- [x] Prettier-clean on all touched files.
- [ ] Manual end-to-end smoke test:
- [ ] `/api/auth/login` → Entra → back at `/api/auth/callback` → session cookie set, redirect to SPA.
- [ ] `/api/auth/me` → 200 JSON when authenticated, 401 when anonymous.
- [ ] `/api/auth/logout` → Redis key gone, cookie cleared, lands at SPA via Entra logout.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #112
This commit was merged in pull request #112.
This commit is contained in:
@@ -1,21 +1,25 @@
|
||||
import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { sessionCookieName } from '../session/session-cookie';
|
||||
import {
|
||||
PRE_AUTH_COOKIE_NAME,
|
||||
clearPreAuthCookieOptions,
|
||||
preAuthCookieOptions,
|
||||
} from './auth.cookie';
|
||||
import { AuthCodeFlowException, type AuthCodeFlowError, authErrorCode } from './auth.errors';
|
||||
import { AuthService, type PreAuthPayload } from './auth.service';
|
||||
import { AuthService, type AuthenticatedUser, type PreAuthPayload } from './auth.service';
|
||||
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
|
||||
|
||||
/**
|
||||
* OIDC routes mounted under `/api/auth/` per ADR-0009.
|
||||
*
|
||||
* v1 ships two routes — `GET /login` (PR #105) and `GET /callback`
|
||||
* (this PR). The next PR adds session persistence (Redis,
|
||||
* ADR-0010); after that, `/me` and `/logout` close the loop.
|
||||
* Routes shipped here: `GET /login`, `GET /callback`, `GET /me`,
|
||||
* `GET /logout`. The callback persists the resolved identity into
|
||||
* the express-session store (ADR-0010); `/me` reads it back; the
|
||||
* logout endpoint destroys the session, clears the cookie, and
|
||||
* redirects to Entra's RP-initiated logout endpoint so the user is
|
||||
* signed out at the IdP too.
|
||||
*/
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -102,6 +106,13 @@ export class AuthController {
|
||||
|
||||
try {
|
||||
const user = await this.authService.completeAuthCodeFlow(code, state, preAuth);
|
||||
req.session.user = user;
|
||||
// Force the save before the redirect: express-session writes
|
||||
// on response end, but the 302 we're about to emit closes the
|
||||
// response before the async store-write would otherwise
|
||||
// complete. Without this, the browser hits the SPA before
|
||||
// Redis carries the new payload.
|
||||
await saveSession(req);
|
||||
this.logger.log(
|
||||
{
|
||||
event: 'auth.signed_in',
|
||||
@@ -112,8 +123,6 @@ export class AuthController {
|
||||
},
|
||||
'AuthCallback',
|
||||
);
|
||||
// No session persistence yet — next PR. SPA will see the user
|
||||
// as anonymous on the landing page.
|
||||
res.redirect(302, this.entra.postLogoutRedirectUri);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthCodeFlowException) {
|
||||
@@ -124,6 +133,65 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of the current session for the SPA. Returns the
|
||||
* curated subset of the resolved identity — `amr` and other
|
||||
* auth-internal claims stay server-side.
|
||||
*
|
||||
* 401 (with no body beyond a `{error}` tag) on missing session
|
||||
* lets the SPA distinguish "anonymous" from any other failure;
|
||||
* the browser's session cookie was either absent, expired, or
|
||||
* pointed at a Redis key that no longer exists.
|
||||
*/
|
||||
@Get('me')
|
||||
me(@Req() req: Request, @Res() res: Response): void {
|
||||
const user = req.session.user;
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'unauthenticated' });
|
||||
return;
|
||||
}
|
||||
res.json(toPublicUser(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* RP-initiated logout per ADR-0009. Destroys the BFF session
|
||||
* (DEL on the Redis key), clears the session cookie, then
|
||||
* redirects the browser to Entra's `/oauth2/v2.0/logout` so the
|
||||
* IdP-side session is killed too — single sign-out behaviour. The
|
||||
* post-logout redirect on the Entra end lands the user back on
|
||||
* the SPA root.
|
||||
*
|
||||
* Idempotent: if the user was already anonymous, the destroy is
|
||||
* a no-op and we redirect anyway. CSRF for v1 relies on
|
||||
* SameSite=Lax (subresource requests don't carry the cookie); a
|
||||
* dedicated CSRF middleware lands with phase-2 security.
|
||||
*/
|
||||
@Get('logout')
|
||||
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
const wasAuthenticated = Boolean(req.session.user);
|
||||
const logoutUrl = this.authService.buildLogoutUrl();
|
||||
|
||||
try {
|
||||
await destroySession(req);
|
||||
} catch (err) {
|
||||
// The Redis DEL failed — log and continue. Clearing the
|
||||
// cookie still gets the user effectively logged out from the
|
||||
// BFF's point of view; the orphan Redis key will hit its idle
|
||||
// TTL on its own.
|
||||
this.logger.error(
|
||||
{
|
||||
event: 'session.destroy_failed',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
'AuthLogout',
|
||||
);
|
||||
}
|
||||
|
||||
res.clearCookie(sessionCookieName(), { path: '/' });
|
||||
this.logger.log({ event: 'auth.signed_out', wasAuthenticated }, 'AuthLogout');
|
||||
res.redirect(302, logoutUrl);
|
||||
}
|
||||
|
||||
private redirectWithError(res: Response, kind: AuthCodeFlowError['kind']): void {
|
||||
const url = new URL(this.entra.postLogoutRedirectUri);
|
||||
url.searchParams.set('auth_error', authErrorCode({ kind } as AuthCodeFlowError));
|
||||
@@ -131,6 +199,34 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
interface PublicUser {
|
||||
oid: string;
|
||||
tid: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
function toPublicUser(user: AuthenticatedUser): PublicUser {
|
||||
return {
|
||||
oid: user.oid,
|
||||
tid: user.tid,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
function saveSession(req: Request): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.session.save((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
function destroySession(req: Request): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.session.destroy((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
function readPreAuthCookie(req: Request): PreAuthPayload | null {
|
||||
const raw = (req.signedCookies as Record<string, unknown>)[PRE_AUTH_COOKIE_NAME];
|
||||
if (typeof raw !== 'string') {
|
||||
|
||||
Reference in New Issue
Block a user