From b4cac75336b700e5a96be167237d38f0171aaab9 Mon Sep 17 00:00:00 2001 From: Ahmad Vegah Date: Mon, 22 Dec 2025 11:40:51 +0000 Subject: [PATCH 1/3] feat: msal-node integration behind feature flag - setup mock and unit tests request/response and msal node - featureflag for msal node - implement MSAL Node to login, logout and acquire an access token for a protected resource --- webapp/api/auth/authConfig.js | 26 ++ webapp/api/auth/authProvider.js | 267 ++++++++++++++++++ webapp/api/auth/authProvider.test.js | 170 +++++++++++ webapp/api/routes/index.js | 10 +- webapp/api/routes/portal/msal-node/login.js | 38 +++ .../api/routes/portal/msal-node/login.test.js | 42 +++ webapp/app/config.js | 3 + webapp/jest.config.js | 3 +- webapp/test-mocks/jest.setup.auth-provider.js | 15 + 9 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 webapp/api/auth/authConfig.js create mode 100644 webapp/api/auth/authProvider.js create mode 100644 webapp/api/auth/authProvider.test.js create mode 100644 webapp/api/routes/portal/msal-node/login.js create mode 100644 webapp/api/routes/portal/msal-node/login.test.js create mode 100644 webapp/test-mocks/jest.setup.auth-provider.js 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/msal-node/login.js b/webapp/api/routes/portal/msal-node/login.js new file mode 100644 index 00000000..4d889e55 --- /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(); + }), + })); +}); From 40d458a188983101daf9f05c5eb6a03f2ec5caf0 Mon Sep 17 00:00:00 2001 From: Ahmad Vegah Date: Mon, 22 Dec 2025 11:44:48 +0000 Subject: [PATCH 2/3] chore: renaming --- webapp/api/routes/portal/login.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(); From 361b3570d70c6f4f8f0746ac57bcb168b347a573 Mon Sep 17 00:00:00 2001 From: Ahmad Vegah Date: Mon, 22 Dec 2025 11:52:04 +0000 Subject: [PATCH 3/3] fix: remove invalid token --- webapp/api/routes/portal/msal-node/login.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/api/routes/portal/msal-node/login.js b/webapp/api/routes/portal/msal-node/login.js index 4d889e55..9c97d9c4 100644 --- a/webapp/api/routes/portal/msal-node/login.js +++ b/webapp/api/routes/portal/msal-node/login.js @@ -22,7 +22,7 @@ export default function (app) { })(req, res, next); }, function (req, res) { - if (!req.session?.isAuthenticated || !req.session?.account) { + 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); }