Integrating (Azure AD-flavored) SCIM into your application for automatic user provisioning

profile picture of Tim Weiß
Tim Weiß on August 17, 2022

What is SCIM and why do I need it

SCIM is the industry standard when it comes to synchronizing users of an organization with your application. As most businesses usually have a centralized repository for managing their users (employees), many bigger-scale customers will require your application to support some sort of user provisioning. Thus, supporting SCIM is a must for everybody that wants to close enterprise deals. We know that you‘d rather spend time on creating value where your product is best, so while this post is a hands-on guide on how to integrate SCIM within an arbitrary web application, it’s without any claim for feature completeness, as this isn‘t necessary for most cases.

What this guide will set you up with

If you follow this guide, you can set up your application to

  • Support synchronization (creation, update, deletion) of users with an identity provider
  • Identify users based on their roles/groups

Keeping the integration process efficient

It‘s possible you‘ll lose track when integrating SCIM into your application. Chances are high, this might even be the first time you‘ll make your software compliant with a standard such as SCIM. Therefore it is good to keep your goal in mind: allowing the synchronization of users with your platform. You very likely don‘t want to build a general-purpose integration, but the authors of the standard also had to keep that in mind to support a very broad variety of software, which can become a distraction for you, if you‘ll try to follow (just) the standard. Still, you should not deviate when clear instructions are given on behavior, as we‘ll explore later on.

Building your endpoints

We‘ll be using Express in this post together with TypeScript, but as long as your backend supports HTTP requests, you‘ll probably be able to replicate what we‘re doing ;)

Authenticating your SCIM requests

You should not leave your SCIM endpoints in the open. Otherwise, any actor might iterate over your users and even run malicious updates. For that reason, we‘re building a middleware we can plug into our services. You could of course configure it however you like to. To keep it simple, we‘re setting a secret as an environment variable and checking if the Authorization header contains it.

const authorizeScim = () => (req, res, next) => {
  const authorizedBearer = `Bearer ${config.scimToken}`;
  if (authorizedBearer === req.headers.authorization) {
    next();
  } else {
    res.status(401).send("Unauthorized");
  }
};
js

Getting a/many user

The first building block also is where a request in our SCIM journey starts. Whenever an update gets pushed, the IdP first checks if that user already exists. Thus, we‘re creating an endpoint that fetches the user.

/**
 * @api {get} /scim/v2/Users Get User List
 */
router.get("/", authorizeScim(), async (req, res) => {
  const filter = req.query.filter;
  if (!filter) {
    return res.status(400).send("filter parameter is required");
  }

  const result = await getUsersByFilter(filter);

  res.send({
    schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
    totalResults: 0,
    startIndex: 1,
    itemsPerPage: 0,
    Resources: result,
  });
});

/**
 * @api {get} /scim/v2/Users/:id Get User
 */
router.get("/:id", authorizeScim(), async (req, res) => {
  const filter = `userName eq "${req.params.id}"`;

  const result = await getUsersByFilter(filter);

  if (result.length > 0) {
    return res.send(result[0]);
  }

  return res.status(404).send({
    schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
    detail: "User not found",
    status: 404,
  });
});
js

Let‘s take a look what happens behind the scenes:

/**
 * This retrieves users from the database and returns them in SCIM format.
 * The filter parameter is defined by the SCIM standard.
 * Right now, we'll only support filtering by userName.
 * @param {*} filter SCIM Users filter
 */
const getUsersByFilter = async (filter) => {
  const filtered = filter.match(/userName eq "(.*)"/);
  if (!filtered) {
    // no matched string
    console.warn("unsupported filter", filter);
    return [];
  }

  const userName = filtered[1];

  try {
    const result = await db.findUserByEmail(userName); // this is where you interface

    if (result) {
      return [result].map(asScimUserSchema);
    }
  } catch (e) {
    console.error(e);
    // did not find user, return empty result to comply with SCIM
  }

  return [];
};
js

To conform your user with the SCIM schema, we’ll map it to the standard-compliant schema:

/**
 * Transforms our user object into the format supported by the SCIM standard.
 */
const asScimUserSchema = (user) => {
  let scimUser = {
    schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
    id: null,
    userName: null,
    name: {
      givenName: null,
      familyName: null,
    },
    emails: [
      {
        primary: true,
        value: null,
        type: "work",
      },
    ],
    externalId: null,
    active: false,
    meta: {
      resourceType: "User",
      location: null,
    },
  };

  scimUser["meta"]["location"] =
    config.backendUrl + "/scim/v2/Users/" + user.directory_id;
  scimUser["id"] = user.directory_id;
  scimUser["externalId"] = user._id;
  scimUser["active"] = !(user.deleted || false);
  scimUser["userName"] = user.email;
  scimUser["name"]["givenName"] = user.first_name;
  scimUser["name"]["familyName"] = user.last_name;
  scimUser["emails"][0]["value"] = user.email;

  return scimUser;
};
js

Creating a user

Of course, SCIM also needs a way to get users into your system. Later on, we‘ll talk about how to configure the side of the IdP to specify what it expects to be saved (and returned by our GET endpoint). We‘ll integrate a very simple user with common attributes. I‘ll also show you a couple of more exotic cases that often occur, so we‘ll also cover that.

First, let‘s take a look at the endpoint. Not a lot of magic happens here, of course.

router.post("/", authorizeScim(), async (req, res) => {
  try {
    const created = await createScimUser(req.body);
    if (created) {
      res.status(201).send(created);
    } else {
      res.status(409).send({
        schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
        detail: "User already exists",
        status: 409,
      });
    }
  } catch (e) {
    console.error(e);
    return res.status(400).send({
      schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
      detail: "Could not create user",
      status: 400,
    });
  }
});
js

Now, here‘s where the actual magic takes place, of course. You can also integrate this with your existing user management, as long as the values that SCIM delivers are stored 1:1. Otherwise, it will try to (indefinitely) update your user until the values returned by your database match the ones the IdP expects.

const createScimUser = async (user) => {
  // we can utilize the SAML functions to create the user here
  try {
    const exists = await db.findUserByEmail(user.userName);
    if (exists) {
      // to follow the SCIM standard, we need to return a 409 Conflict
      return null;
    }
  } catch (e) {}

  console.log("creating user from scim: ", user.userName);

  const created = await createUser(transformScimUser(user)); // createUser adds user to your database

  if (created) {
    return asScimUserSchema(created);
  }

  throw new Error("Failed to create user");
};
js

Just like with fetching a user, we need to run the transformation in reverse to make a SCIM user conform to our user model:

const transformScimUser = (user) => {
  let transformed = {
    username: user.userName,
    firstName: null,
    lastName: null,
    email: null,
    department: null,
    active: user.active,
  };

  if (user.name) {
    transformed.firstName = user.name.givenName;
    transformed.lastName = user.name.familyName;
  } else {
    console.warn("user has no name, will transform to allow default behavior");
    transformed.firstName = "";
    transformed.lastName = "";
  }

  if (user.emails && user.emails.length > 0) {
    transformed.email = user.emails[0].value;
  } else {
    // fallback to support users without a name
    console.warn("using fallback (username) for user without email specified");
    transformed.email = user.userName;
  }

  return transformed;
};
js

Updating a user

This is probably the endpoint that gets called the most by your IdP. SCIM detects changes by first asking your app about the information it has on one user. It then pushes an update (either through one request or multiple) with all the fields that have changed.

The API endpoint is similar to our existing ones:

router.patch("/:id", authorizeScim(), async (req, res) => {
  try {
    const updated = await patchScimUser(req.body, req.params.id);
    if (updated) {
      res.status(200).send(updated);
    } else {
      res.status(404).send({
        schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
        detail: "User not found",
        status: 404,
      });
    }
  } catch (e) {
    console.error(e);
    return res.status(400).send({
      schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
      detail: "Could not create user",
      status: 400,
    });
  }
});
js

Now, the business logic does include some tricks. Sometimes, SCIM pushes multiple updates within one request. Therefore, an update consists of multiple entries. This means we have to parse them. We‘ll write a simple general-purpose parser for this. It updates the existing user based on the updates SCIM gives us. Therefore, we only need to save the changes after passing the update through the parser.

/**
 * Parses the object path of the patch operation and assigns it the desired value.
 */
const makeUpdateFromPath = (path, value) => {
  let updateObj = {};

  // second case: givenName
  if (path.startsWith("name.givenName")) {
    updateObj.firstName = value;
  }

  // third case: familyName
  if (path.startsWith("name.familyName")) {
    updateObj.lastName = value;
  }

  // fourth case: active
  if (path.startsWith("active")) {
    updateObj.active = value;
  }

  // fourth case: email
  // as we're identifying users via their email, we need to support this case differently
  if (path.startsWith('emails[type eq "work"]')) {
    updateObj.email = value;
  }

  return updateObj;
};

const patchScimUser = async (patch, userName) => {
  // parse operations and form update object
  patch.operations = patch.Operations; // support Azure AD-flavored SCIM

  const update = {};
  update.userName = userName;

  if (!patch.operations) {
    throw new Error("No operations found in patch");
  }

  patch.operations.forEach((operation) => {
    if (operation.op === "Replace") {
      Object.assign(
        update,
        makeUpdateFromPath(operation.path, operation.value)
      );
    } else if (operation.op === "Add") {
      Object.assign(
        update,
        makeUpdateFromPath(operation.path, operation.value)
      );
    } else if (operation.op === "Remove") {
      Object.assign(update, makeUpdateFromPath(operation.path, null));
    }
  });

  return updateScimUser(update);
};
js
const updateScimUser = async (user) => {
  let existing = null;
  try {
    existing = await db.findUserByEmail(user.userName); // your database lookup here
  } catch (error) {
    console.warn("tried updating a user that does not exists");
  }

  if (!existing) {
    // to follow the SCIM standard, we should return a 404 Not Found
    return null;
  }

  user.username = user.username || existing.directory_id;
  user.firstName = user.firstName || existing.first_name;
  user.lastName = user.lastName || existing.last_name;
  user.email = user.email || existing.email;
  user.active = user.active || !existing.deleted;

  console.log("updating user from SCIM", user.userName);

  // if you identify users via email, it's good to be extra careful
  // update email if it's changed before running the update
  if (user.email && user.email !== existing.email) {
    console.log(
      "updating user email from SCIM",
      existing.email,
      "to",
      user.email
    );
    try {
      // this will throw if user can't be found
      const emailInUser = await db.findUserByEmail(user.email);
      console.warn(
        "to-be-updated email already exists in database for user, will skip updating email for this update",
        emailInUser
      );
    } catch (e) {
      // only update if the email is not already in use
      await db.updateEmail(existing.email, user.email);
    }
  }

  // update user active status
  if (!user.active !== existing.deleted) {
    console.log(
      "updating user deleted status from SCIM",
      existing.deleted,
      "to",
      !user.active
    );
    await db.setDeleted(existing._id, !user.active);
  }

  // first, save the update to our default user fields
  const updated = await saveUser(user, true); // this is your database update logic

  if (updated) {
    return asScimUserSchema(updated);
  }

  throw new Error("Failed to update user");
};
js

Deleting/disabling a user

Do note that some implementations do not use the DELETE route but rather update the user‘s disabled field. Azure‘s implementation (which is, de facto, the enterprise standard) is always using the PATCH endpoint. Thus, we don’t need to implement a separate endpoint for deleting users.

Testing your endpoints

One strategy (especially during early development) is to test your endpoints using an API client like Insomnia or Postman. That is however limited to the „internal compliance“ of your endpoints. To reliably test it, it‘s best to integrate it using a real environment. If you know your customers will be using Azure AD, it‘s best to test it with that (and so on).

Generally, you also want to watch out for some behaviors with your application that are allowed with SCIM:

  • what happens when users change their email addresses
  • what happens when a user gets (soft-)deleted, but the same one comes back (which can happen when somebody is on pregnancy leave, for example)
  • users will change their last (or first) names from time to time
  • and many more cases…

If you want to test your endpoint with Azure AD, I will also walk you through setting up your application with your (or your customer‘s) AD.

Integrating with Azure AD

Because Azure AD is by far the most-used IdP, we‘re focusing on how to integrate with them (our guide also accounts for some quirks of Azure‘s implementation).

  • In your Azure portal, go to your Active Directory settings
  • Visit the tab Enterprise applications and click on New application
  • Click on Create your own application, enter your app’s name, and choose Integrate any other application you don’t find in the gallery
  • In the now created application, go to Provisioning, click Get started
  • Select Automatic as the provisioning mode, and under admin credentials, enter your previously defined SCIM endpoint (the base route without /users) and token. Test your connection and press save afterwards.
  • Select Edit provisioning, go to Mappings and disable Provision Azure Active Directory Groups
  • Go to Provision Azure Active Directory Users, here you can update your attributes, and save them after editing
  • On the Provisioning page, you can now Start provisioning to start the synchronization of your users

Testing updates

After starting the provisioning process, Azure AD should have pushed all existing users of your directory to your application. To test your update endpoint, you need to update some of your user‘s fields. Let‘s walk you through that:

  • Go to the start page of your Azure AD tenant, and choose Users under the Manage section
  • Choose one of the users you want to update
  • Click on Edit properties, it should navigate you to a page where you can update all available fields of your user
  • After saving your updates, you can look out for updates coming from Azure AD to your SCIM endpoint

Tunneling your requests for debugging

In case you‘re debugging a local version of your application, it‘s probably not reachable from the internet by default. As Azure AD also requires SSL connections for your SCIM endpoint, a tunneling service is recommended.

Not to recommend any service in particular, but ngrok is an application worth checking out to test your integration. Instead of putting in your backend‘s URL, you‘ll enter the endpoint from ngrok as the base for the Tenant URL.

Conclusion

Congratulations! Your app should now be equipped with a near production-ready integration of SCIM. As a general word of advice, it‘s good to closely watch your first couple of integrations, as mistakes happen and Azure AD is rather silent when it comes to error handling.

Of course, your journey into SCIM can go much further than what this post covered. In fact, it only scratched the surface of what a fully compliant and rich integration will offer. Nevertheless, we‘ve covered a lot of grounds, as your application is now able to receive automated updates for your customer‘s users.

If you have any feedback, we‘re more than happy to hear about it! You can email me (the author!) personally, too :)

Outlook

Our journey also does not stop here. In the next blog post, we‘ll take a look at how to provision groups via SCIM. This will also allow us to synchronize roles if you desire to.