Search code examples
javascriptnode.jsazure-active-directoryazure-ad-b2cmsal.js

How to implement msal-node in a non-monolithic application design that utilises route and middleware modules?


I am creating a Node.js application with Azure AD B2C authentication.

The logic I want to implement is within this sample:

https://github.com/Azure-Samples/active-directory-b2c-msal-node-sign-in-sign-out-webapp/blob/main/call-protected-api/index.js

For those familiar with OAuth2 and Azure AD B2C:

The sample contains all Express route handlers and middleware functions in the application entry file, i.e: index.js.

I am able to get the sample application working, as is, with no modifications.

Rather than have all the logic in one file, however, I would like to split it out into separate route and middleware modules.

I have done this before in other applications, so I understand how to create that structure and functionality.

But I am having difficulty answering these questions:

  • How I can move the 'global' msal-node values out of index.js and into their own modules?
  • How I can make these 'global' values accessible to other modules (the middleware)?

The specific values that need to be available across modules are anything that is referenced by a middleware function, i.e:

  1. const confidentialClientConfig

  2. const confidentialClientApplication

  3. const apiConfig

  4. const APP_STATES

  5. const authCodeRequest and const tokenRequest

  6. const sessionConfig

  7. app.use(session(sessionConfig))

  8. const getAuthCode

Is it just a matter of:

  • Creating a module for each of the 8 values above?

  • Importing them into whatever module references them?

At a high level, my doubts about the above approach relate to:

  • If I import a module with a 'global' value into multiple other modules, will that create some sort of duplication?

  • Will it work? I.e: should some values only be defined once, and not imported into multiple modules? E.g:

const confidentialClientApplication = new msal.ConfidentialClientApplication(confidentialClientConfig);  

In case it is of any help in answering the question, below is how my app will be structured eventually:

Application

config
dist
middleware
node_modules
routes
src
view
.gitignore
app.js
babel.config.json
package.json
webpack.config.json 

app.js

// various imports here  

const routes = require('./routes').routes; // route handlers are defined in routes/index.js 

app.use(express.static('dist')); // this is just to specify the path for js, css, images etc   

app.use('/', routes);   

// template engine logic here  

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});

routes/index.js

import express from 'express';
const router = express.Router();

// routes
import { route01 } from './route01';
import { route02 } from './route02;
import { route03 } from './route03';
import { route04 } from './route04';

/*

data flow:

hit > app.js > app.use('/', routes) > routes/index.js > relevant route > relevant middleware > response  

*/

router.use('/', route01);
router.use('/route02', route02); 
router.use('/route03', route03); 
router.use('/route04', route04); 

export { router as routes }; 

routes/route01

import express from 'express';
const router = express.Router();

import { route01_middleware } from '../middleware/route01_middleware';

router.route('/route01').get(route01_middleware);

export { router as route01 };

middleware/route01_middleware.js

const route01_middleware = async (req, res) => {

    /* 

    this could include references to:
      - req.session.accessToken
      - APP_STATES
      - confidentialClientApplication.acquireTokenByCode(tokenRequest).then((response)=>{ ... })  
      - getAuthCode(process.env.SIGN_UP_SIGN_IN_POLICY_AUTHORITY, apiConfig.webApiScopes, APP_STATES.LOGIN, res);
      - etc 
      
    */ 

};

export { route01_middleware };

Solution

  • After testing I can confirm that:

    • Yes, you can put all of the msal-node 'global' values in their own files
    • Use import statements to import dependencies in these files
    • Use export statements to make these values available in other files

    (In regards to my concern about code being 'duplicated' when importing the same module into multiple other modules, there seems to be some sort of module caching that occurs by default in JavaScript, so I think I don't need to worry about that).

    After verifying the above, however, I decided to:

    • Put all the msal-node 'global values' in one file called azureAuthConfig.js
    • Export the values so they are available to the middleware files
    • Import the values into the middleware files

    I also decided to use the ES Module system rather than CommonJS Module system so that I could use import/export statements and not require(). So I added type: module to my application's package.json file. And I stopped using babel to convert my server side code (as ES Modules 'just work' now).

    In the end, my 'non-monolithic' version of the sample code ended up looking something like this:

    configuration/azureAuthConfig.js

    These are all the 'global values' that were originally in the sample application's entry file.

    I have put them in this separate file and export/import them where required.

    import * as dotenv from 'dotenv';
    dotenv.config(); 
    import * as msal from "@azure/msal-node";
    
    const confidentialClientConfig = {
        auth: {
            clientId: process.env.APP_CLIENT_ID, 
            authority: process.env.SIGN_UP_SIGN_IN_POLICY_AUTHORITY, 
            clientSecret: process.env.APP_CLIENT_SECRET,
            knownAuthorities: [process.env.AUTHORITY_DOMAIN], //This must be an array
            redirectUri: process.env.APP_REDIRECT_URI,
            validateAuthority: false
        },
        system: {
            loggerOptions: {
                loggerCallback(loglevel, message, containsPii) {
                    console.log(message);
                },
                piiLoggingEnabled: false,
                logLevel: msal.LogLevel.Verbose,
            }
        }
    };
    
    const confidentialClientApplication = new msal.ConfidentialClientApplication(confidentialClientConfig);
    
    const apiConfig = {
        webApiScopes: [`https://${process.env.TENANT_NAME}.onmicrosoft.com/my-api-uri-thing/tasks.read`, `https://${process.env.TENANT_NAME}.onmicrosoft.com/my-api-uri-thing/tasks.write`],
        anonymousUri: 'http://localhost:3000/public',
        protectedUri: 'http://localhost:3000/hello'
    };
    
    const APP_STATES = {
        LOGIN: 'login',
        LOGOUT: 'logout',
        CALL_API:'call_api',  
        PASSWORD_RESET: 'password_reset',
        EDIT_PROFILE : 'update_profile'
    }
    
    const authCodeRequest = {
        redirectUri: confidentialClientConfig.auth.redirectUri,
    };
    
    const tokenRequest = {
        redirectUri: confidentialClientConfig.auth.redirectUri,
    };
    
    const sessionConfig = {
        secret: process.env.SESSION_SECRET,
        resave: false,
        saveUninitialized: false,
        cookie: {
            secure: false, // set this to true on production
        }
    }
    
    const getAuthCode = (authority, scopes, state, res) => {
    
        // prepare the request
        console.log(`Fetching Authorization code`); 
        authCodeRequest.authority = authority;
        authCodeRequest.scopes = scopes;
        authCodeRequest.state = state;
    
        //Each time you fetch Authorization code, update the relevant authority in the tokenRequest configuration
        tokenRequest.authority = authority;
    
        // request an authorization code to exchange for a token
        return confidentialClientApplication.getAuthCodeUrl(authCodeRequest)
            .then((response) => {
                console.log(`\n\ngetAuthCodeUrl response (aka AuthCodeURL):\n${response}\n\n`);
                //redirect to the auth code URL/send code to 
                res.redirect(response);
            })
            .catch((error) => {
                res.status(500).send(error);
            });
    }
    
    export { confidentialClientApplication, apiConfig, APP_STATES, authCodeRequest, tokenRequest, sessionConfig, getAuthCode };
    

    app.js

    import * as dotenv from 'dotenv';
    dotenv.config(); 
    const port = process.env.PORT || 3000; 
    import express from 'express'; 
    const app = express();
    import * as fs from 'fs';
    import session from 'express-session'; 
    import { sessionConfig } from './configuration/azureAuthConfig.js';
    import { routes } from './routes/index.js'; 
    
    app.use(express.static('dist'));
    app.use(session(sessionConfig));
    app.use('/', routes);
    
    // my template logic  
    
    app.listen(port, () => {
        console.log(`Example app listening at http://localhost:${port}`);
    });
    

    routes/index.js

    import express from 'express';
    const router = express.Router();
    
    // routes
    import { pagesRoute } from './pagesRoute.js';
    import { signinRoute } from './signinRoute.js';
    import { signoutRoute } from './signoutRoute.js';
    import { redirectRoute } from './redirectRoute.js';
    import { passwordRoute } from './passwordRoute.js';
    import { profileRoute } from './profileRoute.js';
    import { apiRoute } from './apiRoute.js';
    import { helloRoute } from './helloRoute.js';
    import { publicRoute } from './publicRoute.js';
    /*
    
    data flow:
    
    hit > app.js > app.use() middleware > routes/index.js > relevant route > relevant middleware > response  
    
    */
    
    router.use('/signin', signinRoute); 
    router.use('/', pagesRoute);  
    router.use('/signout', signoutRoute); 
    router.use('/redirect', redirectRoute); 
    router.use('/password', passwordRoute); 
    router.use('/profile', profileRoute); 
    router.use('/api', apiRoute); 
    router.use('/hello', helloRoute); 
    router.use('/public', publicRoute); 
    
    export { router as routes };  
    

    routes/pagesRoute.js

    import express from 'express';
    const router = express.Router();
    
    import { api_pages_get } from '../middleware/pages/api_pages_get.js';
    
    router.route('/').get(api_pages_get);
    
    export { router as pagesRoute };
    

    middleware/pages/api_pages_get.js

    // this is just some html stored in variables for testing functionality   
    import { launch_page_html, signed_in_page_html } from '../auth/html.js';  
    
    const api_pages_get = async (req, res) => {
    
        if(!req.session.accessToken){
            //User is not logged in
            try {
                console.log("\n\n############ THE USER IS NOT LOGGED IN ############\n\n"); 
                res.redirect("/signin");
            } catch (error) {
                console.error(error);
                res.status(500).send(error);
            }         
        }else{
            console.log(`\n\n############ THE USER IS LOGGED IN ############\n\n`); 
            //Users have the accessToken because they signed in and the accessToken is still in the session
            console.log(`\n\nreq.session.accessToken:\n${req.session.accessToken}\n\n`);
    
            try {
                res.render('index', { page_html: signed_in_page_html });
            } catch (error) {
                console.error(error);
                res.status(500).send(error);
            }
        }  
    
    };
    
    export { api_pages_get };