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?
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.
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}`);
});
When clicked on Login with Microsoft, Access, ID and refresh tokens generated and also called Microsoft Graph API Endpoint:
API permissions granted to the Microsoft Entra ID application:
Reference:
node.js - Handling refresh tokens in Azure (Microsoft graph) delegation flow - Stack Overflow by me