Search code examples
javakeycloaknashorn

Aggregate attributes of roles in claims using Keycloak Script Mapper


We have a Keycloak Script Mapper to add attributes of roles to the ID token. The goal is to aggregate the values available in attributes for the roles. The mapper looks like this:

/**
 * Merge with concatenation for the values of attributes obtained
 * from the token with the attributes obtained from the roles. If for
 * example a group and a role have the same attribute key, the values
 * for that key from the group and role will be concatenated.
 *
 * Known limitations:
 * When only roles have a certain attribute, and not a group, the 
 * mapper only uses the first role it can find. Bug was difficult to
 * fix because state in the variable currentClaims seems to be
 * persisted over multiple calls.
 * Workaround: Also add this specific attribute to a group with a
 * dummy value.
 * 
 * NOTE: there is no role attribute mapper out-of-the-box in 
 * Keycloak.
 * 
 * Available variables in script: 
 * user - the current user
 * realm - the current realm
 * token - the current token
 * userSession - the current userSession
 * keycloakSession - the current keycloakSession
 * 
 * Documentation on available variables:
 * https://stackoverflow.com/a/52984849
 */
 
var currentClaims = {};
token.getOtherClaims().forEach(function(k, v) {
  currentClaims[k] = v;
});

function isMultiValued(v) {
  // From experience, multivalued attribute values are sometimes 
  // Arrays and sometimes Objects. Thus look for negative case:
  // anything other than a string is multivalued.
  return !(typeof v === 'string' || v instanceof String);
}

function addToList(l, values) {
  for each(var v in values) {
    l.add(v);
  }
  return l;
}

function toStringArray(arr) {
  return Java.to(arr, "java.lang.String[]");
}

user.getRealmRoleMappings().forEach(function(roleModel) {
  roleModel.getAttributes().forEach(function(k, v) {
    var currentValue = currentClaims[k];
    if (k in currentClaims) {
      if (!isMultiValued(currentValue)) {
        v = toStringArray([currentValue].concat(v));
      } else {
        v = addToList(currentValue, v);
      }
    }
    currentClaims[k] = v; // <= to also aggregate over roles!
    token.setOtherClaims(k, v); 
  }); 
});

The part with currentClaims[k] = v I added to aggregate values available in roles, so that also if two roles contain the same attribute their values are aggregated.

E.g., if we have a user has the roles a and b with respectively attribute foo with value 1 and 2, we expect that the ID token contains a claim for foo with the values 1 and 2.

user:
  role a foo -> 1
  role b foo -> 2
expected ID token:
  foo -> [1, 2]

But with the current code the currentClaims variable seems to hold state over multiple calls to the function. Every time the ID token is inspected more values for 2 are added into the token, leading to a foo claim like [1, 2, 2, ..., 2] with more and more 2s added every time the token is retrieved. I tried wrapping the whole call in a function so that possibly state was discarded between calls, but to no avail. This is the result:

aggregation

Why is the state kept over multiple calls? And is there a way to also aggregate the values for the role attributes?


Solution

  • Why is the state kept over multiple calls?

    Unknown. Maybe something with Nashorn.

    [I]s there a way to also aggregate the values for the role attributes?

    To not keep aggregating value check in the addToList function if it is already there (workaround):

    function addToList(l, values) {
      for each(var v in values) {
        if (!l.contains(v)) {
          l.add(v);
        }
      }
      return l;
    }
    

    And aggregate by keeping some state:

    var newClaims = {};
    newClaims = currentClaims;
    user.getRealmRoleMappings().forEach(function(roleModel) {
      roleModel.getAttributes().forEach(function(k, v) {
        var currentValue = newClaims[k];
        if (k in newClaims) {
          if (isMultiValued(currentValue)) {
            v = addToList(currentValue, v);
          }
        }
        newClaims[k] = v;
      }); 
    });
    token.setOtherClaims("new-claims", newClaims); 
    

    This puts all claims in the new-claims field in the JWT.

    //// EDIT:

    Note: this JavaScript/Java interop in addToList has issues (like changing the actual attributes on roles...). Better to use pure JavaScript for concatenating and then convert back to Java:

    function toStringArray(arr) {
      return Java.to(arr, "java.lang.String[]");
    }
    
    
    Array.prototype.includes = function(obj) {
      var i = this.length;
      while (i--) {
        if (this[i] === obj) {
          return true;
        }
      }
      return false;
    }
    
    function addToList(l, values) {
      // Add values to new instance to avoid magical addition to
      // current values in role attributes
      var arr = [];
      for each(var v in l) {
        arr.push(v);
      }
      for each(var v in values) {
        if (!arr.includes(v)) {
          arr.push(v);
        }
      }
      return toStringArray(arr);
    }