node.jsazureoauth-2.0azure-active-directoryazure-ad-msal

MSAL Node service & The Partner Center API


tl;dr

Using msal-node in a non-interactive service, how do I authorize as an MFA-enabled user once and never (or very rarely) have to authorize as that user again? Particularly when making requests to the Partner Center API.

longer version

I've been trying to create a backend service (i.e. a daemon) in NodeJS that interacts with the Microsoft Partner Center API. However, I can't find an officially supported way to do this without constant user interaction.

Based on most documentation I could find, services without any user interaction are meant to use the "client credentials" flow. This works great for other Azure APIs, but doesn't seem to work in the Partner Center API despite what the docs say.

What does work, and what I'm forced to do, is login as a user instead. However, because all users are MFA-enabled, I have to follow the approach outlined in these docs where I manually login as a user and do a one-time exchange for a refresh token. I then save this to a file and the daemon can then continually exchange it for both an access token as well as an updated refresh token.

But it doesn't look like msal-node supports using just the refresh token out-of-the-box. The only instance method that accepts one as a parameter is .acquireTokenByRefreshToken() which does return an access token (see this sample) but does not update the instance's internal cache or return a new refresh token, so the existing refresh token would expire prematurely. It also seems to be deprecated in favor of .acquireTokenSilent(), but that method requires the instance's internal cache to already be populated with authentication data. However, I don't see any public methods to manually set the internal cache's data, and it's only set automatically after calling methods like .acquireTokenByCode() which accept short-lived tokens generated after user login. And again, since all users are MFA-enabled, that would require user interaction every time the service restarted.

So a bit of a chicken-or-the-egg problem. Any help is appreciated, I've been going nuts reading this much MS documentation and code.


Solution

  • After posting this question, I figured out how to do this in msal-node natively. By default msal-node only caches tokens in memory, which isn't great for non-interactive services that inevitably restart.

    Luckily the config parameter of a msal-node client app has an optional cache.cachePlugin property to define your own cache getter/setter. As this documentation shows (ignore the "ADAL" part), you can use this to fetch/store tokens on the disk. With this you can now just call acquireTokenSilent anytime you need a new access token, and it'll automatically retrieve and/or refresh one for you, even if your service restarts. Here's an example module with a function to get an access token for the cached user account, and a function to cache a user account in the first place:

    auth.js

    const fs = require('fs');
    const msal = require('@azure/msal-node');
    
    const CLIENT_ID = "MY-CLIENT-ID";
    const CLIENT_SECRET = "MY-CLIENT-SECRET";
    const CACHE_PATH = "/PATH/TO/CACHE/FILE";
    const SCOPES = ["MY-REQUEST-SCOPE(S)"]; // e.g. "https://api.partnercenter.microsoft.com/.default", "https://graph.microsoft.com/.default", etc.
    
    const CCA = new msal.ConfidentialClientApplication({
        auth: { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET },
        cache: {
            cachePlugin: {
                // Read the disk for tokens
                beforeCacheAccess: async (context) => context.tokenCache.deserialize(await fs.promises.readFile(CACHE_PATH, 'utf-8')),
                // Write the token back to the disk if it was refreshed
                afterCacheAccess: (context) => context.cacheHasChanged ? fs.promises.writeFile(CACHE_PATH, context.tokenCache.serialize()) : null
            }
        }
    });
    
    
    /**
     * Add the account that generated the authCode to the disk cache. 
     * @param {String} authCode - The code in the query of the MS redirect after the successful one-time user login
     * @param {String} redirectUri - The URL that MS redirects to after the one-time user authentication
     */
    exports.addAccountToCache = async (authCode, redirectUri) => {
        await CCA.acquireTokenByCode({
            redirectUri: redirectUri,
            scopes: ["user.read"],
            code: authCode
        });
    };
    
    
    var lastTokenRes;
    /**
     * Gets a new or existing access token for a user account, and automatically handles refreshing when needed. 
     */
    exports.getAccessToken = async () => {
        // Checking token expiration ourselves may seem redundant since calling .acquireTokenSilent() will do that on its own
        // anyway, but each call to it takes at least 300ms regardless of whether or not a valid token is in the cache
        if (!lastTokenRes || lastTokenRes.expiresOn >= Date.now()) {
            let firstAccount = await CCA.getTokenCache().getAllAccounts()[0];
            lastTokenRes = await CCA.acquireTokenSilent({
                account: firstAccount,
                scopes: SCOPES
            });
        }
    
        return lastTokenRes.accessToken;
    }
    

    Caching a user account requires calling addAccountToCache with an authorization code for that user. Said code is generated via the authorization-code-flow described here, and should only need to be done once so long as the service calls getAccessToken at least once every 90 days. For redundancy in a production setting, I'd also create an express route to sign-in a user account and generate their auth codes like in this sample app, but manually works too since you can just pass around the generated cache file as needed.