Automating Azure AD B2C creation with PowerShell Core
azure b2c powershell

How to create a B2C tenant with a script, end-to-end including custom attributes, user flows and application registrations.
November 26, 2021

This post is a continuation of my Azure AD B2C scripting journey...

There's also a GitHub repository with full scripts.


Creating an Azure AD B2C tenant manually, using the Azure Portal, is relatively simple task mainly thanks to the UI, which directs your steps quite well. What's not so easy is automating this, either with scripts or deployment templates.

Why?

Nevertheless, it is possible to write an end-to-end script that provisions a new B2C tenant and this blogpost summarizes how.

Setting up the tenant is usually a one-time thing, so why is this helpful? My particular scenario was portability of environments - being able to deploy a complete solution, including Azure infrastructure, code and identity, in an automated fashion to any subscription. It can be useful for solutions which provide services to multiple customers and need to frequently create new tenants with predefined structure.

tl;dr

It's possible to use a script to create and automatically provision a B2C tenant, using a combination of Microsoft Graph PowerShell (or REST APIs), Azure management APIs and Azure CLI.

It's quite hard to summarize into tl;dr, so just scroll through the article or check the code repo.

Final state

The goal of this exercise is to provide basic configuration, kick off a PowerShell script and after a while have a fully prepared Azure AD B2C tenant. This tenant should contain a custom user attribute definition, sign in user flow that uses this attribute, application registration for a client app that users will log into, application registration for a service app which will have access to Microsoft Graph APIs and a set of users with the custom attribute value set. All admin consents should be granted automatically too.

B2C tenant creation and provisioning

Full script can be found on GitHub, this post walks through key parts of the solution and highlights some of the complications that were solved.

The process is designed as a series of PowerShell functions which could work individually or as a chain. These are the steps:

  1. Create B2C tenant
  2. Sign in to Microsoft Graph PowerShell
  3. Create custom attribute
  4. Create user flows
  5. Create app registrations
  6. Assign Microsoft Graph access
  7. Grant admin consent
  8. Import users

Let's unpack it and go step by step.

Prerequisites

PowerShell Core

Microsoft.Graph PowerShell module.

Azure CLI, authenticated with access to the targeted Azure subscription.

All the scripts used in this process should work in PowerShell Core on all platforms (nothing is Windows-specific).

In terms of permissions, things are obviously easier with admin access to the Azure subsription. But majority of the work is done within the newly created B2C tenant, which is inherently owned by the creator.

Create B2C tenant

Currently it's not possible to create new B2C tenant with Terraform or Azure Resource Manager. REST APIs can be used though - I prefer Azure CLI and the az rest command, but Azure PowerShell (Invoke-AzRestMethod) would work too.

First ensure that the Microsoft.AzureActiveDirectory provider is registered:

$aadProviderRegState = $(az provider show -n Microsoft.AzureActiveDirectory --query "registrationState" -o tsv)
if($aadProviderRegState -ne "Registered")
{
  Write-Host "Resource Provider 'Microsoft.AzureActiveDirectory' not registered yet. Registering now..."
  az provider register --namespace Microsoft.AzureActiveDirectory

  while($(az provider show -n Microsoft.AzureActiveDirectory --query "registrationState" -o tsv) -ne "Registered")
  {
    Write-Host "Resource Provider registration not yet finished. Waiting..."
    Start-Sleep -Seconds 10
  }
  Write-Host "Resource Provider registration finished."
}

Then check if the tenant already exists:

if (!$AzureSubscriptionId) {
  Write-Host "Getting subscription ID from the current account..."
  $AzureSubscriptionId = $(az account show --query "id" -o tsv)
  Write-Host $AzureSubscriptionId
}

$resourceId = 
"/subscriptions/$AzureSubscriptionId/resourceGroups/$AzureResourceGroup/providers/Microsoft.AzureActiveDirectory/b2cDirectories/$B2CTenantName.onmicrosoft.com"

# Check if tenant already exists
Write-Host "Checking if tenant '$B2CTenantName' already exists..."
az resource show --id $resourceId | Out-Null
if($LastExitCode -eq 0) # No error means, the resource exists
{
  Write-Warning "Tenant '$B2CTenantName' already exists. Not attempting to recreate it."
  return
}

And start RESTing:

$reqBody=@"
{
  "location":"$($Location)",
  "sku": {
      "name":"Standard",
      "tier":"A0"
  },
  "properties": {
      "createTenantProperties": {
          "displayName":"$($B2CTenantName)",
          "countryCode":"$($CountryCode)"
      }
  }
}
"@

# Flatten the JSON to make Azure CLI happy, otherwise it complains about incorrect content type.
$reqBody = $reqBody.Replace("`n", "").Replace("`"", "\`"")

Write-Host "Creating B2C tenant $B2CTenantName..."
# https://docs.microsoft.com/en-us/rest/api/activedirectory/b2c-tenants/create
az rest --method PUT --url "https://management.azure.com$($resourceId)?api-version=2019-01-01-preview" --body $reqBody

if($LastExitCode -ne 0)
{
    throw "Error on creating new B2C tenant!"
}

Write-Host "*** B2C Tenant creation started. It can take a moment to complete."

do
{
  Write-Host "Waiting for 30 seconds for B2C tenant creation..."
  Start-Sleep -Seconds 30

  az resource show --id $resourceId
}
while($LastExitCode -ne 0)

We're not done yet. The newly created tenant is not fully provisioned yet. Azure Portal does a bit of magic when you enter the management site for the first time and we need to replicate that:

function Invoke-TenantInit {
  param (
    [string] $B2CTenantName
  )

  $B2CTenantId = "$($B2CTenantName).onmicrosoft.com"

  # Get access token for the B2C tenant with audience "management.core.windows.net".
  $managementAccessToken = $(az account get-access-token --tenant "$B2CTenantId" --query accessToken -o tsv)

  # Invoke tenant initialization which happens through the portal automatically.
  Write-Host "Invoking tenant initialization..."
  Invoke-WebRequest -Uri "https://main.b2cadmin.ext.azure.com/api/tenants/GetAndInitializeTenantPolicy?tenantId=$($B2CTenantId)&skipInitialization=false" `
    -Method "GET" `
    -Headers @{
      "Authorization" = "Bearer $($managementAccessToken)"
    }
}

The GET call to GetAndInitializeTenantPolicy creates the "b2c-extensions-app" application, which is needed to manage custom attributes on users.

The tricky part of this script is to get the access token for main.b2cadmin.ext.azure.com. I'm using Azure CLI, because it conveniently provides a token with audience management.core.windows.net, which is accepted by this API.

Sign in to Microsoft Graph PowerShell

I'm using the PowerShell module for Microsoft Graph to sign-in interactively with administrator's account and get permissions to perform Application, User and Directory operations.

Connect-MgGraph -TenantId "$($B2CTenantName).onmicrosoft.com" -Scopes "User.ReadWrite.All", "Application.ReadWrite.All", "Directory.AccessAsUser.All", "Directory.ReadWrite.All"

This commandlet should open a browser window and after sign in should ask to grant permissions to Graph PowerShell.

Every time you need to extend/reduce scope of your session, simply change the -Scopes value and run this command again. You will be prompted for the updated permissions.

An alternative to the interactive login is app-only authentication, using a service principal with certificate:

Connect-MgGraph -ClientID "<client ID>" -TenantId "$($B2CTenantName).onmicrosoft.com" -Certificate $Certificate

Create custom attribute

Creating user attributes is super easy in Azure Portal as it can be done nicely with the UI:

image-20211115131826616

Values of user attributes can be returned as claims, if configured in user flows (e.g. after sign in).

Accessing user attributes programmaticaly is a little more difficult, because there are multiple types of extensions and it isn't obvious which one to choose.

User attributes are represented in Microsoft Graph as application extension properties on the b2c-extensions-app application, but I wasn't able to make it available in user flows by using the New-MgApplicationExtensionProperty command.

Note: This design implies that if the b2c-extensions-app is deleted, custom properties are removed from all users along with the data. Also, if an extension attribute is deleted, it's automatically removed from all user accounts and values are deleted.

The correct Microsoft Graph endpoint is: /identity/userFlowAttributes, but it doesn't seem to have a PowerShell representation and I don't have the Microsoft Graph access token handy at this point of the script.

In the end I just replicated what the Azure Portal does and called the main.b2cadmin.ext.azure.com API again:

function Add-CustomAttribute {
  param (
    [string] $B2CTenantName,
    [string] $AttributeName,
    [string] $Description
  )

  $B2CTenantId = "$($B2CTenantName).onmicrosoft.com"

  # Get access token for the B2C tenant with audience "management.core.windows.net".
  $managementAccessToken = $(az account get-access-token --tenant $B2CTenantId --query accessToken -o tsv)
  $reqBody = @"
{
  "dataType": 2,
  "label": "$($AttributeName)",
  "adminHelpText": "$($Description)",
  "userInputType": 1,
  "userAttributeOptions": [],
  "attributeType": 3
}
"@

  # Create the attribute using the same method as the Portal.
  Write-Host "Creating custom attribute $($AttributeName)..."
  Invoke-WebRequest -Uri "https://main.b2cadmin.ext.azure.com/api/userAttribute?tenantId=$($B2CTenantId)" `
    -Method "POST" `
    -Headers @{
      "Authorization" = "Bearer $($managementAccessToken)";
      "Content-Type" = "application/json"
    } `
    -Body $reqBody
}

Request body parameters:

Create user flow

One of the nice features of Azure AD B2C are predefined policies in the form of user flows for the most common identity tasks. In the UI it's, again, very simple to set up and code integration is quite straightforward too.

Create user policy in Azure Portal

When it comes to automation, although user flows are present in the Beta endpoint of Microsoft Graph (and Microsoft Graph PowerShell as New-MgIdentityUserFlow etc.), the API currently doesn't support all functionality - for instance, selecting user claims is missing.

But the Azure Portal does it somehow... Which brings me back to the old trusted main.b2cadmin.ext.azure.com API.

function Add-UserFlow {
  param(
    [string] $B2CTenantName,
    [string] $DefinitionFilePath
  )

  $B2CTenantId = "$($B2CTenantName).onmicrosoft.com"

  # Get access token for the B2C tenant with audience "management.core.windows.net".
  $managementAccessToken = $(az account get-access-token --tenant $B2CTenantId --query accessToken -o tsv)

  Write-Host "Creating $($DefinitionFilePath) user flow..."
  $signinFlowContent = Get-Content $DefinitionFilePath
  # Using WebRequest here, because Microsoft Graph is currently not able to create user flows with custom properties.
  Invoke-WebRequest -Uri "https://main.b2cadmin.ext.azure.com/api/adminuserjourneys?tenantId=$($B2CTenantId)" `
    -Method "POST" `
    -Headers @{
      "Authorization" = "Bearer $($managementAccessToken)";
      "Content-Type" = "application/json"
    } `
    -Body $signinFlowContent
}

This function expects a path to the flow definition JSON file to be included with the HTTP request. For my signin flow the file looks like this:

{
  "id": "signin",
  "type": "B2CSignIn_V3",
  "protocol": "OpenIdConnect",
  "booleanData": {
    "mfaDisable": true,
    "allowEmailFactor": true
  },
  "idpData": {
    "idpSelection": [
      {
        "technicalProfileId": "SelfAsserted-LocalAccountSignin-Email-V3-SignInOnly"
      }
    ]
  },
  "userAttributesData": {
    "claims": [
      {
        "id": "displayName"
      },
      {
        "id": "emails"
      },
      {
        "id": "extension_MyAttribute"
      },
      {
        "id": "objectId"
      }
    ]
  }
}

Create application registration

Moving on and out of the REST API world for a while. In this example, we're going to create an application registration in our new tenant with the following result state:

There's a lot of stuff going on in this script, so let's take it step by step.

Create custom permission scope

First, the definition of custom permission scope. It has to be done in two steps: create the scope and the application registration, then update the identifier URI with the newly created app's ID.

# Create the Games.Access permission scope
$gamesAccessScope = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphPermissionScope
$gamesAccessScope.AdminConsentDescription = "Allows the app to access to game data on behalf of a user."
$gamesAccessScope.AdminConsentDisplayName = "Access Games"
$gamesAccessScope.Id = New-Guid
$gamesAccessScope.IsEnabled = $true
$gamesAccessScope.Type = "Admin"
$gamesAccessScope.Value = "Games.Access"

# Create the UI application
$alwaysOnUI = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphApplication
$alwaysOnUI.DisplayName = "AlwaysOn UI"
$alwaysOnUI.SignInAudience = "AzureADandPersonalMicrosoftAccount"
$alwaysOnUI.Spa.RedirectUris = "http://localhost:8080"
$alwaysOnUI.Web.ImplicitGrantSettings.EnableAccessTokenIssuance = $true
$alwaysOnUI.Web.ImplicitGrantSettings.EnableIdTokenIssuance = $true
$alwaysOnUI.IsFallbackPublicClient = $true
$alwaysOnUI.Api.Oauth2PermissionScopes = $gamesAccessScope

# Create the app in B2C
$alwaysOnUI = New-MgApplication -BodyParameter $alwaysOnUI

Next step is to assign resource access permissions. For custom scope (defined above as Games.Access) it would look like this:

 # Adding Games.Access API permission
 $gamesRRA = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess
 $gamesRRA.ResourceAccess = @{ Id = $gamesAccessScope.Id; Type = "Scope" }
 $gamesRRA.ResourceAppId = $alwaysOnUI.AppId # ID of the resource that application requires access to - it's the same in this case

In general, resource access assignments need ID of the scope and App ID of the application which provides this scope.

Declare Microsoft Graph access scope

In comparison, for Microsoft Graph the access definition looks like this:

# Well-known ID for offline_access = 7427e0e9-2fba-42fe-b0c0-848c9e6a8182
$offlineAccessScope = @{ Id = "7427e0e9-2fba-42fe-b0c0-848c9e6a8182"; Type = "Scope" }

# Well-known ID for openid = 37f7f235-527c-4136-accd-4a02d197296e
$openidScope = @{ Id = "37f7f235-527c-4136-accd-4a02d197296e"; Type = "Scope" }

# offline_access and openid scopes are tied to the same app
$graphRRA = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess
$graphRRA.ResourceAccess = @($offlineAccessScope, $openidScope)
$graphRRA.ResourceAppId = "00000003-0000-0000-c000-000000000000" # Well-known ID, the same across all tenants

You can see that there are a few hard-coded IDs. We can do that, because these are defined on the global Microsoft.Graph application and are constant across tenants.

In order to find the ID of a particular scope, we can query Microsoft Graph:

https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '00000003-0000-0000-c000-000000000000'&$select=appRoles, oauth2PermissionScopes

The appId of Microsoft Graph will always be 00000003-0000-0000-c000-000000000000. A list of common application IDs of Microsoft applications can be found in the documentation.

In the end, the code creates a PowerShell object of type MicrosoftGraphRequiredResourceAccess, which combines resource access requests for both scopes ($offlineAccessScope, $openidScope). Each ResourceAppId can be represented by exactly one required resource access object and can contain one or more scopes in ResourceAccess property.

Update permission scopes on the application

With both access requests prepared, we can combine them:

$resourceAccessList = @(
  $gamesRRA
  $graphRRA
)

And finally update the application registration:

Update-MgApplication `
  -ApplicationId $alwaysOnUI.Id `
  -RequiredResourceAccess $resourceAccessList `
  -IdentifierUris "https://$($B2CTenantName).onmicrosoft.com/$($alwaysOnUI.AppId)"

(This snippet also updates the IdentifierUris property, because the example application is designed to use it's own ID as identifier. This is another thing that the Azure Portal provides automatically.)

Depending on the permissions the application is asking to have, it will most probably need an admin consent. Our own scope requires it, so let's start with that.

Granting the admin consent is a simple operation in the portal UI (just a click of a button), but it's a little more complicated to do in a script.

image-20211125131732897

First of all, a service principal for the application registration needs to be created. This is done automatically by the portal, but when creating the application with PowerShell it requires an extra step.

$servicePrincipal = New-MgServicePrincipal -AppId $alwaysOnUI.AppId

This command uses the App ID of the app created earlier.

Then the admin consent itself can be granted:

New-MgOauth2PermissionGrant `
  -ConsentType AllPrincipals `
  -ClientId $servicePrincipal.Id `
  -Scope $gamesAccessScope.Value `
  -ResourceId $servicePrincipal.Id

(In this case the ClientId and ResourceId are the same, because the application is technically granting access to itself.)

Probably a more common situation is with Microsoft Graph APIs, where the application which is asking to get tenant-wide Graph permissions (like User.Read.All or Application.ReadWrite.All) requires admin consent.

Similarly to our own application, we are going to need a service principal. The difference here is that the Microsoft Graph service principal already exists.

How to get its ID? By asking Microsoft Graph, obviously :)

$graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"

Using the well known application ID of Microsoft Graph (00000003-0000-0000-c000-000000000000). Unlike the service principal ID, this doesn't change across tenants.

Then define the scope, this time in more object-oriented way than in the example before. But in the end the code behaves the same:

$userReadAllScope = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphResourceAccess
$userReadAllScope.Id = "df021288-bdef-4463-88db-98f22de89214" # Well-known ID, the same across all tenants
$userReadAllScope.Type = "Role" # Application permissions

$graphRequiredResourceAccess = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess
$graphRequiredResourceAccess.ResourceAccess = $userReadAllScope
$graphRequiredResourceAccess.ResourceAppId =  $graphServicePrincipal.AppId

Create new application registration and service principal:

$alwaysOnGraphClient = New-MgApplication `
                        -DisplayName "AlwaysOn Graph Client" `
                        -SignInAudience "AzureADandPersonalMicrosoftAccount" `
                        -RequiredResourceAccess $graphRequiredResourceAccess
                        
$workerServicePrincipal = New-MgServicePrincipal -AppId $alwaysOnGraphClient.AppId

# Create secret for the application. This has to be done after the app is created.
$secret = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphPasswordCredential
$secret.KeyId = New-Guid
$secret.DisplayName = "secret"

Write-Host "Creating secret for the ResultWorker application..."
$secret = Add-MgApplicationPassword -ApplicationId $alwaysOnGraphClient.Id -PasswordCredential $secret

(Secret is important, because it's used when getting access token when using the service principal.)

And finally provide admin consent:

New-MgServicePrincipalAppRoleAssignment `
  -ServicePrincipalId $workerServicePrincipal.Id `
  -ResourceId $graphServicePrincipal.Id `
  -AppRoleId $userReadAllScope.Id `
  -PrincipalId $workerServicePrincipal.Id

Import users

Next challenge: Create users in the B2C tenant and make sure that they have the custom attribute populated. Currently, this cannot be done from the UI (extensions are not surfaced to the user profile page), so scripting is the only way.

Custom attributes in B2C are stored in the "b2c-extensions-app ..." which is created for each tenant.

b2c-extensions-app

To set custom attribute value on a user, AdditionalProperties need to be used. The extension property must have this form:

extension_<extension app ID>_<property name>

Where <extension app ID> is the app ID of the b2c-extensions-app per tenant. An easy way to get the right property name is by querying Graph and depending on the naming convention:

$myExtensionProp = "extension_$((Get-MgApplication | Where-Object { $_.DisplayName.StartsWith("b2c-extensions-app") }).AppId.Replace('-', ''))_MyProperty";

Creation of a user account is straightforward with Graph PowerShell:

# Create a password
$passwordProfile = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphPasswordProfile
$user | Add-Member -NotePropertyName password -NotePropertyValue (Get-RandomPassword -Length 14) # generate a random password - see below
$passwordProfile.Password = $user.password
$passwordProfile.ForceChangePasswordNextSignIn = $false # configure the password profile based on security requirements

# Create identity for the user
$identity = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphObjectIdentity
$identity.SignInType = "emailAddress"
$identity.Issuer = $B2CTenantId
$identity.IssuerAssignedId = "martin@mydomain.cz"

# Use the extension property name we created above
$extension = @{
  $myExtensionProp = "SomeValue"
}

# Execute the user creation on Graph
New-MgUser -DisplayName "Martin" `
  -AccountEnabled `
  -Identities $identity `
  -PasswordProfile $PasswordProfile `
  -CreationType LocalAccount `
  -AdditionalProperties $extension

This script creates a B2C native user (no Microsoft Account, Facebook or Google integration) who will use their e-mail address to log in. This address can be anything (doesn't even need to exist). Depending on security requirements, the password profile can be configured differently or with more complex password.

Helper function to generate a basic random password, safe for use with service principal secrets too:

function Get-RandomPassword {
  param(
    [int] $Length
  )

  # Selection of PowerShell-safe characters for the password.
  $ascii = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".ToCharArray()
  # more complex:
  #$ascii = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{]+-[*=@:)}$^%;(_!#?>/|.".ToCharArray()

  for ($loop = 1; $loop le $length; $loop++) {
    $TempPassword += ($ascii | Get-Random)
  }

  return $TempPassword
}

Done. We have now successfully created a new B2C tenant and filled it with applications, user flows and user accounts.

How to delete the tenant

It might be tempting to simply go to the Azure Portal, find the B2C tenant resource and click Delete. If you're paying attention to dialog boxes, you will learn that what you are about to delete is actually just the link to your subscription, but not the tenant itself.

image-20211125132502862

A correct approach is to use the Delete tenant functionality from within the tenant and complete all steps listed there.

See Clean up resources and delete the tenant for full guidance.

References

Full script can be found on GitHub.

Found something inaccurate or plain wrong? Was this content helpful to you? Let me know!

📧 codez@deedx.cz