Bug in Authorization extension 'Add User to Groups' API (not always working correctly in rule)

Hello,

I created a rule as pasted below. This rule is enabled and runs before the default auth0-authorization-extension rule.

When a new user is signing in with a social account, the rule will look for an existing ‘username-password’ account with the same email address (which had already been created and assigned with groups & roles in the Authorization Extension); and then it will copy the ‘groups’ and ‘roles’ to the current (social) user account; and in the end, delete the original ‘username-password’ account.

Most things are already working fine with this rule. However, I found that from time to time, the “Add User to Groups” API call would only partially succeed (having returned HTTP 204 nevertheless). By ‘partial success’, I mean that when trying to add the user to multiple, only some groups would be added and the other not.

Can someone please help check why this is happening? I can clearly see the HTTP 204 success response for the requests in the rule debug logs like:

[23:16:50.021Z] INFO wt: 170920/231647.304, [response] socket:/data/io/port.sock: patch /api/users/google-apps%7Cxxxxx@xxxxx.com/groups {} 204 (2566ms)

However, sometimes it just adds some of all the groups I provided in request body.

The entire rule is pasted below. EXTENSION_URL, RULE_CLIENT_ID and RULE_CLIENT_SECRET need to be populated with correct values for the rule to run. And the non-interactive api-client needs to be configured with enough scopes under ‘auth0-authorization-extension-api’ and ‘delete:users’ under ‘Auth0 Management API’.

function(user, context, callback) {
  var audience = '';

  audience = audience || (context.request && context.request.query && context.request.query.audience);
  if (audience === 'urn:auth0-authz-api') {
    return callback(new UnauthorizedError('no_end_users'));
  }

  audience = audience || (context.request && context.request.body && context.request.body.audience);
  if (audience === 'urn:auth0-authz-api') {
    return callback(new UnauthorizedError('no_end_users'));
  }

  // Modify these based on tenant URL & client configuration:
  var EXTENSION_URL = 'https://[tenant-subdomain].webtask.io/[authorization-extension-key]/api';
  var RULE_CLIENT_ID = '[api-client-id]';
  var RULE_CLIENT_SECRET = '[api-client-secret]';

  var TENANT_ORIGIN = 'https://' + context.request.hostname;
  var CREDENTIAL_EXCHANGE_URL = TENANT_ORIGIN + '/oauth/token';
  var MANAGEMENT_URL = TENANT_ORIGIN + '/api/v2';
  var RULE_CACHE_KEY = 'auth0_merge_authz_rule';
  var Promise = require('promise');
  var request = require('request@2.56.0');
  var async = require('async@2.1.2');
  var jwt = require('jsonwebtoken');
  var _ = require('lodash');
  var userApiUrl = auth0.baseUrl + '/users';
  var baseApiClientRequestOpts = {
    json: true,
    timeout: 10000
  };

  function getCached(key) {
    var cachedValue = (global[RULE_CACHE_KEY] || {})[key];
    console.log('### returning cached value for key', key, ' => ', cachedValue);
    return cachedValue;
  }

  function cache(key, val) {
    var ruleCache = global[RULE_CACHE_KEY];
    if (!ruleCache) {
      ruleCache = global[RULE_CACHE_KEY] = {};
    }
    ruleCache[key] = val;
    // console.log('### caching global[RULE_CACHE_KEY] => ', global[RULE_CACHE_KEY]);// Uncomment for debugging
  }

  function getApiToken(audience) {
    var cachedToken = getCached(audience);
    if (cachedToken) {
      var decoded = jwt.decode(cachedToken);
      if ((decoded.exp - 60) * 1000 > Date.now()) {
        return Promise.resolve(cachedToken);
      }
    }
    var options = {
      json: true,
      url: CREDENTIAL_EXCHANGE_URL,
      body: {
        client_id: RULE_CLIENT_ID,
        client_secret: RULE_CLIENT_SECRET,
        audience: audience,
        grant_type: 'client_credentials'
      }
    };
    return new Promise(function(resolve, reject) {
      request.post(options, getResponseHandler(resolve, reject));
    }).then(function(data) {
      var accessToken = data.access_token;
      cache(audience, accessToken);
      return accessToken;
    });
  }

  function getAuthzApiToken() {
    return getApiToken('urn:auth0-authz-api');
  }

  function getManagementApiToken() {
    return getApiToken(MANAGEMENT_URL+ '/');// The management-api 'audience' string must have a trailing `/` (slash)
  }

  function getApiClientRequestOptions(url, accessToken, body) {
    return Object.assign({}, baseApiClientRequestOpts, {
      url: url,
      headers: {
        'Authorization': 'Bearer ' + accessToken
      },
      body: body
    });
  }

  function getResponseHandler(resolve, reject) {
    return function(error, res, data) {
      if (error) {
        return reject(error);
      }
      if ((res.statusCode + '').slice(0, 1) !== '2') {//If code is not 2xx
        return reject(res.body || res.statusCode);
      }
      return resolve(data);
    };
  }

  function deleteUser(accessToken, userId) {
    return new Promise(function(resolve, reject) {
      request.del(
        getApiClientRequestOptions([MANAGEMENT_URL, 'users', encodeURIComponent(userId)].join('/'), accessToken),
        getResponseHandler(resolve, reject)
      );
    });
  }

  function getUserGroups(accessToken, userId) {
    return new Promise(function(resolve, reject) {
      request.get(
        getApiClientRequestOptions([EXTENSION_URL, 'users', encodeURIComponent(userId), 'groups'].join('/'), accessToken),
        getResponseHandler(resolve, reject)
      );
    });
  }

  function getUserRoles(accessToken, userId) {
    return new Promise(function(resolve, reject) {
      request.get(
        getApiClientRequestOptions([EXTENSION_URL, 'users', encodeURIComponent(userId), 'roles'].join('/'), accessToken),
        getResponseHandler(resolve, reject)
      );
    });
  }

  function addUserToGroups(accessToken, userId, groupIds) {
    var url = [EXTENSION_URL, 'users', encodeURIComponent(userId), 'groups'].join('/');
    console.log('### CALLING addUserToGroups with url => ', url, ' and groupIds => ', groupIds);
    return new Promise(function(resolve, reject) {
      request.patch(
        getApiClientRequestOptions(url, accessToken, groupIds),
        getResponseHandler(resolve, reject)
      );
    });
  }

  function addUserToRoles(accessToken, userId, roleIds) {
    var url = [EXTENSION_URL, 'users', encodeURIComponent(userId), 'roles'].join('/');
    console.log('### CALLING addUserToRoles with url => ', url, ' and roleIds => ', roleIds);
    return new Promise(function(resolve, reject) {
      request.patch(
        getApiClientRequestOptions(url, accessToken, roleIds),
        getResponseHandler(resolve, reject)
      );
    });
  }

  function deleteGroupMembers(accessToken, groupId, userIds) {
    var url = [EXTENSION_URL, 'groups', groupId, 'members'].join('/');
    console.log('### CALLING deleteGroupMembers with url => ', url, ' and userIds => ', userIds);
    return new Promise(function(resolve, reject) {
      request.del(
        getApiClientRequestOptions(url, accessToken, userIds),
        getResponseHandler(resolve, reject)
      );
    });
  }

  function removeUserFromRoles(accessToken, userId, roleIds) {
    var url = [EXTENSION_URL, 'users', encodeURIComponent(userId), 'roles'].join('/');
    console.log('### CALLING removeUserFromRoles with url => ', url, ' and roleIds => ', roleIds);
    return new Promise(function(resolve, reject) {
      request.del(
        getApiClientRequestOptions(url, accessToken, roleIds),
        getResponseHandler(resolve, reject)
      );
    });
  }

  function proceedToNext() {
    return callback(undefined, user, context);
  }

  function getArrayMapper(field) {
    return function(arr) {
      return arr.map(function(item) {
        return item[field];
      });
    };
  }

  function getUsernamePasswordUser(user) {
    return new Promise(function(resolve, reject) {
      request({
        json: true,
        url: userApiUrl,
        headers: {
          Authorization: 'Bearer ' + auth0.accessToken
        },
        qs: {
          search_engine: 'v2',
          q: 'email.raw:"', user.email, '" -user_id:"', user.user_id, '"'].join('')
        }
      },
      function(error, res, data) {
        if (error) {
          return reject(error);
        }
        if (res.statusCode !== 200) {
          return reject(res.statusCode);
        }

        if (data.length > 0) {
          async.filter(data, function(targetUser, filterCb) {
            filterCb(null, targetUser.identities[0].connection === 'Username-Password-Authentication');
          }, function(error, results) {
            if (error) {
              return reject(error);
            }
            if (results.length === 1) {
              return resolve(results[0]);
            }
            reject(null);
          });
        } else {
          reject(null);
        }
      });
    });
  }

  // If `app_metadata.authorization` had already been assigned, it means this is not the first time user logged in
  if (user.app_metadata && user.app_metadata.authorization) {
    return proceedToNext();
  }
  var userId = user.user_id;
  getUsernamePasswordUser(user)
    .catch(function() {
      var errorMsg = 'Authorization Extension: No username password account found.';
      console.warn(errorMsg + ' For user: ', user);
      return Promise.reject(errorMsg);
    })
    .then(function(originalUser) {
      return getAuthzApiToken()
        .then(function(accessToken) {
          var originalUserId = originalUser.user_id;

          function deleteOriginalUserAsync(results) {
            var groupIds = results[0];
            var roleIds = results[1];
            var deleteUserFromGroup = _.bind(deleteGroupMembers, undefined, accessToken, _, [originalUserId]);

            groupIds.reduce(function(promise, groupId) {
                return promise.then(deleteUserFromGroup(groupId));
              }, Promise.resolve())
              .then(removeUserFromRoles.bind(undefined, accessToken, originalUserId, roleIds))
              .then(function() {
                return getManagementApiToken()
                  .then(_.bind(deleteUser, undefined, _, originalUserId));
              });
            // Do not return promise
          }

          function copyUserGroups() {
            return getUserGroups(accessToken, originalUserId)
              .then(getArrayMapper('_id'))
              .then(function(groupIds) {
                if (!groupIds.length) {
                  return groupIds;
                }
                return addUserToGroups(accessToken, userId, groupIds)
                  .then(function() { return groupIds; });
              });
          }

          function copyUserRoles() {
            return getUserRoles(accessToken, originalUserId)
              .then(getArrayMapper('_id'))
              .then(function(roleIds) {
                if (!roleIds.length) {
                  return roleIds;
                }
                return addUserToRoles(accessToken, userId, roleIds)
                  .then(function() { return roleIds; });
              });
          }

          return Promise.all([copyUserGroups(), copyUserRoles()])
            .then(deleteOriginalUserAsync);
        });
    })
    .then(proceedToNext)
    .catch(function(error) {
      console.error(error);
      return callback(new UnauthorizedError(error));
    });
}

Does anything know why this happens?