import type { Request, Response } from 'express'; import type { Logger } from 'nestjs-pino'; import type { AuditWriter } from '../audit/audit.service'; import { PRE_AUTH_COOKIE_NAME } from '../auth/auth.cookie'; import { AuthCodeFlowException } from '../auth/auth.errors'; import type { AuthService, PreAuthPayload } from '../auth/auth.service'; import type { EntraConfig } from '../auth/entra-config.token'; import type { SessionEstablisher } from '../auth/session-establisher.service'; import { AdminAuthController } from './admin-auth.controller'; 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/', adminRedirectUri: 'http://localhost:3000/api/admin/auth/callback', adminPostLogoutRedirectUri: 'http://localhost:4300/', authority: 'https://login.microsoftonline.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', }; const USER = { oid: 'admin-oid', tid: ENTRA.tenantId, username: 'admin@apf.example', displayName: 'Admin Smith', amr: ['pwd', 'mfa'], roles: ['admin'], }; const PRE_AUTH: PreAuthPayload = { state: 'state-nonce', codeVerifier: 'verifier-secret', createdAt: 1_000, }; const ADMIN_LOGOUT_URL = `${ENTRA.authority}/oauth2/v2.0/logout?post_logout_redirect_uri=${encodeURIComponent(ENTRA.adminPostLogoutRedirectUri)}`; function makeResStub() { const res = { cookie: jest.fn(), clearCookie: jest.fn(), redirect: jest.fn(), status: jest.fn(), json: jest.fn(), }; res.cookie.mockReturnValue(res); res.clearCookie.mockReturnValue(res); res.redirect.mockReturnValue(res); res.status.mockReturnValue(res); res.json.mockReturnValue(res); return res as unknown as Response & typeof res; } function makeReqStub(opts?: { signedCookies?: Record; sessionUser?: typeof USER; }): Request { return { signedCookies: opts?.signedCookies ?? {}, session: opts?.sessionUser !== undefined ? { user: opts.sessionUser } : {}, sessionID: 'sid-admin', } as unknown as Request; } function makeFixture(opts?: { completeAuthCodeFlow?: jest.Mock }) { const beginAuthCodeFlow = jest.fn().mockResolvedValue({ authUrl: 'https://entra.example/authorize?state=state-nonce', preAuthPayload: PRE_AUTH, }); const completeAuthCodeFlow = opts?.completeAuthCodeFlow ?? jest.fn().mockResolvedValue(USER); const buildLogoutUrl = jest.fn().mockReturnValue(ADMIN_LOGOUT_URL); const authService = { beginAuthCodeFlow, completeAuthCodeFlow, buildLogoutUrl, } as unknown as AuthService; const audit = { signIn: jest.fn().mockResolvedValue(undefined), signInFailed: jest.fn().mockResolvedValue(undefined), signOut: jest.fn().mockResolvedValue(undefined), }; const establisher = { establish: jest.fn().mockResolvedValue(undefined), destroy: jest.fn().mockResolvedValue(undefined), }; const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; return { controller: new AdminAuthController( authService, logger as unknown as Logger, ENTRA, audit as unknown as AuditWriter, establisher as unknown as SessionEstablisher, ), beginAuthCodeFlow, completeAuthCodeFlow, buildLogoutUrl, audit, establisher, logger, }; } describe('AdminAuthController.login', () => { it('passes the admin redirect URI to beginAuthCodeFlow', async () => { const { controller, beginAuthCodeFlow } = makeFixture(); await controller.login(makeResStub()); expect(beginAuthCodeFlow).toHaveBeenCalledWith(ENTRA.adminRedirectUri); }); it('writes the pre-auth cookie and 302s to the Entra auth URL', async () => { const { controller } = makeFixture(); const res = makeResStub(); await controller.login(res); expect(res.cookie).toHaveBeenCalledWith( PRE_AUTH_COOKIE_NAME, expect.any(String), expect.objectContaining({ signed: true, httpOnly: true }), ); expect(res.redirect).toHaveBeenCalledWith(302, expect.stringContaining('entra.example')); }); }); describe('AdminAuthController.callback', () => { it('passes the admin redirect URI to completeAuthCodeFlow and establishes a surface=admin session', async () => { const { controller, completeAuthCodeFlow, establisher } = makeFixture(); const res = makeResStub(); const req = makeReqStub({ signedCookies: { [PRE_AUTH_COOKIE_NAME]: JSON.stringify(PRE_AUTH) }, }); await controller.callback(req, res, 'auth-code', PRE_AUTH.state); expect(completeAuthCodeFlow).toHaveBeenCalledWith( 'auth-code', PRE_AUTH.state, PRE_AUTH, ENTRA.adminRedirectUri, ); expect(establisher.establish).toHaveBeenCalledWith({ user: USER, req, res, surface: 'admin', }); expect(res.redirect).toHaveBeenCalledWith(302, ENTRA.adminPostLogoutRedirectUri); }); it('redirects with ?auth_error=token-exchange-failed when Entra returns error', async () => { const { controller } = makeFixture(); const res = makeResStub(); const req = makeReqStub({ signedCookies: { [PRE_AUTH_COOKIE_NAME]: JSON.stringify(PRE_AUTH) }, }); await controller.callback(req, res, undefined, undefined, 'access_denied', 'consent declined'); expect(res.redirect).toHaveBeenCalledWith( 302, expect.stringContaining('auth_error=token-exchange-failed'), ); // Error redirect lands on the admin post-logout target, not the user-portal one. expect(res.redirect).toHaveBeenCalledWith( 302, expect.stringContaining(ENTRA.adminPostLogoutRedirectUri), ); }); it('redirects with ?auth_error=flow-expired when the pre-auth cookie is missing', async () => { const { controller, audit } = makeFixture(); const res = makeResStub(); const req = makeReqStub({ signedCookies: {} }); await controller.callback(req, res, 'auth-code', PRE_AUTH.state); expect(res.redirect).toHaveBeenCalledWith( 302, expect.stringContaining('auth_error=flow-expired'), ); expect(audit.signInFailed).toHaveBeenCalledWith( expect.objectContaining({ failureKind: 'no-pre-auth-cookie', payload: expect.objectContaining({ surface: 'admin' }), }), ); }); it('redirects with the typed error from AuthCodeFlowException', async () => { const completeAuthCodeFlow = jest .fn() .mockRejectedValue(new AuthCodeFlowException({ kind: 'state-mismatch' })); const { controller, audit } = makeFixture({ completeAuthCodeFlow }); const res = makeResStub(); const req = makeReqStub({ signedCookies: { [PRE_AUTH_COOKIE_NAME]: JSON.stringify(PRE_AUTH) }, }); await controller.callback(req, res, 'auth-code', PRE_AUTH.state); expect(res.redirect).toHaveBeenCalledWith( 302, expect.stringContaining('auth_error=state-mismatch'), ); expect(audit.signInFailed).toHaveBeenCalledWith( expect.objectContaining({ failureKind: 'state-mismatch', payload: expect.objectContaining({ surface: 'admin' }), }), ); }); }); describe('AdminAuthController.me', () => { it('returns the public payload with roles when the admin session is populated', () => { const { controller } = makeFixture(); const res = makeResStub(); const req = makeReqStub({ sessionUser: USER }); controller.me(req, res); expect(res.json).toHaveBeenCalledWith({ oid: USER.oid, tid: USER.tid, username: USER.username, displayName: USER.displayName, roles: USER.roles, }); }); it('throws UnauthorizedException when no user is on the admin session', () => { const { controller } = makeFixture(); const res = makeResStub(); const req = makeReqStub(); expect(() => controller.me(req, res)).toThrow(/Unauthenticated/); }); }); describe('AdminAuthController.logout', () => { it('destroys the admin session, clears the admin cookie + CSRF cookie, redirects to Entra logout', async () => { const { controller, establisher, buildLogoutUrl } = makeFixture(); const res = makeResStub(); const req = makeReqStub({ sessionUser: USER }); await controller.logout(req, res); expect(establisher.destroy).toHaveBeenCalledWith({ actor: USER, req }); expect(buildLogoutUrl).toHaveBeenCalledWith(ENTRA.adminPostLogoutRedirectUri); expect(res.clearCookie).toHaveBeenCalledWith('portal_admin_session', { path: '/' }); expect(res.clearCookie).toHaveBeenCalledWith('portal_csrf', { path: '/' }); expect(res.redirect).toHaveBeenCalledWith(302, ADMIN_LOGOUT_URL); }); it('uses the __Host- prefixed admin cookie name in production', async () => { const originalNodeEnv = process.env['NODE_ENV']; try { process.env['NODE_ENV'] = 'production'; const { controller } = makeFixture(); const res = makeResStub(); const req = makeReqStub({ sessionUser: USER }); await controller.logout(req, res); expect(res.clearCookie).toHaveBeenCalledWith('__Host-portal_admin_session', { path: '/', }); } finally { if (originalNodeEnv === undefined) { delete process.env['NODE_ENV']; } else { process.env['NODE_ENV'] = originalNodeEnv; } } }); it('still clears cookies and redirects for an anonymous admin sign-out', async () => { const { controller, establisher } = makeFixture(); const res = makeResStub(); const req = makeReqStub(); await controller.logout(req, res); expect(establisher.destroy).toHaveBeenCalledWith({ actor: undefined, req }); expect(res.clearCookie).toHaveBeenCalledWith('portal_admin_session', { path: '/' }); expect(res.redirect).toHaveBeenCalled(); }); });