Exporting/Importing users in Azure AD B2C

Azure AD B2C are cloud services that are on 24×7. It does not come with backup/restore functionality, because the service is designed to be always on and available. If you want to automate export/import of users, you need to use Microsoft Graph API to perform the task. Yes, you can also use the AzureAD powershell module, but it is really just a layer on top of the Graph API and although it gives you ease of use, it also gives you less flexibility. In this blog post, I’ll use the Microsoft Graph API.

The scripts can be found in this github repo https://github.com/cljung/B2C-devdiv/tree/main/9-export-import

Things are never as easy as they first seem…

Id – The identity of directory objects is a guid that is globally unique across all tenants world-wide. That means that the id of a user object, for instance, is never repeated, can never be reused and you can not set your own value – the system does that for you. If an object is deleted and recreated, it get’s a new id.

Tenant dependent attribute values – Some attribute have values that are dependent on the tenant. In the below screenshot, the issuer attribute has the tenant’s name in its value and that must be updated between export/import to reflect the target tenant.

Attributes that doesn’t make sense to export/import – Some attributes doesn’t make sense to bother about. In the example below, createdDateTime doesn’t make sense to carry over and will be recreated during import. OnPremisesLastSyncDateTime is an attribute for Azure AD when syncing with an on-premise environment and that has no meaning in B2C. Both attributes should just be skipped.

Extension Attributes – extension attributes has a namespace name of extension_<guid>_<name> where the guid-part is an appId guid (without dashes) of the application id it was registered to schema wise. That appId is a unique id for the existing B2C tenant and can’t be carried over from the source to the target tenant. The extension attribute needs to be recreated in the target tenant, under the namespace of another app, and the new extension attribute needs to reflect that name.

All in all, the above examples illustrates that the task is a little more complex than a simple export/import. Then it becomes even more complex if you are using groups in your solution, because a group is a directory object identified by an id, and it tracks its members via their id, ie we have a situation where both parts in the 1:N relationship are identifiers that cannot be carried. You need a translation table between old and new identifiers to solve this.

Export – Nothing is too complex that it can’t be solved with some scripting…

The export part is a loop where we use Microsoft Graph API to get 10 users at at time and repeat that until we’re done. For each user we get, we both strip out named attributes and all attributes with null values (since it’s meaningless to import null values). We also in this process collect a list of all extension attributes. The reason we’re collecting them from the user’s profile is because then we know they are actually in use and not stale definitions.

$unneededProps=@("@odata.id", "businessPhones", "createdDateTime", "imAddresses", "infoCatalogs", "mailNickname", "proxyAddresses", 
        "refreshTokensValidFromDateTime", "signInSessionsValidFromDateTime", "assignedLicenses", "assignedPlans", 
        "deviceKeys", "onPremisesExtensionAttributes", "onPremisesProvisioningErrors", "provisionedPlans")
Set-Content -Path $userFile -Value "{ `"users`": ["
$sep = " "
$extensionAttributes=@()
$resp = Invoke-RestMethod -Method GET -Uri "$GraphEndpoint/users?`$top=10" -Headers $authHeader
$resp.value.Count
while ( $resp.'@odata.nextLink' ) {
    foreach( $user in $resp.value ) {
        # get a list of props that have non-null values (we don't need to export/import nulls)
        $nonNullProps = ($user.psobject.properties.name).Where({ $null -ne $user.$_ })
        # remove some props that  doesn't make sense for B2C
        foreach( $name in $unneededProps) {
            $nonNullProps.Remove( $name ) | Out-null
        }
        # collect a list of any extension attributes so we can export their definition later
        $extensionAttributes += ($nonNullProps | where {$_.StartsWith("extension_")})
        # export the user
        $row = ($user | Select-Object $nonNullProps | ConvertTo-json -Compress)
        Add-Content -Path $userFile -Value "$sep$row"
        $sep = ","
    }
    $resp = Invoke-RestMethod -Method GET -Uri $resp.'@odata.nextLink' -Headers $authHeader
    $resp.value.Count
}
# make the list of extension attributes unique (remove dups)
$extensionAttributes = $extensionAttributes | select -Unique
Add-Content -Path $userFile -Value "]`n}"

The result is written to a file named Users.json that contain all the users (without the attributes we don’t care about). The next part of the export script creates a list of extension attributes used, listed under which app they were registered under. The purpose of this data is that we can recreate the extension attributes in the target tenant, which is a prerequisite for importing users having values for those extension attributes. This is done by searching for an app with the name in the target environment and registering them there. This data gets exported to a file named ExtensionAttributes.json

{ "attributes": [
    {
        "appId":  "afe37282-fa86-4aae-9ac5-0b831d8ed3f7",
        "objectId":  "fcd0daac-e4e3-4a0d-8fa9-46027086ca62",
        "appName":  "b2c-extensions-app. Do not modify. Used by AADB2C for storing user data.",
        "extensionAttributes":  [
                                    {
                                        "dataType":  "String",
                                        "name":  "Geo"
                                    },
                                    {
                                        "dataType":  "Boolean",
                                        "name":  "requiresMigration"
                                    },

The last part of the export script just enumerates existing groups and it’s members into two files – Groups.json and GroupMembers.json. These files all contain ids from the source tenant which means they need to be translated during import.

Importing

Importing is divided into three scripts – ImportExtensionAttributes.ps1, ImportUsers.ps1 and ImportGroups.ps1 – and they must be run in that order.

ImportExtensionAttributes.ps1

The ImportExtensionAttributes.ps1 files takes the file ExtensionAttributes.json and (re)creates the extension attributes, with name and datatype, to the named application. If you want to change/consolidate what app namespace different extension attributes are registered under, you can edit the file before running this step. This means that if in the source tenant, extension attributes were scattered across multiple apps, you could aggregate them into just one app namespace by editing this file before running the ImportExtensionAttributes.ps1 script.

When the script runs, it creates anew file named ExtensionAttributesLookup.json which is a mapping file between the names used in the B2C source tenant and the B2C target tenant. This file will be used when importing users to translate the names of the extension attributes.

{ 
  "attributes": {
    "nameNew":  "extension_dff0417c555b49aea9961f53eb75aa58",
    "name":  "extension_afe37282fa864aae9ac50b831d8ed3f7"
  }
}

ImportUsers.ps1

The ImportUsers.ps1 script is probably the one that does most of the work as it is translating and cleaning data. First, it is doing a simple search-and-replace of all the extension attribute names based on the ExtensionAttributesLookup.json file. Then, for each user, it is removing attributes who’s value we can’t import, like id and userPrincipalName. It also makes sure that the attribute passwordProfile is correct as we need it for LocalAccounts. Lastly it makes sure the identities collection is not including the userPrincipalName entry (as it is created by B2C on write) and that the issuer attribute is referencing the target tenant name. It also captures the output of the POST (write) operation to create a lookup table of old/new identifiers for user objects. This is needed for group membership import. Any errors that occur are captured in a file called UsersError.json.

$data = (Get-Content -Path $usersFile)
$eaLookup = (Get-Content -Path $extAttrFileLookup | ConvertFrom-json)
# do global string replace to update the names of the extension attributes
foreach( $ea in $eaLookup.attributes ) {
    if ( $ea.name -ne $null ) {
        $data = $data.Replace( $ea.name, $ea.nameNew )
    }
}
$data = ($data | ConvertFrom-json)
$usersLookup=@()
$usersErrors=@()
foreach( $user in $data.users ) {
    if ( $user.userPrincipalName -imatch "#EXT#" -or $user.userType -eq "Guest" -or $user.creationType -eq "Invitation") {
        continue
    }
    $idOld = $user.id
    # remove objectId and UPN as they are immutable and can't be imported
    $user.psobject.properties.Remove("id")
    $user.psobject.properties.Remove("userPrincipalName")
    # add a passwordProfile as all accounts need one
    if ( $user.passwordProfile -eq $null ) {
        $user | Add-Member -MemberType NoteProperty -Name "passwordProfile" -Value $pwdProf.passwordProfile
    } elseif ($user.creationType -eq "LocalAccount" -and $user.passwordProfile -ne $null ) {
        $user.passwordProfile.password = $pwdProf.passwordProfile.password
    }
    # remove the UPN item in the identities collection as it is created automatically on write
    $identities=@()
    $identities += $user.identities | where {$_.signInType -ne "userPrincipalName"}
    $user.psobject.properties.Remove("identities")
    $user | Add-Member -MemberType NoteProperty -Name "identities" -Value $identities
    # change all yourtenant.onmicrosoft.com to the real value
    foreach( $identity in $identities ) {
        if ( $identity.issuer.EndsWith(".onmicrosoft.com") ) {
            $identity.issuer = $targetTenantName
        }
    }
    $resp = Invoke-RestMethod -Method "POST" -Uri "$GraphEndpoint/users" -Headers $authHeader -Body ($user | ConvertTo-json)
    # save new user's id for the lookup table
    $usersLookup += @{ id = $idOld; idNew = $resp.id }
}

Passwords

We can’t export the password, since that is technically impossible in Azure AD (B2C), so when importing the user profile data, we need to set a new, random, password. The way forward is either forcing the user setting a new password or following the migration pattern where the new IDP gets help from the old IDP in password validation.

More things to consider

Batching – These scripts work well for a one time export/import of a smaller amount of users. If you have a larger amount of users, you should consider modifying the ImportUsers.ps1 script to use the Graph API batching technique. It makes the script a bit more complex but it will gain performance up to 5x.

Delta changes – if you run export/import more than once, you need to handle delta changes, because just rerunning the import scripts will give you duplicates of customers. You would need to use the UsersLookup.json file and check if a user was already imported in the last run. If that was the case, should you see if any attributes has been changed since last run, etc etc. This can be a quite complex process. If you go this route, you are building more of a migration solution rather than just a backup/restore solution.