Password Migration with Entra External ID – part 1

Migrating to Entra Enternal ID from another identity provider brings with it the problem of how do you migrate the users’ passwords. In the best of worlds, only the end users should know their passwords, which then becomes a migration problem. The solution has been around for a while that the new identity provider delegates to the old, soon retired, identity provider the password validation the first time an end user logs in to the new system. This was the case with Azure AD B2C and it is till the case for Entra External ID.

The solution in Entra External ID is using a Custom Authentication Extension of type PasswordSubmitCustomExtension that makes an API call to your custom code so you can handle the password migration by calling the old identity provider.

This post is the first of two and will show you the configuration required to setup such a solution. The second post will show the code behind the API that will show you migrating from Auth0 (sorry, I had to pick one. No offence, Auth0).

The Microsoft documentation is available but be prepared that it is long, hard to follow, and also inconclusive. It leaves you with more than a few gaps.

What challanges are we facing?

Besides the obvious – do you have an API in your old system that can authenticate a user by username/password – there are a few challenges you are up for. They are:

  1. You have no Entra Admin Portal UI support and will have to configure this via Powershell.
  2. The Powershell module isn’t fully developed here, so the Powershell code will be kind of ugly.
  3. When you have configured it, Entra will send not the usual JWT access token to your app but an encrypted JWE access token – which will make your aspnet authentication middleware unhappy without code changes

On the positive side, when you have configured it, it really works well!

Create a Certificate in KeyVault

First, you need to create a self-signed certificate in Azure KeyVault as documented here. To be fair, you could do without KeyVault and generate the certificate yourself, but since there will be a production migration here at the end, you should stick to using KeyVault.

The KeyVault and the Azure subscription can be in your normal location and needs no relationship with the Entra External tenant. However, this certificate is more important than the Microsoft documentation tells you. Not only does it encrypt the password that the end user types in when trying to authenticate for the first time in the User Flow, it also encrypts the Entra access token being passed for authorization to your API. As soon as you have run a piece of powershell later, your access token will be a JWE and not a JWT which will completley disrupt your API. So timing configuration and code deployment is important here. KeyVault creates a self-signed certificate with a liftetime of a year, so the clock starts ticking for your migration period once you generated the certificate. For better timing of your production migration, consider generating your own self-signed certificate with openssl and upload it to KeyVault.

As the documentation explains, after you have created it in KeyVault, download it in CER format as a file. We will get back to it with Powershell later when you add it as a KeyCredential to your custom extension app.

Extension attribute toBeMigrated

You need to create an extension attribute to track each users migration status. The documentation calls it toBeMigrated, but it could have any name you like. However, an extension attribute must be registered on an application and in this case, it needs to be registered on the b2c-extensions application. The below Powershell code creates the extension attribute.

$b2cExt = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/applications?`$filter=startswith(displayName, 'b2c-extension')"
$extAttr = "extension_"+$b2cExt.value.appId.Replace("-","")+"_toBeMigrated"

Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/applications/$($b2cExt.value.id)/extensionProperties" -Body "{'name': 'toBeMigrated', 'dataType': 'Boolean', 'targetObjects':[ 'User' ] }"

Create the custom authentication extension for password migration

Entra needs to know where our endpoint to the password migration extension is. But as we learned it the previous post, Entra needs an app registration with the appropriate API permission to call our API. We assume that this appreg is already created and all we need to do is get a reference to it.

$extApp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/applications?`$filter=displayName eq '$customExtensionAppName'"

Once we have this app’s reference, we can register our extension. This is the powershell for registering the password migration extension. Note that you need to update the API endpoint targetUrl to meet your needs.

$extensionParams = @"
{
  "@odata.type": "#microsoft.graph.onPasswordSubmitCustomExtension",
  "displayName": "OnPasswordSubmitCustomExtension",
  "description": "Validate password",
  "endpointConfiguration": {
    "@odata.type": "#microsoft.graph.httpRequestEndpoint",
    "targetUrl": "$("https://$apiHostingDomain/api/authenticationevent/passwordmigration")"
  },
  "authenticationConfiguration": {
    "@odata.type": "#microsoft.graph.azureAdTokenAuthentication",
    "resourceId": "$("api://$apiHostingDomain/$($extApp.value.appId)")"
  },
  "clientConfiguration": {
    "timeoutInMilliseconds": 2000,
    "maximumRetries": 1
  }
}
"@

Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/identity/customAuthenticationExtensions" -Body $extensionParams
$authExt = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/identity/customAuthenticationExtensions"
$authExtPwdMigration = ($authExt.value | where {$_.displayName -eq "OnPasswordSubmitCustomExtension"})

Bind the extension to your client application

The extension must be bound to your client app as otherwise, how would your client app know that it should call your API. First we need to retrieve the client app’s AppID, then we need to bind it to using the authentication listener. The binding points to two things: first the extensions itself, but second it also references the extension attribute in the migrationPropertyId value. This means “if this property is true, call this extension handler to take care of business”.

$clientApp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/applications?`$filter=displayName eq '$appName'"

$evtListener = @"
{  
    "@odata.type": "#microsoft.graph.onPasswordSubmitListener",  
    "conditions": {  
        "applications": {  
            "includeAllApplications": false,  
            "includeApplications": [  
                {  
                    "appId": "$($clientApp.value[0].appId)"  
                }  
            ]  
        }  
    },  
    "priority": 500,  
    "handler": {  
        "@odata.type": "#microsoft.graph.onPasswordMigrationCustomExtensionHandler",  
        "migrationPropertyId": "$extAttr",  
        "customExtension": {  
            "id": "$($authExtPwdMigration.id)"  
        }  
    }  
}  
"@

Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/beta/identity/authenticationEventListeners" -Body $evtListener

Add the key credentials to your extension app

In order for the password to be delivered securely and encrypted to your app, we need to add the KeyVault certificate to the extension app. At this point you must have downloaded the KeyVault certificate in the CER format to your local machine. Set the full path of the file to variable $certFullPath. Notice that the tokenEncryptionKeyId is set to the generated KeyId value. This will tell Entra to “please encrypt any tokens using this certificate”. Once you invoke the PATCH request in the below code, all API calls to your custom extension using the same extension appreg will use an encrypted JWE access token. This will require a code change explained in the next post.

$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certFullPath)
$certBase64 = [Convert]::ToBase64String($cert.RawData)

$keyCredentials = @"
{
  "keyCredentials": [
    {
      "keyId": "$((New-Guid).Guid.ToString())",
      "endDateTime": "$($cert.NotBefore.toUniversaltime().ToString("o"))",
      "startDateTime": "$($cert.NotAfter.toUniversaltime().ToString("o"))",
      "type": "AsymmetricX509Cert",
      "usage": "Encrypt",
      "key": "$certBase64",
      "displayName": "CN=JitMigration"
    }
  ],
  "tokenEncryptionKeyId": ""
}
"@

$KeyCredentials = ($KeyCredentials | ConvertFrom-json)
$KeyCredentials.tokenEncryptionKeyId = $KeyCredentials.keyCredentials[0].keyId

$extApp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/applications?`$filter=displayName eq '$customExtensionAppName'"
Invoke-MgGraphRequest -Method PATCH -Uri "https://graph.microsoft.com/v1.0/applications/$($extApp.value.id)" -Body ($KeyCredentials | ConvertTo-Json -Depth 10)