diff --git a/webapp/api/auth/authConfig.js b/webapp/api/auth/authConfig.js
new file mode 100644
index 00000000..96a6ba79
--- /dev/null
+++ b/webapp/api/auth/authConfig.js
@@ -0,0 +1,26 @@
+/**
+ * Configuration object to be passed to MSAL instance on creation.
+ * For a full list of MSAL Node configuration parameters, visit:
+ * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/configuration.md
+ */
+
+const authorityUrl = process.env.B2C_BASE_URL + '/B2C_1_login';
+
+export const msalConfig = {
+ auth: {
+ clientId: process.env.B2C_CLIENT_ID,
+ authority: authorityUrl,
+ clientSecret: process.env.B2C_CLIENT_SECRET,
+ knownAuthorities: [new URL(authorityUrl).host],
+ protocolMode: 'OIDC',
+ },
+ system: {
+ loggerOptions: {
+ piiLoggingEnabled: false,
+ logLevel: 2,
+ loggerCallback(loglevel, message, containsPii) {
+ if (!containsPii) log.info(message);
+ },
+ },
+ },
+};
\ No newline at end of file
diff --git a/webapp/api/auth/authProvider.js b/webapp/api/auth/authProvider.js
new file mode 100644
index 00000000..c0cf9037
--- /dev/null
+++ b/webapp/api/auth/authProvider.js
@@ -0,0 +1,267 @@
+const msal = require('@azure/msal-node');
+const axios = require('axios');
+
+class AuthProvider {
+ msalConfig;
+ cryptoProvider;
+
+ constructor(msalConfig) {
+ this.msalConfig = msalConfig
+ this.cryptoProvider = new msal.CryptoProvider();
+ };
+
+ login(options = {}) {
+ return async (req, res, next) => {
+
+ /**
+ * MSAL Node library allows you to pass your custom state as state parameter in the Request object.
+ * The state parameter can also be used to encode information of the app's state before redirect.
+ * You can pass the user's state in the app, such as the page or view they were on, as input to this parameter.
+ */
+ const state = this.cryptoProvider.base64Encode(
+ JSON.stringify({
+ successRedirect: options.successRedirect || '/',
+ })
+ );
+
+ const authCodeUrlRequestParams = {
+ state: state,
+
+ /**
+ * By default, MSAL Node will add OIDC scopes to the auth code url request. For more information, visit:
+ * https://docs.microsoft.com/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes
+ */
+ scopes: options.scopes || [],
+ redirectUri: options.redirectUri,
+ };
+
+ const authCodeRequestParams = {
+ state: state,
+
+ /**
+ * By default, MSAL Node will add OIDC scopes to the auth code request. For more information, visit:
+ * https://docs.microsoft.com/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes
+ */
+ scopes: options.scopes || [],
+ redirectUri: options.redirectUri,
+ };
+
+ /**
+ * If the current msal configuration does not have cloudDiscoveryMetadata or authorityMetadata, we will
+ * make a request to the relevant endpoints to retrieve the metadata. This allows MSAL to avoid making
+ * metadata discovery calls, thereby improving performance of token acquisition process. For more, see:
+ * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/performance.md
+ */
+ if (!this.msalConfig.auth.cloudDiscoveryMetadata || !this.msalConfig.auth.authorityMetadata) {
+
+ const [cloudDiscoveryMetadata, authorityMetadata] = await Promise.all([
+ this.getCloudDiscoveryMetadata(this.msalConfig.auth.authority),
+ this.getAuthorityMetadata(this.msalConfig.auth.authority)
+ ]);
+
+ this.msalConfig.auth.cloudDiscoveryMetadata = JSON.stringify(cloudDiscoveryMetadata);
+ this.msalConfig.auth.authorityMetadata = JSON.stringify(authorityMetadata);
+ }
+
+ const msalInstance = this.getMsalInstance(this.msalConfig);
+
+ // trigger the first leg of auth code flow
+ return this.redirectToAuthCodeUrl(
+ authCodeUrlRequestParams,
+ authCodeRequestParams,
+ msalInstance
+ )(req, res, next);
+ };
+ }
+
+ acquireToken(options = {}) {
+ return async (req, res, next) => {
+ try {
+ const msalInstance = this.getMsalInstance(this.msalConfig);
+
+ /**
+ * If a token cache exists in the session, deserialize it and set it as the
+ * cache for the new MSAL CCA instance. For more, see:
+ * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/caching.md
+ */
+ if (req.session.tokenCache) {
+ msalInstance.getTokenCache().deserialize(req.session.tokenCache);
+ }
+
+ const tokenResponse = await msalInstance.acquireTokenSilent({
+ account: req.session.account,
+ scopes: options.scopes || [],
+ });
+
+ /**
+ * On successful token acquisition, write the updated token
+ * cache back to the session. For more, see:
+ * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/caching.md
+ */
+ req.session.tokenCache = msalInstance.getTokenCache().serialize();
+ req.session.accessToken = tokenResponse.accessToken;
+ req.session.idToken = tokenResponse.idToken;
+ req.session.account = tokenResponse.account;
+
+ res.redirect(options.successRedirect);
+ } catch (error) {
+ if (error instanceof msal.InteractionRequiredAuthError) {
+ return this.login({
+ scopes: options.scopes || [],
+ redirectUri: options.redirectUri,
+ successRedirect: options.successRedirect || '/',
+ })(req, res, next);
+ }
+
+ next(error);
+ }
+ };
+ }
+
+ handleRedirect(options = {}) {
+ return async (req, res, next) => {
+ if (!req.body || !req.body.state) {
+ return next(new Error('Error: response not found'));
+ }
+
+ const authCodeRequest = {
+ ...req.session.authCodeRequest,
+ code: req.body.code,
+ codeVerifier: req.session.pkceCodes.verifier,
+ };
+
+ try {
+ const msalInstance = this.getMsalInstance(this.msalConfig);
+
+ if (req.session.tokenCache) {
+ msalInstance.getTokenCache().deserialize(req.session.tokenCache);
+ }
+
+ const tokenResponse = await msalInstance.acquireTokenByCode(authCodeRequest, req.body);
+
+ req.session.tokenCache = msalInstance.getTokenCache().serialize();
+ req.session.idToken = tokenResponse.idToken;
+ req.session.account = tokenResponse.account;
+ req.session.isAuthenticated = true;
+
+ const state = JSON.parse(this.cryptoProvider.base64Decode(req.body.state));
+ res.redirect(state.successRedirect);
+ } catch (error) {
+ next(error);
+ }
+ }
+ }
+
+ logout(options = {}) {
+ return (req, res, next) => {
+
+ /**
+ * Construct a logout URI and redirect the user to end the
+ * session with Azure AD. For more information, visit:
+ * https://docs.microsoft.com/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request
+ */
+ let logoutUri = `${this.msalConfig.auth.authority}/oauth2/v2.0/`;
+
+ if (options.postLogoutRedirectUri) {
+ logoutUri += `logout?post_logout_redirect_uri=${options.postLogoutRedirectUri}`;
+ }
+
+ req.session.destroy(() => {
+ res.redirect(logoutUri);
+ });
+ }
+ }
+
+ /**
+ * Instantiates a new MSAL ConfidentialClientApplication object
+ * @returns
+ * @param msalConfig
+ */
+ getMsalInstance(msalConfig) {
+ return new msal.ConfidentialClientApplication(msalConfig);
+ }
+
+
+ /**
+ * Prepares the auth code request parameters and initiates the first leg of auth code flow
+ * @param authCodeUrlRequestParams
+ * @param authCodeRequestParams
+ * @param msalInstance
+ */
+ redirectToAuthCodeUrl(authCodeUrlRequestParams, authCodeRequestParams, msalInstance) {
+ return async (req, res, next) => {
+ // Generate PKCE Codes before starting the authorization flow
+ const { verifier, challenge } = await this.cryptoProvider.generatePkceCodes();
+
+ // Set generated PKCE codes and method as session vars
+ req.session.pkceCodes = {
+ challengeMethod: 'S256',
+ verifier: verifier,
+ challenge: challenge,
+ };
+
+ /**
+ * By manipulating the request objects below before each request, we can obtain
+ * auth artifacts with desired claims. For more information, visit:
+ * https://azuread.github.io/microsoft-authentication-library-for-js/ref/modules/_azure_msal_node.html#authorizationurlrequest
+ * https://azuread.github.io/microsoft-authentication-library-for-js/ref/modules/_azure_msal_node.html#authorizationcoderequest
+ **/
+ req.session.authCodeUrlRequest = {
+ ...authCodeUrlRequestParams,
+ responseMode: msal.ResponseMode.FORM_POST, // recommended for confidential clients
+ codeChallenge: req.session.pkceCodes.challenge,
+ codeChallengeMethod: req.session.pkceCodes.challengeMethod,
+ };
+
+ req.session.authCodeRequest = {
+ ...authCodeRequestParams,
+ code: '',
+ };
+
+ try {
+ const authCodeUrlResponse = await msalInstance.getAuthCodeUrl(req.session.authCodeUrlRequest);
+ res.redirect(authCodeUrlResponse);
+ } catch (error) {
+ next(error);
+ }
+ };
+ }
+
+ /**
+ * Retrieves cloud discovery metadata from the /discovery/instance endpoint
+ * @returns
+ */
+ async getCloudDiscoveryMetadata(authority) {
+ const endpoint = 'https://login.microsoftonline.com/common/discovery/instance';
+
+ try {
+ const response = await axios.get(endpoint, {
+ params: {
+ 'api-version': '1.1',
+ 'authorization_endpoint': `${authority}/oauth2/v2.0/authorize`
+ }
+ });
+
+ return await response.data;
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ /**
+ * Retrieves oidc metadata from the openid endpoint
+ * @returns
+ */
+ async getAuthorityMetadata(authority) {
+ const endpoint = `${authority}/v2.0/.well-known/openid-configuration`;
+
+ try {
+ const response = await axios.get(endpoint);
+ return await response.data;
+ } catch (error) {
+ console.log(error);
+ }
+ }
+}
+
+module.exports = AuthProvider;
\ No newline at end of file
diff --git a/webapp/api/auth/authProvider.test.js b/webapp/api/auth/authProvider.test.js
new file mode 100644
index 00000000..d9dfd8c2
--- /dev/null
+++ b/webapp/api/auth/authProvider.test.js
@@ -0,0 +1,170 @@
+const AuthProvider = require('./authProvider');
+const msal = require('@azure/msal-node');
+const axios = require('axios');
+
+
+jest.mock('@azure/msal-node');
+jest.mock('axios');
+jest.unmock('./authProvider');
+
+describe('AuthProvider Unit Tests', () => {
+ let authProvider;
+ let mockReq, mockRes, mockNext;
+ let mockMsalInstance;
+
+ const mockConfig = {
+ auth: {
+ clientId: 'test-client-id',
+ authority: 'https://login.microsoftonline.com/test-tenant',
+ clientSecret: 'secret',
+ knownAuthorities: 'https://login.microsoftonline.com'
+ }
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockReq = {
+ session: {},
+ body: {},
+ query: {}
+ };
+ mockRes = {
+ redirect: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn()
+ };
+ mockNext = jest.fn();
+
+ mockMsalInstance = {
+ getAuthCodeUrl: jest.fn(),
+ acquireTokenByCode: jest.fn(),
+ acquireTokenSilent: jest.fn(),
+ getTokenCache: jest.fn().mockReturnValue({
+ serialize: jest.fn().mockReturnValue('mock-serialized-cache'),
+ deserialize: jest.fn()
+ })
+ };
+
+ msal.ConfidentialClientApplication.mockImplementation(() => mockMsalInstance);
+
+ // Use real CryptoProvider for the test logic (it's logic-heavy)
+ const RealCrypto = jest.requireActual('@azure/msal-node').CryptoProvider;
+ msal.CryptoProvider = RealCrypto;
+
+ authProvider = new AuthProvider(mockConfig);
+ });
+
+ describe('login()', () => {
+ it('should fetch metadata if missing, set PKCE in session, and redirect', async () => {
+
+ axios.get.mockResolvedValueOnce({ data: { tenant_discovery_endpoint: 'mock-tenant' } });
+ axios.get.mockResolvedValueOnce({ data: { authorization_endpoint: 'mock-authorization' } });
+
+
+ const mockAuthUrl = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?state=...';
+ mockMsalInstance.getAuthCodeUrl.mockResolvedValue(mockAuthUrl);
+
+ const middleware = authProvider.login({
+ redirectUri: 'http://localhost/dashboard',
+ scopes: ['User.Read']
+ });
+ await middleware(mockReq, mockRes, mockNext);
+
+ expect(axios.get).toHaveBeenCalledTimes(2);
+ expect(mockReq.session.pkceCodes).toBeDefined();
+ expect(mockReq.session.pkceCodes.verifier).toBeDefined();
+ expect(mockRes.redirect).toHaveBeenCalledWith(mockAuthUrl);
+ });
+ });
+
+ describe('handleRedirect()', () => {
+ it('should exchange code for token and update session', async () => {
+
+ mockReq.session.pkceCodes = { verifier: 'mock-verifier' };
+ mockReq.session.authCodeRequest = { scopes: ['User.Read'] };
+
+
+ const stateJson = JSON.stringify({ successRedirect: '/dashboard' });
+ const stateBase64 = new msal.CryptoProvider().base64Encode(stateJson);
+
+ mockReq.body = {
+ code: 'mock-auth-code',
+ state: stateBase64
+ };
+
+
+ mockMsalInstance.acquireTokenByCode.mockResolvedValue({
+ idToken: 'id-token',
+ account: { username: 'testuser' }
+ });
+
+ const middleware = authProvider.handleRedirect();
+ await middleware(mockReq, mockRes, mockNext);
+
+
+ expect(mockMsalInstance.acquireTokenByCode).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'mock-auth-code'}),
+ expect.anything()
+ );
+
+
+ expect(mockReq.session.isAuthenticated).toBe(true);
+ expect(mockReq.session.account.username).toBe('testuser');
+ expect(mockReq.session.tokenCache).toBe('mock-serialized-cache');
+ expect(mockRes.redirect).toHaveBeenCalledWith('/dashboard');
+ });
+ });
+
+ describe('acquireToken()', () => {
+ it('should return cached token if available', async () => {
+ mockReq.session.tokenCache = 'existing-cache-data';
+ mockReq.session.account = { homeAccountId: '1' };
+
+ mockMsalInstance.acquireTokenSilent.mockResolvedValue({
+ accessToken: 'new-access-token',
+ idToken: 'new-id-token',
+ account: { homeAccountId: '1' }
+ });
+
+ const middleware = authProvider.acquireToken();
+ await middleware(mockReq, mockRes, mockNext);
+
+ expect(mockMsalInstance.getTokenCache().deserialize).toHaveBeenCalledWith('existing-cache-data');
+ expect(mockMsalInstance.acquireTokenSilent).toHaveBeenCalled();
+ expect(mockRes.redirect).toHaveBeenCalled();
+ });
+
+ it('should trigger login if silent acquisition fails (InteractionRequired)', async () => {
+
+ axios.get.mockResolvedValueOnce({ data: { tenant_discovery_endpoint: 'mock-tenant' } });
+ axios.get.mockResolvedValueOnce({ data: { authorization_endpoint: 'mock-authorization' } });
+
+ const interactionError = new msal.InteractionRequiredAuthError('Login needed');
+ mockMsalInstance.acquireTokenSilent.mockRejectedValue(interactionError);
+
+ const loginSpy = jest.spyOn(authProvider, 'login');
+
+ const middleware = authProvider.acquireToken({ redirectUri: '/dashboard' });
+ await middleware(mockReq, mockRes, mockNext);
+
+ expect(loginSpy).toHaveBeenCalled();
+
+ expect(mockRes.redirect).toHaveBeenCalled();
+ });
+ });
+
+ describe('logout()', () => {
+ it('should destroy session and redirect to Azure logout', () => {
+ mockReq.session.destroy = jest.fn((next) => next());
+
+ const middleware = authProvider.logout({ postLogoutRedirectUri: 'http://localhost' });
+ middleware(mockReq, mockRes, mockNext);
+
+ expect(mockReq.session.destroy).toHaveBeenCalled();
+ expect(mockRes.redirect).toHaveBeenCalledWith(
+ expect.stringContaining('logout?post_logout_redirect_uri=http://localhost')
+ );
+ });
+ });
+});
\ No newline at end of file
diff --git a/webapp/api/routes/index.js b/webapp/api/routes/index.js
index afcb1973..1f48803c 100644
--- a/webapp/api/routes/index.js
+++ b/webapp/api/routes/index.js
@@ -19,6 +19,7 @@ import salvageAward from './report/salvage-award';
import checkYourAnswers from './report/check-your-answers';
import portalStart from './portal/start';
import portalLogin from './portal/login';
+import portalMSALLogin from './portal/msal-node/login';
import portalLogout from './portal/logout';
import portalLoginRedirectUrl from './portal/loginRedirectUrl';
import portalDashboard from './portal/dashboard';
@@ -28,6 +29,7 @@ import accountError from './portal/error';
import sendSample from './report/send-sample';
import health from './health';
+import config from '../../app/config';
export default () => {
@@ -53,7 +55,13 @@ export default () => {
checkYourAnswers(app);
portalStart(app);
- portalLogin(app);
+
+ if (config.USE_MSAL) {
+ portalMSALLogin(app);
+ } else {
+ portalLogin(app);
+ }
+
portalLogout(app);
portalLoginRedirectUrl(app);
portalDashboard(app);
diff --git a/webapp/api/routes/portal/login.test.js b/webapp/api/routes/portal/login.test.js
index b2b2f3d3..957981bd 100644
--- a/webapp/api/routes/portal/login.test.js
+++ b/webapp/api/routes/portal/login.test.js
@@ -5,7 +5,7 @@ const request = require('supertest');
const passport = require('passport');
-describe('Login Unit Tests', () => {
+describe('Passport Unit Tests', () => {
beforeEach(() => {
users.length = 0;
@@ -105,7 +105,7 @@ describe('Login Unit Tests', () => {
});
});
-describe('Login Integration Tests', () => {
+describe('Login Unit Tests', () => {
afterEach(() => {
jest.clearAllMocks();
diff --git a/webapp/api/routes/portal/msal-node/login.js b/webapp/api/routes/portal/msal-node/login.js
new file mode 100644
index 00000000..9c97d9c4
--- /dev/null
+++ b/webapp/api/routes/portal/msal-node/login.js
@@ -0,0 +1,38 @@
+require("dotenv-json")();
+
+import {msalConfig} from "../../../auth/authConfig";
+
+const AuthProvider = require('../../../auth/authProvider');
+
+const bunyan = require('bunyan');
+const log = bunyan.createLogger({
+ name: 'Microsoft OIDC Example Web Application',
+ level: 'info',
+});
+
+export const authProvider = new AuthProvider(msalConfig);
+
+export default function (app) {
+ app.get(
+ '/login',
+ function (req, res, next) {
+ authProvider.login({
+ scopes: ['openid', 'profile', process.env.B2C_CLIENT_ID],
+ redirectUri: process.env.ENV_BASE_URL + process.env.B2C_REDIRECT_URL,
+ })(req, res, next);
+ },
+ function (req, res) {
+ if (!req.session.isAuthenticated || !req.session.account) {
+ const logoutUrl = `${process.env.B2C_BASE_URL}/oauth2/v2.0/logout?p=B2C_1_login&post_logout_redirect_uri=${process.env.ENV_BASE_URL}/error`;
+ return res.redirect(logoutUrl);
+ }
+ const profile = req.session.account.idTokenClaims;
+
+ log.info('We received a return from AzureAD.');
+ res.render('portal/dashboard', { user: profile });
+ }
+ );
+};
+
+
+
diff --git a/webapp/api/routes/portal/msal-node/login.test.js b/webapp/api/routes/portal/msal-node/login.test.js
new file mode 100644
index 00000000..630ac43c
--- /dev/null
+++ b/webapp/api/routes/portal/msal-node/login.test.js
@@ -0,0 +1,42 @@
+import app from '../../../../server';
+import {authProvider, users} from "./login";
+const request = require('supertest');
+
+
+// Mock the config module
+jest.mock('../../../../app/config', () => ({
+ SERVICE_NAME: 'Report Wreck Material',
+ PORT: '3000',
+ USE_HTTPS: 'false',
+ COOKIE_TEXT:
+ 'GOV.UK uses cookies to make the site simpler. Find out more about cookies',
+ SERVICE_UNAVAILABLE: false,
+ USE_MSAL: true,
+ RATE_LIMIT_POINTS: 100, // to allow tests passing
+}));
+
+
+describe('MSAL Login Tests', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ jest.resetAllMocks();
+ })
+
+ it('GET /login should authenticate using the strategy and render the dashboard', async () => {
+ const response = await request(app).get('/login');
+ expect(response.status).toBe(200);
+ expect(response.text).toContain('Your reports of wreck material');
+ });
+
+ it('GET /login should redirect 302 when auth fails or if session is invalid', async () => {
+ authProvider.login.mockImplementation(() => (req, res, next) => {
+ req.session.isAuthenticated = false;
+ req.session.account = null;
+ next();
+ });
+ const response = await request(app).get('/login');
+ expect(response.status).toBe(302);
+ expect(response.header.location).toContain('https://testb2cmcga.b2clogin.com/TESTB2CMCGA.onmicrosoft.com/oauth2/v2.0/logout?p=B2C_1_login&post_logout_redirect_uri');
+ });
+
+});
\ No newline at end of file
diff --git a/webapp/app/config.js b/webapp/app/config.js
index 8e7c3846..73cbbff0 100644
--- a/webapp/app/config.js
+++ b/webapp/app/config.js
@@ -12,6 +12,9 @@ module.exports = {
// Force HTTP to redirect to HTTPS on production
USE_HTTPS: 'false',
+ // Feature Flag to use MSAL Node
+ USE_MSAL: false,
+
// Cookie warning - update link to service's cookie page.
COOKIE_TEXT:
'GOV.UK uses cookies to make the site simpler. Find out more about cookies',
diff --git a/webapp/jest.config.js b/webapp/jest.config.js
index be2d6b85..ca6d40c6 100644
--- a/webapp/jest.config.js
+++ b/webapp/jest.config.js
@@ -2,6 +2,7 @@ module.exports = {
setupFilesAfterEnv: [
'./test-mocks/jest.setup.redis-mock.js',
'./test-mocks/jest.setup.passport-mock.js',
- './test-mocks/jest.setup.passport-azure-ad.js'
+ './test-mocks/jest.setup.passport-azure-ad.js',
+ './test-mocks/jest.setup.auth-provider.js',
],
};
\ No newline at end of file
diff --git a/webapp/test-mocks/jest.setup.auth-provider.js b/webapp/test-mocks/jest.setup.auth-provider.js
new file mode 100644
index 00000000..47131726
--- /dev/null
+++ b/webapp/test-mocks/jest.setup.auth-provider.js
@@ -0,0 +1,15 @@
+process.env.B2C_CLIENT_ID = 'test-client-id';
+process.env.ENV_BASE_URL = 'http://localhost:3000';
+process.env.B2C_REDIRECT_URL = '/redirect';
+
+jest.mock('../api/auth/authProvider', () => {
+ return jest.fn(() => ({
+ login: jest.fn(() => (req, res, next) => {
+ req.session.isAuthenticated = true;
+ req.session.account = {
+ idTokenClaims: { oid: 1, username: 'testuser' }
+ };
+ next();
+ }),
+ }));
+});