Auth0 Account Linking Extension Page Mismatch

Hi Community,

We are running into 400 Bad Request errors when redirecting to the Account Linking Extension. It appears to be a Page Mismatch error in the redirect URL - the extension displays the error “You seem to have reached this page in error. Please try logging in again”.

We recently changed one of our tenants to a custom domain, and we think this might be the cause of the issue, but we are not too sure about which secrets should be changed in the Action to support this change.

So far, I have tried changing the config.token.issuer to the new custom domain, as well as changing the config.endpoints urls to be based off of the new custom domain, to no avail. I noticed that our Auth0 Management API Identifier, as well as our Account Linking Application Domain are still based off of the old default domain. The Account Linking webtask redirect url also begins with the old domain name. Am I supposed to recreate these based off of the new domain?

Also, is the Auth0 Account Linking Extension set to be deprecated? We are wondering if it’s wise to fix the current account linking solution, or if we should plan to build our own.

const request = require("request");
const queryString = require("querystring");
const Promise = require("any-promise");
const jwt = require("jsonwebtoken");
const axios = require("axios");

/**
* Handler that will be called during the execution of a PostLogin flow.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to fchange the behavior of the login.
*/
exports.onContinuePostLogin = async (event, api) => {
    api.user.setAppMetadata('accountLinkProcessing', false);
}

exports.onExecutePostLogin = async (event, api) => {
    var LOG_TAG = '[ACCOUNT_LINK]: ';
    var CONTINUE_PROTOCOL = 'redirect-callback';
    var Auth0ManagementAccessToken = '';

    console.log(LOG_TAG, 'Entered Account Link Action');

    var config = {
        endpoints: {
            linking: event.secrets.ACCOUNT_LINK_URL,
            userApi: event.secrets.AUTH0_BASE_URL + '/users',
            usersByEmailApi: event.secrets.AUTH0_BASE_URL + '/users-by-email'
        },
        token: {
            clientId: event.secrets.M2M_CLIENT_ID,
            clientSecret: event.secrets.M2M_CLIENT_SECRET,
            // Production tenant uses custom domain.
            // Use default for staging and development.
            issuer: event.secrets.AUTH0_DOMAIN
        }
    }; 

    flagAsNotProcessing();

    // // If the user does not have an e-mail account,
    // // just continue the authentication flow.
    // // See auth0-extensions/auth0-account-link-extension#33
    if (event.user.email === undefined) {
        console.log(LOG_TAG, 'Account Link Action: No event.user.email');
        return;
    }

    await createStrategy().then(callbackWithSuccess).catch(callbackWithFailure);

    async function createStrategy() {
        if (shouldLink()) {
            await setManagementAccessToken(true)
            return linkAccounts();
        } else if (shouldPrompt()) {
            await setManagementAccessToken(false)
            return promptUser();
        }

        return continueAuth();

        function shouldLink() {
            return !!event.request.query.link_account_token;
        }

        function shouldPrompt() {
            // user has not decided to create new account
            // should not prompt if user has already attempted to register
            return !insideRedirect() && !redirectingToContinue() && firstLogin();

            // Check if we're inside a redirect
            // in order to avoid a redirect loop
            // TODO: May no longer be necessary
            function insideRedirect() {
                return event.request.query.redirect_uri &&
                    event.request.query.redirect_uri.indexOf(config.endpoints.linking) !== -1;
            }

            function firstLogin() {
                return event.stats.logins_count <= 1;
            }


            // Check if we're coming back from a redirect
            // in order to avoid a redirect loop. User will
            // be sent to /continue at this point. We need
            // to assign them to their primary user if so.
            function redirectingToContinue() {
                const userSelectedContinue = event.transaction?.protocol === CONTINUE_PROTOCOL

                // set flag that the user has selected to create a new account with same email
                if (userSelectedContinue) {
                    api.user.setAppMetadata('selectedCreateNewAccount', true);
                }
                return userSelectedContinue;
            }
        }
    }

    function flagAsProcessing() {
        api.user.setAppMetadata('accountLinkProcessing', true);
        console.log(LOG_TAG, 'Account link processing flag enabled.');
    }

    function flagAsNotProcessing() {
        api.user.setAppMetadata('accountLinkProcessing', false);
        console.log(LOG_TAG, 'Account link processing flag disabled.');
    }

    async function setManagementAccessToken(shouldLink) {
        if (Auth0ManagementAccessToken !== '') {
            return;
        }

        var options = {
            method: 'POST',
            url: `${event.secrets.MGMT_API_BASE_URL}/oauth/token`,
            headers: {'content-type': 'application/x-www-form-urlencoded'},
            data: new URLSearchParams({
                grant_type: 'client_credentials',
                client_id: event.secrets.M2M_CLIENT_ID,
                client_secret: event.secrets.M2M_CLIENT_SECRET,
                audience: event.secrets.AUTH0_BASE_URL + "/"
            })
        };
        try {
            const response = await axios.request(options);
            Auth0ManagementAccessToken = response.data.access_token;
        } catch (error) {
            console.error(LOG_TAG, "axios error:" + error + " for options: " + JSON.stringify(options, null, 2));
            return;
        }
    }

    function verifyToken(token, secret) {
        return new Promise(function(resolve, reject) {
            jwt.verify(token, secret, function(err, decoded) {
                if (err) {
                    console.error(LOG_TAG, `verifyToken error: ${err}`);
                    return reject(err);
                }

                return resolve(decoded);
            });
        });
    }

    function linkAccounts() {
        flagAsProcessing();

        var secondAccountToken = event.request.query.link_account_token;

        return verifyToken(secondAccountToken, config.token.clientSecret)
            .then(function(decodedToken) {
                // Redirect early if tokens are mismatched
                if (event.user.email !== decodedToken.email) {
                    console.error(LOG_TAG, 'User: ', decodedToken.email, 'tried to link to account ', event.user.email);
                    event.user.redirect = {
                        url: buildRedirectUrl(secondAccountToken, event.request.query, 'accountMismatch')
                    };

                    return event.user;
                }

                var headers = {
                    Authorization: 'Bearer ' + Auth0ManagementAccessToken,
                    'Content-Type': 'application/json',
                    'Cache-Control': 'no-cache'
                };

                return apiCall({
                    method: 'GET',
                    url: config.endpoints.userApi+'/'+decodedToken.sub+'?fields=identities',
                    headers: headers
                })
                    .then(function(secondaryUser) {
                        var provider = secondaryUser &&
                            secondaryUser.identities &&
                            secondaryUser.identities[0] &&
                            secondaryUser.identities[0].provider;

                        var linkUri = config.endpoints.userApi+'/'+event.user.user_id+'/identities';

                        return apiCall({
                            method: 'POST',
                            url: linkUri,
                            headers,
                            json: { user_id: decodedToken.sub, provider: provider }
                        });
                    })
                    .then(function(_) {
                        console.info(LOG_TAG, 'Successfully linked accounts for user: ', event.user.email);
                        return _;
                    });
            });
    }

    function continueAuth() {
        return Promise.resolve();
    }

    function promptUser() {
        return searchUsersWithSameEmail().then(function transformUsers(users) {
            return users.filter(function(u) {
                return u.user_id !== event.user.user_id;
            }).map(function(user) {
                return {
                    userId: user.user_id,
                    email: user.email,
                    picture: user.picture,
                    connections: user.identities.map(function(identity) {
                        return identity.connection;
                    })
                };
            });
        }).then(function redirectToExtension(targetUsers) {
            if (targetUsers.length > 0) {
                flagAsProcessing();
                event.user.redirect = {
                    url: buildRedirectUrl(createToken(config.token), event.request.query)
                };
            }
        });
    }

    function callbackWithSuccess() {
        if (api.redirect.canRedirect() && event.user.redirect) {
            api.redirect.sendUserTo(event.user.redirect.url);
        }
        return;
    }

    function callbackWithFailure(err) {
        console.error(LOG_TAG, err.message, err.stack);
        api.access.deny(err.message);
    }

    function createToken(tokenInfo, targetUsers) {
        var options = {
            expiresIn: '5m',
            audience: tokenInfo.clientId,
            issuer: qualifyDomain(tokenInfo.issuer)
        };

        var userSub = {
            sub: event.user.user_id,
            email: event.user.email,
            base: event.secrets.AUTH0_BASE_URL
        };

        return jwt.sign(userSub, tokenInfo.clientSecret, options);
    }


    function searchUsersWithSameEmail() {
        return apiCall({
            url: config.endpoints.usersByEmailApi,
            qs: {
                email: event.user.email
            }
        });
    }

    // Consider moving this logic out of the rule and into the extension
    function buildRedirectUrl(token, q, errorType) {
        var params = {
            child_token: token,
            audience: q.audience,
            client_id: q.client_id,
            redirect_uri: q.redirect_uri,
            scope: q.scope,
            response_type: q.response_type,
            response_mode: q.response_mode,
            auth0Client: q.auth0Client,
            original_state: q.original_state || q.state,
            nonce: q.nonce,
            error_type: errorType
        };

        return config.endpoints.linking + '?' + queryString.encode(params);
    }

    function qualifyDomain(domain) {
        return 'https://'+domain+'/';
    }

    function apiCall(options) {
        return new Promise(function(resolve, reject) {
            var reqOptions = Object.assign({
                url: options.url,
                headers: {
                    Authorization: 'Bearer ' + Auth0ManagementAccessToken,
                    Accept: 'application/json'
                },
                json: true
            }, options);

            request(reqOptions, function handleResponse(err, response, body) {
                if (err) {
                    reject(err);
                } else if (response.statusCode < 200 || response.statusCode >= 300) {
                    console.error(LOG_TAG, 'API call failed: ', body);
                    reject(new Error(body));
                } else {
                    resolve(response.body);
                }
            });
        });
    }
};

Thanks,
Eric