Migrating from Azure AD B2C to External ID - Part 2

Migrating from Azure AD B2C to External ID

Part 2: Migration Tools and Process

Microsoft has released an official B2C to External ID Migration Kit that provides a sample implementation for migrating users with minimal downtime. This guide walks through the architecture, tools, and implementation details.

⚠️ Preview Status: The migration kit is a sample implementation showcasing the Just-In-Time password migration public preview. It's suitable for proof-of-concept and testing but may require additional security hardening for production deployments.


Migration Architecture Overview

The migration follows a two-phase approach that ensures zero downtime and smooth user experience:

Diagram


Migration Kit Components

The toolkit consists of three main components:

Component Purpose
B2CMigrationKit.Console CLI tool for bulk export and import operations
B2CMigrationKit.Function Azure Function for JIT password migration
B2CMigrationKit.Core Shared business logic and services

Project Structure

B2CMigrationKit/
├── src/
│   ├── B2CMigrationKit.Core/           # Shared services
│   │   ├── Abstractions/               # Interfaces
│   │   ├── Configuration/              # Options classes
│   │   ├── Models/                     # User, results
│   │   └── Services/
│   │       ├── Infrastructure/         # Graph, Storage clients
│   │       └── Orchestrators/          # Export, Import, JIT
│   ├── B2CMigrationKit.Console/        # CLI application
│   └── B2CMigrationKit.Function/       # Azure Function
├── scripts/                            # PowerShell helpers
└── docs/                               # Architecture & Developer guides

Phase 1: Bulk User Migration

Step 1: Export Users from B2C

The export process reads all users from your B2C tenant and saves them to Azure Blob Storage:

# Run export operation
B2CMigrationKit.Console export --config appsettings.json

Configuration (appsettings.json):

{
  "Migration": {
    "B2C": {
      "TenantId": "your-b2c-tenant-id",
      "TenantDomain": "yourb2c.onmicrosoft.com",
      "AppRegistration": {
        "ClientId": "b2c-app-client-id",
        "ClientSecret": "b2c-app-secret",
        "Enabled": true
      }
    },
    "Storage": {
      "ConnectionString": "UseDevelopmentStorage=true",
      "ContainerName": "migration-data"
    },
    "Export": {
      "SelectFields": "id,userPrincipalName,displayName,givenName,surname,mail,identities",
      "MaxUsers": 0
    }
  }
}

Export Output:

{
  "id": "00aa00aa-bb11-cc22-dd33-44ee44ee44ee",
  "userPrincipalName": "user@b2cprod.onmicrosoft.com",
  "displayName": "John Doe",
  "mail": "john.doe@example.com",
  "identities": [
    {
      "signInType": "emailAddress",
      "issuer": "b2cprod.onmicrosoft.com",
      "issuerAssignedId": "john.doe@example.com"
    }
  ]
}

Step 2: Import Users to External ID

The import process creates users in External ID with:

  • Transformed UPN - Domain changed to External ID tenant
  • Placeholder password - Random strong password (replaced during JIT)
  • Migration flag - extension_{appId}_RequireMigration = true
  • B2C ObjectId - Preserved for correlation
# Run import operation
B2CMigrationKit.Console import --config appsettings.json

Configuration additions:

{
  "Migration": {
    "ExternalId": {
      "TenantId": "your-external-id-tenant-id",
      "TenantDomain": "yourexternalid.onmicrosoft.com",
      "ExtensionAppId": "00000000000000000000000000000000",
      "AppRegistration": {
        "ClientId": "external-id-app-client-id",
        "ClientSecret": "external-id-app-secret",
        "Enabled": true
      }
    },
    "Import": {
      "MigrationAttributes": {
        "StoreB2CObjectId": true,
        "SetRequireMigration": true
      }
    }
  }
}

UPN Transformation:

The import orchestrator transforms UPNs to work with External ID:

B2C:         user@b2cprod.onmicrosoft.com
External ID: user@externalid.onmicrosoft.com

This preserves the local part (username) as a unique identifier, enabling the JIT migration to map back to B2C for password validation.


Phase 2: Just-In-Time Password Migration

The JIT migration is the key innovation - users authenticate transparently without resetting passwords.

How JIT Works

JIT password migration sequence: User signs in, External ID checks RequireMigration flag, delegates to Azure Function, validates against B2C via ROPC

Step 2: Create the encryption certificate

# Create self-signed certificate:
#   Name: JitMigrationEncryptionCert
#   Subject: CN=JitMigration
#   Key Type: RSA
#   Key Size: 2048 or 4096

3. Deploy the Azure Function

The JIT function handles the password validation:

[FunctionName("JitMigrationEndpoint")]
public static async Task<IActionResult> Run(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest req,
    ILogger log)
{
    // 1. Parse request from External ID
    var payload = await ParseRequestAsync(req, log);
    
    // 2. Decrypt password using private key from Key Vault
    var (password, nonce) = await DecryptPasswordAsync(
        payload.EncryptedPasswordContext, log);
    
    // 3. Transform UPN back to B2C format
    string b2cUpn = TransformUpnForB2C(payload.UserPrincipalName);
    // user@externalid.onmicrosoft.com → user@b2c.onmicrosoft.com
    
    // 4. Validate against B2C using ROPC
    var isValid = await ValidateWithB2CAsync(b2cUpn, password);
    
    // 5. Return appropriate action
    if (isValid)
    {
        return new OkObjectResult(new {
            data = new {
                actions = new[] { 
                    new { type = "microsoft.graph.passwordSubmit.MigratePassword" }
                },
                nonce = nonce
            }
        });
    }
    else
    {
        return new OkObjectResult(new {
            data = new {
                actions = new[] { 
                    new { type = "microsoft.graph.passwordSubmit.Retry" }
                },
                nonce = nonce
            }
        });
    }
}

4. Configure Custom Authentication Extension

Create the extension in External ID:

POST https://graph.microsoft.com/beta/identity/customAuthenticationExtensions

{
    "@odata.type": "#microsoft.graph.onPasswordSubmitCustomExtension",
    "displayName": "JIT Password Migration",
    "description": "Validates credentials against B2C during migration",
    "endpointConfiguration": {
        "@odata.type": "#microsoft.graph.httpRequestEndpoint",
        "targetUrl": "https://your-function.azurewebsites.net/api/JitMigration"
    },
    "authenticationConfiguration": {
        "@odata.type": "#microsoft.graph.azureAdTokenAuthentication",
        "resourceId": "api://your-function.azurewebsites.net/{app-id}"
    }
}

5. Create Event Listener

Link the extension to your application:

POST https://graph.microsoft.com/beta/identity/authenticationEventListeners

{
    "@odata.type": "#microsoft.graph.onPasswordSubmitListener",
    "conditions": {
        "applications": {
            "includeAllApplications": false,
            "includeApplications": [
                { "appId": "your-app-client-id" }
            ]
        }
    },
    "priority": 500,
    "handler": {
        "@odata.type": "#microsoft.graph.onPasswordMigrationCustomExtensionHandler",
        "migrationPropertyId": "extension_00000000_RequireMigration",
        "customExtension": {
            "id": "custom-extension-id"
        }
    }
}

Response Actions

The JIT function can return four actions:

Action When to Use
MigratePassword Password valid - External ID stores it and sets flag to false
UpdatePassword Password valid but weak - routes user to password reset
Retry Password invalid - user can try again
Block Authentication blocked (e.g., account locked in B2C)

Attribute Mapping

Configure how attributes are mapped between B2C and External ID:

{
  "Import": {
    "AttributeMappings": {
      "extension_b2c_CustomerId": "extension_extid_LegacyUserId",
      "extension_b2c_MembershipLevel": "extension_extid_Tier"
    },
    "ExcludeFields": [
      "createdDateTime",
      "lastPasswordChangeDateTime"
    ]
  }
}

Migration Checklist

Pre-Migration

  • Create External ID tenant
  • Register app in B2C with Graph permissions (User.Read.All)
  • Register app in External ID with Graph permissions (User.ReadWrite.All, Directory.ReadWrite.All)
  • Create extension attributes in External ID
  • Set up Azure Key Vault with encryption certificate
  • Deploy JIT Azure Function
  • Configure B2C ROPC policy for JIT validation

During Migration

  • Run bulk export from B2C
  • Validate exported data
  • Run bulk import to External ID
  • Configure Custom Authentication Extension
  • Create Event Listener
  • Test with subset of users

Post-Migration

  • Monitor JIT function logs
  • Track migration progress (RequireMigration flag)
  • Update application endpoints to External ID
  • Decommission B2C tenant after migration complete

Monitoring Progress

Track migration status with KQL:

// Users still requiring migration
externalid_users
| where extension_RequireMigration == true
| summarize PendingMigration = count()

// Migration completion over time
SigninLogs
| where TimeGenerated > ago(30d)
| where AppDisplayName == "Your App"
| summarize MigratedUsers = dcount(UserId) by bin(TimeGenerated, 1d)

Troubleshooting

Common Issues

Issue Cause Solution
ROPC fails in JIT B2C ROPC policy missing Create B2C_1_ROPC policy
UPN mismatch Domain not transformed Check TransformUpnForB2C() logic
Extension attr not found Wrong ExtensionAppId Use b2c-extensions-app ID without hyphens
Certificate error Key Vault access Enable Managed Identity, grant Get permission

What's Next


Resources


← Part 1 - Introduction | Part 3 - Future and Best Practices →

Archives