Search code examples
node.jsazureexpressopenid-connect

Node.js/Express configuration for Azure OIDC not returning requested refresh token


I have been tasked to migrate our existing application to use Azure OIDC for authentication. I have the basic login flow working with the code below as a test, but the redirect once authentication completes is not sending a refresh token, even though - according to everything I've found to configure - it should be.

const config = require("config");
const msal = require('@azure/msal-node');
const axios = require("axios");

const ACCOUNTS_COOKIE_NAME = 'test-account';
const CREDENTIALS_COOKIE_NAME = 'test-credentials';
const TEST_GROUPS_COOKIE_NAME = 'test-groups';

const COOKIE_OPTIONS = {
  path: '/',
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  ...config.get('cookies')
}

const MSAL_CONFIG = {
  auth: {
    clientId: config.get("azure.auth.clientId"),
    authority: `https://login.microsoftonline.com/${config.get("azure.auth.tenantId")}/v2.0`,
    clientSecret: config.get("azure.auth.clientSecret")
  },
  system: {
    loggerOptions: {
      loggerCallback: (level, message, containsPii) => {
        if (!containsPii) {
          console.log(message);
        }
      },
      piiLoggingEnabled: false,
      logLevel: msal.LogLevel.Verbose,
    }
  }
}

const REDIRECT_URI = config.get("azure.redirectUri");
const GRAPH_ME_ENDPOINT = 'https://graph.microsoft.com/v1.0/me';

const generateCredentialsCookiePayload = (tokenResponse) => {
  const { tokenType, accessToken, refreshToken, account } = tokenResponse;
  return {
    account_id: account.localAccountId,
    token_type: tokenType,
    access_token: accessToken,
    refresh_token: refreshToken,
    expires: account.idTokenClaims.exp * 1000
  }
}

const generateAccountCookiePayload = (account) => {
  const { homeAccountId, tenantId, localAccountId, username, name } = account;
  return {
    homeAccountId,
    tenantId,
    localAccountId,
    username,
    name
  }
}

module.exports = (router) => {
  const msalClient = new msal.ConfidentialClientApplication(MSAL_CONFIG);

  router.get('/auth/login', (req, res) => {
    const authCodeUrlParams = {
      scopes: [ 'https://graph.microsoft.com/.default', 'offline_access' ],
      redirectUri: REDIRECT_URI,
    };

    msalClient.getAuthCodeUrl(authCodeUrlParams)
      .then((authCodeUrl) => {
        res.redirect(authCodeUrl);
      })
      .catch((error) => {
        console.error(error);
        res.status(500).send('Error during authentication');
      });
  });

  router.get('/auth/acquireToken', (req, res, next) => {

    // TODO: Update this to use a refresh token to get a new access token
    return res.redirect('/auth/login');
  });

  router.get('/auth/redirect', (req, res) => {
    console.log(`req.query is`, req.query);

    const tokenRequest = {
      code: req.query.code,
      scopes: [ 'https://graph.microsoft.com/.default', 'offline_access' ],
      redirectUri: REDIRECT_URI,
    };

    msalClient.acquireTokenByCode(tokenRequest)
      .then((response) => {
        console.log(response);

        console.log(`token response is ${JSON.stringify(response)}`);

        res.cookie(CREDENTIALS_COOKIE_NAME, generateCredentialsCookiePayload(response), COOKIE_OPTIONS);
        res.cookie(ACCOUNTS_COOKIE_NAME, generateAccountCookiePayload(response.account), COOKIE_OPTIONS);
        res.cookie(TEST_GROUPS_COOKIE_NAME, response.account.idTokenClaims.groups, COOKIE_OPTIONS);

        res.redirect('/testpage');     // TODO: update this
      })
      .catch((error) => {
        console.error(error);
        res.status(500).send('Error acquiring token');
      });
  });

  router.get('/auth/logout', (req, res) => {
    res.clearCookie(CREDENTIALS_COOKIE_NAME, { path: COOKIE_OPTIONS.path });
    res.clearCookie(ACCOUNTS_COOKIE_NAME, { path: COOKIE_OPTIONS.path });
    res.clearCookie(TEST_GROUPS_COOKIE_NAME, { path: COOKIE_OPTIONS.path });
    res.redirect('/');
  });

  router.get('/auth/me', (req, res, next) => {
    const authorization = req.header('authorization');
    const token = authorization.split(' ')[1];
    const endpoint = `${GRAPH_ME_ENDPOINT}?$select=id,employeeId,userPrincipalName,displayName,givenName,surname,jobTitle,mail,officeLocation,businessPhones,mobilePhone`;

    axios.get(endpoint, { headers: { authorization: `Bearer ${token}` }})
      .then((response) => {
        res.json(response.data);
      })
      .catch((error) => {
        next(error);
      });
  });

  return router;
};

I notice that in the token response payload, the scopes listed do not include offline_access.

I've also requested our configuration from the https://login.microsoftonline.com/TENANT_ID/v2.0/.well-known/openid-configuration endpoint, and it shows that the offline_access scope is supported.

What am I missing here?


Solution

  • Note: The refresh token is not directly exposed in the response. Instead, it is stored in the MSAL token cache, which keeps track of the tokens for future use. MSAL manages the cache internally, and you can retrieve it when needed using the getTokenCache() method.

    • Serialize the token cache to retrieve the refresh token: After the initial successful token acquisition, the refresh token can be retrieved by serializing the token cache.

    To fetch the Access, ID and refresh tokens and call the User Info Endpoint modify the code like below:

    const express = require('express');
    const msal = require('@azure/msal-node');
    const axios = require('axios'); 
    const cookieParser = require('cookie-parser');
    
    // Constants for cookies
    const COOKIE_OPTIONS = {
      path: '/',
      httpOnly: true,
      secure: false, // Set to false for local dev over HTTP
      sameSite: 'strict',
    };
    
    const app = express();
    const port = 3000;
    
    // MSAL Config
    const msalConfig = {
      auth: {
        clientId: "ClientID", 
        authority: "https://login.microsoftonline.com/TenantID", 
        redirectUri: "http://localhost:3000/auth/redirect",
      },
      system: {
        loggerOptions: {
          loggerCallback: (level, message, containsPii) => {
            if (!containsPii) {
              console.log(message);
            }
          },
          logLevel: msal.LogLevel.Verbose,
        },
        cache: {
          cacheLocation: 'memoryStorage', // Use in-memory cache for Node.js
          storeAuthStateInCookie: true, // Store the auth state in cookies for persistence
        }
      }
    };
    
    // MSAL instance
    const msalClient = new msal.PublicClientApplication(msalConfig);
    
    // Root route
    app.get('/', (req, res) => {
      res.send('<h1>Welcome! <a href="/auth/login">Login with Microsoft</a></h1>');
    });
    
    // Login route to initiate MSAL authentication
    app.get('/auth/login', (req, res) => {
      const authCodeUrlParams = {
        scopes: ['user.read', 'offline_access'], // Include offline_access to get refresh token
        redirectUri: msalConfig.auth.redirectUri,
      };
    
      msalClient.getAuthCodeUrl(authCodeUrlParams)
        .then(authCodeUrl => {
          res.redirect(authCodeUrl); // Redirect the user to the Microsoft login page
        })
        .catch(error => {
          console.error('Error while getting auth code URL:', error);
          res.status(500).send('Error while redirecting to Microsoft login');
        });
    });
    
    // Redirect route after successful login
    app.get('/auth/redirect', (req, res) => {
      const tokenRequest = {
        code: req.query.code, // Code received in the redirect
        scopes: ['user.read', 'offline_access'], // Include offline_access to get refresh token
        redirectUri: msalConfig.auth.redirectUri,
        accessType: 'offline', // Ensure offline access to get the refresh token
      };
    
      msalClient.acquireTokenByCode(tokenRequest)
        .then(response => {
          console.log("Access Token:", response.accessToken);  // Log access token
          console.log("ID Token:", response.idToken);  // Log ID token
    
          // Extract the refresh token from the token cache synchronously
          try {
            const tokenCache = msalClient.getTokenCache().serialize(); // Synchronously serialize token cache
            const parsedCache = JSON.parse(tokenCache);
            const refreshTokenObject = parsedCache.RefreshToken;
    
            const refreshToken = refreshTokenObject
              ? refreshTokenObject[Object.keys(refreshTokenObject)[0]].secret
              : 'No refresh token found';
    
            // Store tokens in cookies
            res.cookie('access_token', response.accessToken, COOKIE_OPTIONS);
            res.cookie('id_token', response.idToken, COOKIE_OPTIONS);
            res.cookie('refresh_token', refreshToken, COOKIE_OPTIONS);
    
            // Call Microsoft Graph API to get user info
            const GRAPH_ME_ENDPOINT = 'https://graph.microsoft.com/v1.0/me';
    
            axios.get(GRAPH_ME_ENDPOINT, {
              headers: {
                Authorization: `Bearer ${response.accessToken}`, // Set the access token in Authorization header
              }
            })
            .then(graphResponse => {
              // Handle successful response from Graph API
              console.log('User Info:', graphResponse.data);
              res.send(`
                <h1>Logged in successfully!</h1>
                <p>Access Token: ${response.accessToken}</p>
                <p>ID Token: ${response.idToken}</p>
                <p>Refresh Token: ${refreshToken}</p>
                <h2>Graph API User Info:</h2>
                <pre>${JSON.stringify(graphResponse.data, null, 2)}</pre>
              `);
            })
            .catch(error => {
              console.error('Error fetching user data from Graph API:', error);
              res.status(500).send('Error fetching user data from Microsoft Graph API');
            });
          } catch (error) {
            console.error('Error while serializing token cache:', error);
            res.status(500).send('Error while retrieving refresh token');
          }
        })
        .catch(error => {
          console.error('Error while acquiring token:', error);
          res.status(500).send('Error while acquiring token');
        });
    });
    
    // Logout route to clear cookies
    app.get('/auth/logout', (req, res) => {
      res.clearCookie('access_token', { path: COOKIE_OPTIONS.path });
      res.clearCookie('id_token', { path: COOKIE_OPTIONS.path });
      res.clearCookie('refresh_token', { path: COOKIE_OPTIONS.path });
      res.redirect('/');
    });
    
    // Start the server
    app.listen(port, () => {
      console.log(`Server running at http://localhost:${port}`);
    });
    

    enter image description here

    enter image description here

    When clicked on Login with Microsoft, Access, ID and refresh tokens generated and also called Microsoft Graph API Endpoint:

    enter image description here

    API permissions granted to the Microsoft Entra ID application:

    enter image description here

    Reference:

    node.js - Handling refresh tokens in Azure (Microsoft graph) delegation flow - Stack Overflow by me