f50d2d66c0
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.
239 lines
8.7 KiB
TypeScript
239 lines
8.7 KiB
TypeScript
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
import { Logger } from 'nestjs-pino';
|
|
import { AuditWriter } from '../audit/audit.service';
|
|
import { csrfCookieName } from '../security/csrf-cookie';
|
|
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 AuthenticatedUser, type PreAuthPayload } from './auth.service';
|
|
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
|
|
import { SessionEstablisher } from './session-establisher.service';
|
|
|
|
/**
|
|
* OIDC routes mounted under `/api/auth/` per ADR-0009.
|
|
*
|
|
* 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 {
|
|
constructor(
|
|
private readonly authService: AuthService,
|
|
private readonly logger: Logger,
|
|
@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
|
|
private readonly audit: AuditWriter,
|
|
private readonly sessionEstablisher: SessionEstablisher,
|
|
) {}
|
|
|
|
/**
|
|
* Starts the auth flow. Generates a fresh state + PKCE pair via
|
|
* `AuthService`, stashes them in a short-lived signed cookie so
|
|
* `/callback` can verify the round-trip, and 302s the browser to
|
|
* Entra's authorize endpoint.
|
|
*
|
|
* The cookie carries everything the callback needs — no
|
|
* server-side state. That keeps `/login` stateless and friendly
|
|
* to horizontal scaling once the BFF runs more than one instance.
|
|
*/
|
|
@Get('login')
|
|
async login(@Res() res: Response): Promise<void> {
|
|
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
|
this.entra.redirectUri,
|
|
);
|
|
res.cookie(PRE_AUTH_COOKIE_NAME, JSON.stringify(preAuthPayload), preAuthCookieOptions());
|
|
res.redirect(302, authUrl);
|
|
}
|
|
|
|
/**
|
|
* Second leg of the OIDC flow. Entra redirects the user here with
|
|
* `?code=…&state=…` (success) or `?error=…&error_description=…`
|
|
* (failure). The pre-auth cookie set by `/login` carries the
|
|
* matching state + PKCE verifier so we can verify the round-trip
|
|
* and finish the token exchange.
|
|
*
|
|
* On success, the user identity is logged via Pino and the
|
|
* browser is redirected to the SPA. No session is persisted yet —
|
|
* that's the next PR (Redis-backed sessions per ADR-0010); until
|
|
* then the SPA cannot tell the user is "logged in".
|
|
*
|
|
* On failure (any branch — state mismatch, expired cookie,
|
|
* missing `amr` per ADR-0011, MSAL token-exchange error, or
|
|
* Entra-side error from the query) the user lands on the SPA
|
|
* with `?auth_error=<code>` so the front-end can surface a
|
|
* specific message. The pre-auth cookie is cleared on every
|
|
* exit path: it is single-use by design.
|
|
*/
|
|
@Get('callback')
|
|
async callback(
|
|
@Req() req: Request,
|
|
@Res() res: Response,
|
|
@Query('code') code?: string,
|
|
@Query('state') state?: string,
|
|
@Query('error') entraError?: string,
|
|
@Query('error_description') entraErrorDescription?: string,
|
|
): Promise<void> {
|
|
// Drop the single-use cookie regardless of outcome.
|
|
res.clearCookie(PRE_AUTH_COOKIE_NAME, clearPreAuthCookieOptions());
|
|
|
|
// Entra signalled a failure (e.g. user cancelled the consent
|
|
// screen, MFA challenge failed, app-registration mismatch).
|
|
if (entraError) {
|
|
this.logger.warn(
|
|
{
|
|
event: 'auth.entra_error',
|
|
entraError,
|
|
entraErrorDescription,
|
|
},
|
|
'AuthCallback',
|
|
);
|
|
await this.audit.signInFailed({
|
|
failureKind: 'entra-error',
|
|
payload: { entraError, entraErrorDescription },
|
|
});
|
|
return this.redirectWithError(res, 'token-exchange-failed');
|
|
}
|
|
|
|
if (typeof code !== 'string' || typeof state !== 'string') {
|
|
await this.audit.signInFailed({ failureKind: 'missing-code-or-state' });
|
|
return this.redirectWithError(res, 'token-exchange-failed');
|
|
}
|
|
|
|
const preAuth = readPreAuthCookie(req);
|
|
if (!preAuth) {
|
|
// No cookie → either the user opened /callback directly, or
|
|
// their browser dropped the cookie (TTL elapsed in
|
|
// a 3rd-party-cookie blocker, etc.). Treat as flow-expired.
|
|
this.logger.warn({ event: 'auth.no_pre_auth_cookie' }, 'AuthCallback');
|
|
await this.audit.signInFailed({ failureKind: 'no-pre-auth-cookie' });
|
|
return this.redirectWithError(res, 'flow-expired');
|
|
}
|
|
|
|
try {
|
|
const user = await this.authService.completeAuthCodeFlow(
|
|
code,
|
|
state,
|
|
preAuth,
|
|
this.entra.redirectUri,
|
|
);
|
|
await this.sessionEstablisher.establish({ user, req, res, surface: 'user' });
|
|
res.redirect(302, this.entra.postLogoutRedirectUri);
|
|
} catch (err) {
|
|
if (err instanceof AuthCodeFlowException) {
|
|
this.logger.warn({ event: 'auth.flow_error', failure: err.failure }, 'AuthCallback');
|
|
await this.audit.signInFailed({ failureKind: err.failure.kind });
|
|
return this.redirectWithError(res, err.failure.kind);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
// Throw rather than write the response directly: the global
|
|
// `StructuredErrorFilter` then formats it as the shared
|
|
// `{ error: { code, message, traceId } }` envelope. Code is
|
|
// set via the response-object form of HttpException so the
|
|
// filter picks it up verbatim.
|
|
throw new UnauthorizedException({
|
|
code: 'unauthenticated',
|
|
message: 'Unauthenticated',
|
|
});
|
|
}
|
|
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 user = req.session.user;
|
|
const wasAuthenticated = Boolean(user);
|
|
const logoutUrl = this.authService.buildLogoutUrl(this.entra.postLogoutRedirectUri);
|
|
|
|
await this.sessionEstablisher.destroy({ actor: user, req });
|
|
|
|
res.clearCookie(sessionCookieName(), { path: '/' });
|
|
res.clearCookie(csrfCookieName(), { path: '/' });
|
|
this.logger.log({ event: 'auth.signed_out', surface: 'user', 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));
|
|
res.redirect(302, url.toString());
|
|
}
|
|
}
|
|
|
|
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 readPreAuthCookie(req: Request): PreAuthPayload | null {
|
|
const raw = (req.signedCookies as Record<string, unknown>)[PRE_AUTH_COOKIE_NAME];
|
|
if (typeof raw !== 'string') {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<PreAuthPayload>;
|
|
if (
|
|
typeof parsed.state === 'string' &&
|
|
typeof parsed.codeVerifier === 'string' &&
|
|
typeof parsed.createdAt === 'number'
|
|
) {
|
|
return {
|
|
state: parsed.state,
|
|
codeVerifier: parsed.codeVerifier,
|
|
createdAt: parsed.createdAt,
|
|
};
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|