When you plan to migrate your users identities to Azure AD B2C, you may also want to migrate the user accounts as well. This article explains how to migrate existing user accounts from a legacy Identity Provider into Azure AD B2C and seamlessly validating and migrating user passwords at initial logon to a Azure AD B2C protected application. This scenario is applicable when passwords in the legacy Identity Provider are stored in a hashed or non-recoverable state.
Azure AD B2C allows you to pre-create user accounts by using the Graph API.
Using the tools above, read the user accounts from the legacy identity provider and pre-create accounts into Azure AD B2C with random passwords.
We a have pre built tool which can be leveraged to perform user account migration, found here. Or otherwise, follow the detailed guidance below which explains how to use Microsoft Graph API to migrate accounts.
Once the application has been migrated to use Azure AD B2C as the Identity Provider, users who had their accounts migrated will undergo a seamless migration flow with the following logic:
- Read the Azure AD B2C account which corresponds with the email address the user has attempted to login with.
- Check whether the account is flagged for migration on initial logon by evaluating a boolean extension attribute.
- If the boolean returns
True
, call a REST API to validate the password against the legacy Identity Provider. - If the REST API determines the password is incorrect, return a friendly error to the user.
- If the REST API determines the password is correct, write the password into the Azure AD B2C account and change the boolean extension attribute to
False
. - If the boolean extension attribute returns
False
, continue the logon process as normal.
Note
This flow requires using Azure AD B2C Custom policy
The following diagram illustrates the seamless migration flow:
This section describes the process on how to create user accounts in Azure AD B2C via Azure AD Graph API for the pre-migration stage.
To communicate with the Azure AD Graph API, you first need to have service account with administrative privileges. In Azure AD, you can do this by registering an application and authenticating to Azure AD. The application credentials are: Application ID and Application Secret. The application acts as itself, not as a user, to call the Graph API.
In this step, you register your migration application in Azure AD. Create application key (Application secret) and set the application with right privileges (write, set password, and delete users).
- Sign in to the Azure portal.
- Choose your Azure AD B2C tenant by selecting your account in the top right corner of the page.
- From left panel, open the Azure Active Directory (not the Azure AD B2C). You might need to select More Services to find it.
- Select App registrations.
- Select New registration.
- Follow the prompts and create a new application
- For Name, use B2CUserMigration.
- For Supported account types, use Accounts in this organizational directory only.
- For Redirect URI, use type Web: https://localhost (as it's not relevant for this application).
- Click Register
- Once it is created, select the newly created application B2CUserMigration. Select Properties, copy the Application ID, and save it for later.
- Click on Certificates and Secrets menu and add a new key (also known as client secret). Copy the key for later.
- Click on API permissions.
- Click on Add a Permission -> Microsoft Graph.
- Select Application Permissions, select the Directory.ReadWrite.All and Application.ReadWrite.All permissions and click Save.
- Finally, back in the Application Permissions menu, click on the Grant admin Consent button.
You now have an application that has permission to create, read, and update users from your B2C tenant.
The following PowerShell script demonstrates how to:
- Obtain an Access Token to Microsoft Graph API
- Register the extension attribute named
requiresMigration
into Azure AD B2C - This is later referenced asextension_requiresMigration
within the Azure AD B2C Custom Policy. - Create a user object in Azure AD B2C
#Part 1 - Obtain an Access Token to Azure AD Graph API
#AAD B2C tenant
$tenant = "b2cprod.onmicrosoft.com"
#B2CUserMigration Application Registration Application Id
$ClientID = ""
#B2CUserMigration Application Registration generated key (client secret)
$ClientSecret = ""
$loginURL = "https://login.microsoftonline.com"
$resource = "https://graph.microsoft.com"
# Get an OAuth 2 access token based on client id, secret and tenant
$body = @{grant_type="client_credentials";client_id=$ClientID;client_secret=$ClientSecret;resource=$resource}
$oauth = Invoke-RestMethod -Method Post -Uri $loginURL/$tenant/oauth2/token?api-version=1.0 -Body $body
#Part 2 - Register the extension attribute named "requiresMigration" into Azure AD B2C
#ObjectID of the b2c-extensions-app App Registration (You will find this in the App Registrations menu)
$AppObjectID = ""
#Set the endpoint to register extension attributes
$url = "$resource/v1.0/applications/$AppObjectID/extensionProperties"
#Define the extension attribute
$body = @"
{
"name": "requiresMigration",
"dataType": "Boolean",
"targetObjects": ["User"]
}
"@
#Generate the authentication header and make the request
$authHeader = @{"Authorization"= $oauth.access_token;"Content-Type"="application/json";"ContentLength"=$body.length }
$result = Invoke-WebRequest -Headers $authHeader -Uri $url -Method Post -Body $body
#Print the full attribute Name
($result.Content | Convertfrom-Json).name
$extName = ($result.Content | Convertfrom-Json).name
$extName
#Part 3 - Create a user object in Azure AD B2C
#Populate the user properties
#Example $extName = extension_ce0f3b39c19d415988af620a33887208_requiresMigration
$body = @"
{
"displayName": "John Smith",
"identities": [
{
"signInType": "emailAddress",
"issuer": "contoso.onmicrosoft.com",
"issuerAssignedId": "jsmith@yahoo.com"
}
],
"passwordProfile" : {
"password": "Password123!!",
"forceChangePasswordNextSignIn": false
},
"passwordPolicies": "DisablePasswordExpiration",
"$extName": true
}
"@
#Build the authentication header
$authHeader = @{"Authorization"= $oauth.access_token;"Content-Type"="application/json";"ContentLength"=$body.length }
#Set the endpoint to make the POST request to
$url = "$resource/v1.0/users"
#Make the POST request with the body to create the user
Invoke-WebRequest -Headers $authHeader -Uri $url -Method Post -Body $body
The Azure AD B2C custom policy must be reconfigured such that upon sign-in, it can make the necessary validations against data to execute the logic described in the seamless migration flow during sign in.
Follow the steps in the following links to allow your custom policy to read and write custom attributes:
- Create an Application Registration to store the extension properties
- Modify the Custom Policy to reference the Application Registration
The following claims are added such that the Azure AD B2C policy can read and write to the extension attribute containing the users migration status, and also parse the result from the REST API.
- Open the extension policy file (TrustFrameworkExtensions.xml) from your working directory.
- Find the
<BuildingBlocks>
section - Add following XML snippet under the
ClaimsSchema
element. If theClaimsSchema
element does not exist, create this under the<BuildingBlocks>
section
<!-- Holds the value of the migration status on the Azure AD B2C account -->
<ClaimType Id="extension_requiresMigration">
<DisplayName>extension_requiresMigration</DisplayName>
<DataType>boolean</DataType>
<AdminHelpText>extension_requiresMigration</AdminHelpText>
<UserHelpText>extension_requiresMigration</UserHelpText>
</ClaimType>
<!-- Holds the value of whether the authentication succeeded at the legacy IdP -->
<ClaimType Id="tokenSuccess">
<DisplayName>tokenSuccess</DisplayName>
<DataType>boolean</DataType>
<AdminHelpText>tokenSuccess</AdminHelpText>
<UserHelpText>tokenSuccess</UserHelpText>
</ClaimType>
<!-- Holds the value 'false' when the legacy IdP authentication succeeded -->
<ClaimType Id="migrationRequired">
<DisplayName>migrationRequired</DisplayName>
<DataType>boolean</DataType>
<AdminHelpText>migrationRequired</AdminHelpText>
<UserHelpText>migrationRequired</UserHelpText>
</ClaimType>
We must read the value of the extension attribute to determine if the account requires migration.
- Open the extension policy file (TrustFrameworkExtensions.xml) from your working directory.
- Find the
<ClaimsProviders>
section - Add following XML snippet under the
ClaimsProviders
element
<ClaimsProvider>
<DisplayName>Local Account SignIn - Read migration status</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="Get-requiresMigration-status-signin">
<Metadata>
<Item Key="Operation">Read</Item>
<Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">true</Item>
<Item Key="UserMessageIfClaimsPrincipalDoesNotExist">An account could not be found for the provided user ID.</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="signInName" PartnerClaimType="signInNames.emailAddress" Required="true" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="objectId" />
<!-- Set a default value (false) in the case the account does not have this attribute defined -->
<OutputClaim ClaimTypeReferenceId="extension_requiresMigration" DefaultValue="false" />
</OutputClaims>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
- Open the extension policy file (TrustFrameworkExtensions.xml) from your working directory.
- Find the
<ClaimsProviders>
section - Add following XML snippet under the
ClaimsProviders
element - Change the value of
ServiceUrl
to point to your API endpoint URL
<ClaimsProvider>
<DisplayName>REST API to communicate with Legacy IdP</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="UserMigrationViaLegacyIdp">
<DisplayName>REST API call to communicate with Legacy IdP</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.RestfulProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="ServiceUrl">https://<your-api-endpoint></Item>
<Item Key="AuthenticationType">None</Item>
<Item Key="SendClaimsIn">Body</Item>
</Metadata>
<InputClaims>
<InputClaim ClaimTypeReferenceId="signInName" PartnerClaimType="email" />
<InputClaim ClaimTypeReferenceId="password" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="tokenSuccess" DefaultValue="false"/>
<OutputClaim ClaimTypeReferenceId="migrationRequired"/>
</OutputClaims>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
The Technical Profile above defines two input claims: signInName
(sent as email) and password
. When a user sign's in, these claims are sent to your Restful endpoint in a JSON payload within the body. The password
is sent over a HTTPS connection to the REST API.
{
"email" : "user@contoso.com",
"password" : "value"
}
Once the REST API has confirmed the username and password successfully authenticated at the legacy Identity Provider, we must write the new password into the account and also change the extension attribute such that it no longer follows this migration path on subsequent logons.
- Open the extension policy file (TrustFrameworkExtensions.xml) from your working directory.
- Find the
<ClaimsProviders>
section - Add following XML snippet under the
ClaimsProviders
element
<ClaimsProvider>
<DisplayName>Local Account SignIn - Write new password and unmark for migration</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="AAD-WritePasswordAndFlipMigratedFlag">
<Metadata>
<Item Key="Operation">Write</Item>
<Item Key="RaiseErrorIfClaimsPrincipalAlreadyExists">false</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="objectId" Required="true" />
</InputClaims>
<PersistedClaims>
<PersistedClaim ClaimTypeReferenceId="objectId" />
<PersistedClaim ClaimTypeReferenceId="password" PartnerClaimType="password"/>
<PersistedClaim ClaimTypeReferenceId="migrationRequired" PartnerClaimType="extension_requiresMigration"/>
</PersistedClaims>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
<UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
During the Sign In step of the user journey, we must add validation technical profiles to process the logic to migrate an account. These are executed based on Preconditions
to control the logic.
The following XML snippet overrides the SelfAsserted-LocalAccountSignin-Email
, which is defined in the base policy. The XML snippet adds multiple ValidationTechnicalProfiles
with ReferenceIds pointing to the Technical Profiles defined in the previous steps.
- Open the extension policy file (TrustFrameworkExtensions.xml) from your working directory.
- Find the
<ClaimsProviders>
section - Add following XML snippet under the
ClaimsProviders
element
<ClaimsProvider>
<DisplayName>Local Account Sign In</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="SelfAsserted-LocalAccountSignin-Email">
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="extension_requiresMigration" />
</OutputClaims>
<ValidationTechnicalProfiles>
<!--Add user migration validation technical profiles before login-NonInteractive -->
<!-- Populate extension_requireMigration into the claims pipeline -->
<ValidationTechnicalProfile ReferenceId="Get-requiresMigration-status-signin" ContinueOnError="false" />
<!-- If extension_requireMigration is true, call the legacy IdP via the REST API -->
<ValidationTechnicalProfile ReferenceId="UserMigrationViaLegacyIdp" ContinueOnError="false">
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>extension_requiresMigration</Value>
<Value>False</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
</Preconditions>
</ValidationTechnicalProfile>
<!-- If the API returned 'tokensuccess', write the new password and unmark the account for migration -->
<ValidationTechnicalProfile ReferenceId="AAD-WritePasswordAndFlipMigratedFlag" ContinueOnError="false">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="false">
<Value>tokenSuccess</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
</Preconditions>
</ValidationTechnicalProfile>
<!-- Initiate a normal logon against Azure AD B2C -->
<ValidationTechnicalProfile ReferenceId="login-NonInteractive" />
</ValidationTechnicalProfiles>
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
The REST API should only return two sets of results indicating the user authentication succeeded or failed against the legacy Identity Provider. For a successful authentication against the legacy Identity Provider, this would look as follows:
{
"tokenSuccess" : True,
"migrationRequired" : False
}
For a failed authentication against the legacy Identity Provider, the REST API should return a non HTTP 200 response code with a friendly error message. This is demonstrated in the AADtoB2CUserMigration
sample folder.
The migrationRequired key-value pair is used to write the new value into the extension_requiresMigration
attribute when the legacy Identity Provider authentication is successful as part of the AAD-WritePasswordAndFlipMigratedFlag
Technical Profile. This will unmark the account for migration at subsequent logons since it will return 'false'.
Potentially a user may return to the site and forget their password after their account was already pre migrated. We must handle accounts that are created only in Azure AD B2C, already migrated accounts and accounts that still need migrating.
In such a case, we must always check the migration status of the account via the extension attribute. Once the user resets the password, unflag the account for migration via the extension attribute if it was due to be migrated at initial sign in.
The following diagram illustrates the seamless migration flow during password reset:
The password reset journey must be modified such that when a user resets a password on an account that is due migration, then the extension attribute is unmarked such that subsequent logons do not go via the legacy Identity Provider validation.
- Open the extension policy file (TrustFrameworkExtensions.xml) from your working directory.
- Find the
<ClaimsProviders>
section - Add following XML snippet under the
ClaimsProviders
element
<ClaimsProvider>
<DisplayName>Local Account Password Reset - Read migration flag</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="Get-requiresMigration-status-password-reset">
<Metadata>
<Item Key="Operation">Read</Item>
<Item Key="RaiseErrorIfClaimsPrincipalDoesNotExist">true</Item>
<Item Key="UserMessageIfClaimsPrincipalDoesNotExist">An account could not be found for the provided user ID.</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="objectId" Required="true" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="extension_requiresMigration" DefaultValue="false" />
</OutputClaims>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
The above Technical Profile will read the extension_requiresMigration
extension attribute to determine whether the account is due migration.
- Open the extension policy file (TrustFrameworkExtensions.xml) from your working directory.
- Find the
<ClaimsProviders>
section - Add following XML snippet under the
ClaimsProviders
element
<ClaimsProvider>
<DisplayName>Local Account Password Reset - Flip migration flag</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="AAD-FlipMigratedFlag">
<Metadata>
<Item Key="Operation">Write</Item>
<Item Key="RaiseErrorIfClaimsPrincipalAlreadyExists">false</Item>
</Metadata>
<IncludeInSso>false</IncludeInSso>
<InputClaims>
<InputClaim ClaimTypeReferenceId="objectId" Required="true" />
</InputClaims>
<PersistedClaims>
<PersistedClaim ClaimTypeReferenceId="objectId" />
<PersistedClaim ClaimTypeReferenceId="migrationRequired" PartnerClaimType="extension_requiresMigration" DefaultValue="False" AlwaysUseDefaultValue="true"/>
</PersistedClaims>
<IncludeTechnicalProfile ReferenceId="AAD-Common" />
<UseTechnicalProfileForSessionManagement ReferenceId="SM-AAD" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
When AAD-FlipMigratedFlag
is called, the objectId
is used to locate the user account and then write the value of migrationRequired
into extension_requiresMigration
. Since migrationRequired
is not populated into the claims-bag during this journey, it will use the DefaultValue
of False
in all occasions.
Override the LocalAccountWritePasswordUsingObjectId
Technical Profile as follows:
- Open the extension policy file (TrustFrameworkExtensions.xml) from your working directory.
- Find the
<ClaimsProviders>
section - Add following XML snippet under the
ClaimsProviders
element
<ClaimsProvider>
<DisplayName>Local Account Password Reset - Write Password</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="LocalAccountWritePasswordUsingObjectId">
<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="Get-requiresMigration-status-password-reset" ContinueOnError="false" />
<ValidationTechnicalProfile ReferenceId="AAD-FlipMigratedFlag" ContinueOnError="false">
<Preconditions>
<Precondition Type="ClaimEquals" ExecuteActionsIf="true">
<Value>extension_requiresMigration</Value>
<Value>False</Value>
<Action>SkipThisValidationTechnicalProfile</Action>
</Precondition>
</Preconditions>
</ValidationTechnicalProfile>
<ValidationTechnicalProfile ReferenceId="AAD-UserWritePasswordUsingObjectId" />
</ValidationTechnicalProfiles>
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
When the orchestration step executes LocalAccountWritePasswordUsingObjectId
as part of the password reset user journey, it will first obtain the extension_requiresMigration
value from the user object.
If the extension_requiresMigration
value is not false
(default), then AAD-FlipMigratedFlag
Technical Profile will execute. This will change extension_requiresMigration
to false
. Finally the password is written as normal via the AAD-UserWritePasswordUsingObjectId
Technical Profile.
- In the Azure portal, switch into the context of your Azure AD B2C tenant, and click on the Azure AD B2C blade.
- Select Identity Experience Framework.
- Click on All Policies.
- Select Upload Policy
- Check Overwrite the policy if it exists box.
- Upload SeamlessMigration_TrustFrameworkBase.xml and ensure that it does not fail the validation
- Repeat last step to upload the SeamlessMigration_TrustFrameworkExtensions.xml, SeamlessMigration_SignUpOrSignin.xml and SeamlessMigration_PasswordReset.xml policy files.
- Open Azure AD B2C Settings and go to Identity Experience Framework.
- Open B2C_1A_SeamlessMigration_signuporsignin, the relying party (RP) custom policy that you uploaded. Select Run now.
- Login with an email address of an account which has extension_requiresMigration = true.
Find guidance here to help troubleshoot your policies.
Use the VSCode Extension to help develop your policies here.