Automating Azure AD B2C creation with Terraform
azure b2c terraform

Trying to create and provision a B2C tenant with just Terraform. How far can I get?
January 13, 2022

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

There's also a GitHub repository with full scripts.


After having fun with PowerShell Core and Azure AD B2C in the previous blogposts, I still wondered about Terraform. I know it's not possible to create 100% of the resources, but how far can it get me?

Here are the things that I covered using PowerShell and various Microsoft APIs. I'd like to achieve the same with Terraform and as a tl;dr, the icons indicate if I was successful (where ➖ means partially).

  1. ✔️ Create B2C tenant
  2. ❌ Create custom user attribute
  3. ❌ Create user flows
  4. ✔️ Create application registration with certificate authentication
  5. ✔️ Create application registration with API and custom scope
  6. ✔️ Assign access to Microsoft Graph
  7. ✔️ Grant admin consent
  8. ❌ Import users with custom attributes
  9. ➖ Update redirect URI
  10. ❌ Destroy

Terraform providers

There are two main Terraform providers for Azure: azurerm and azuread. Combining these two is necessary, because some operations (tenant creation) are done on the Azure Resource Manager level, while other resources (such as application registrations) are managed within a particular B2C tenant.

The complication comes with configuration of the azuread provider. It's using the standard provider block to supply tenant information:

provider "azuread" {
  tenant_id = "..guid.."
}

The ID of the tenant is not known before the deployment starts (the tenant itself has to be created as part of it first) and it's not possible to directly reference the Terraform resource in a provider configuration. However there's a way to provide the tenant_id value as a variable, if we encapsulate the provider configuration within a module.

✔️ Create B2C tenant

Version 2.91.0 of the azurerm Terraform provider added support for B2C tenants, so it's now possible to create the tenant in the same way as other Azure resources. It's not ideal and I wouldn't recommend it, though.

resource "azurerm_aadb2c_directory" "tenant" {
  country_code            = "CZ"
  data_residency_location = "Europe"
  display_name            = var.tenant_name
  domain_name             = "${var.tenant_name}.onmicrosoft.com"
  resource_group_name     = azurerm_resource_group.deployment.name
  sku_name                = "PremiumP1"
}

Beware: If you change any property that causes replacement of the tenant (such as country_code, data_residency_location or even display_name), you'll get stuck in a destroy/import loop. Terraform will try to destroy the tenant resource (which is not a trivial operation) and then create another one with the same name, causing the "Resource already exists" error. After importing, the changes still cannot be applied, because the tenant has to be recreated...

I had to resolve this by following the tenant deletion procedure and then the creation worked again in Terraform. As long as there are no changes made to the tenant definition, subsequent "applies" work fine.

To create the tenant and then continue automatically to applications, it's necessary to wrap B2C-related resources in to a module, which accepts the tenant ID as a variable:

main.tf

resource "azurerm_aadb2c_directory" "tenant" {
  country_code            = "CZ"
  data_residency_location = "Europe"
  display_name            = var.rg_name
  domain_name             = "${var.rg_name}.onmicrosoft.com"
  resource_group_name     = azurerm_resource_group.deployment.name
  sku_name                = "PremiumP1"
}

module "tenant" { source = "./tenant"

tenant_id = azurerm_aadb2c_directory.tenant.tenant_id tenant_domain_name = azurerm_aadb2c_directory.tenant.domain_name }

tenant/variables.tf

variable "tenant_id" {
  type = string
}

variable "tenant_domain_name" {
  type = string
}

tenant/providers.tf

provider "azuread" {
  tenant_id = var.tenant_id
}

To create just the tenant and get its ID, an output and targeted apply can be used:

output "tenant_id" {
  value = azurerm_aadb2c_directory.tenant.tenant_id
}
terraform apply -target="azurerm_aadb2c_directory.tenant"

Apply complete with tenant ID

This ID can then be used in the azuread provider configuration.

provider "azuread" {
  tenant_id = "fffab8ac-e5a4-4748-bf3d-3e99a5456a76"
}

❌ Create custom user attribute

Not possible with Terraform.

❌ Create user flows

Not possible with Terraform.

✔️ Create application registration with certificate authentication

The azuread module supports this - application registrations can be managed with the azuread_application resource and it works for both Azure AD and Azure AD B2C.

In order to use the certificate authentication, the cert file has to be created in advance.

When striving for 100% Terraform, one idea to solve this could be using the local-exec provider and creating the certificate with it. I haven't tried it myself though.

The owners property is intentionally not specified, because it was causing issues in the module-based deployment. In this setup, owners is automatically populated with the B2C identity that created the tenant.

resource "azuread_application" "graph_worker" {
  display_name     = "GraphWorker_App"
  sign_in_audience = "AzureADMyOrg"

web { redirect_uris = ["http://localhost/"] } }

resource "azuread_application_certificate" "graph_worker" { application_object_id = azuread_application.graph_worker.id type = "AsymmetricX509Cert" value = filebase64("./certs/graphworker-cert.cer") end_date_relative = "17532h" # 2 years }

✔️ Create application registration with API and custom scope

This works fine with Terraform.

The identifier_uris array can only contain verified domains for the tenant, or default (.onmicrosoft.com). Thanks to Terraform we can simply reference the tenant created earlier, which conveniently exports its full name as the domain_name property.

data "azuread_application_published_app_ids" "well_known" {}

resource "random_uuid" "gameaccess_scope_id" {}

resource "azuread_application" "api" { display_name = "Api" sign_in_audience = "AzureADandPersonalMicrosoftAccount"

identifier_uris = [ "https://${azurerm_aadb2c_directory.tenant.domain_name}/apinka" ]

api { requested_access_token_version = 2 oauth2_permission_scope { admin_consent_description = "Allows the app to access to game data on behalf of a user." admin_consent_display_name = "Access Games" enabled = true id = random_uuid.gameaccess_scope_id.result type = "Admin" value = "Games.Access" } }

web { implicit_grant { access_token_issuance_enabled = true id_token_issuance_enabled = true } }

single_page_application { redirect_uris = [ "http://localhost:8080/" ] }

required_resource_access { resource_app_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph

# Terraform datasource of well_known IDs doesn't contain openid and offline_access
resource_access {
  id   = "37f7f235-527c-4136-accd-4a02d197296e" # openid
  type = "Scope"
}

resource_access {
  id   = "7427e0e9-2fba-42fe-b0c0-848c9e6a8182" # offline_access
  type = "Scope"
}

} }

This sample also requests the openid and offline_access scopes for the application registration, which are not present in the azuread_service_principal.msgraph.app_role_ids array. So I just entered them manually.

✔️ Assign access to Microsoft Graph

Can be done with Terraform and it's actually very easy.

data "azuread_application_published_app_ids" "well_known" {}

resource "azuread_service_principal" "msgraph" { application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph use_existing = true }

resource "azuread_application" "graph_worker" { display_name = "GraphWorker_App" sign_in_audience = "AzureADMyOrg"

web { redirect_uris = ["http://localhost/"] }

required_resource_access { resource_app_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph

resource_access {
  id   = azuread_service_principal.msgraph.app_role_ids["Application.ReadWrite.All"]
  type = "Role"
}

} }

resource "azuread_service_principal" "graph_worker" { application_id = azuread_application.graph_worker.application_id }

This example demonstrates one of the aspects where Terraform beats custom scripting: setting up resource access to Microsoft Graph. Thanks to the data source azuread_application_published_app_ids, which contains a wide range of official Microsoft apps and APIs, it's not necessary to work directly with GUIDs.

Another nice helper is the azuread_service_principal.msgraph.app_role_ids array, which contains IDs for Graph scopes. One caveat here is that offline_access and openid are missing from this list.

Again, very easy with Terraform.

# ... continuing the previous sample

resource "azuread_app_role_assignment" "graph_worker" { app_role_id = azuread_service_principal.msgraph.app_role_ids["Application.ReadWrite.All"] principal_object_id = azuread_service_principal.graph_worker.object_id resource_object_id = azuread_service_principal.msgraph.object_id }

Similar to calling Microsoft Graph directly with PowerShell, our application's service principal has to be created separately (as azuread_service_principal).

❌ Import users with custom attributes

My application requires B2C local user accounts with custom attributes. Although the Terraform azuread_user is able to create user accounts in an AAD tenant (including B2C), it's impossible to use it for two important things:

  1. set custom attribute value on a user
  2. create local account which uses any e-mail address for login, regardless of the tenant domain

Let's see the implementation and why it doesn't work. There's a JSON list of users, which I want to import, and Terraform code which iterates through it:

users.json

{
   "users": [
    {
      "displayName": "Heidi Gamemasterova",
      "email": "heidi.gamemaster@martinovo.demo",
      "gameMaster": true
    },
    {
      "displayName": "Thomas Smoke-Tester",
      "email": "thomas.smoketester@martinovo.demo",
      "gameMaster": false
    }
  ]
}

users.tf

# load user list from file
# iterate and create dynamically
locals {
  users_file = jsondecode(file("users.json"))
}

resource "azuread_user" "user" { for_each = { for user in local.users_file.users : user.email => user }

user_principal_name = each.key display_name = each.value.displayName password = "SecretP@sswd99!" }

This code looks nice, and even uses a nice method to parse JSON and iterate through an array. Unfortunately it doesn't work for several reasons.

First, the required parameter user_principal_name must be from a "verified domain", so you'll get a 400 Bad Request error, saying that the domain portion is invalid.

UsersClient.BaseClient.Post(): unexpected status 400 with OData error: Request_BadRequest: The domain portion of the userPrincipalName property is invalid. You must use one of the verified domain names in your organization.

This is actually correct, because B2C is generating this value automatically and uses the object_id of the user:

User principal name of the user

In PowerShell, I was able to use the Identities property to set any username, regardless of domain. That's not possible in Terraform, because it doesn't contain this property.

Getting full user by ID

This is what Microsoft Graph returns when we query for "identities" directly:

image-20211206143211545

The other thing, which is missing in Terraform, are custom attributes on users.

User attributes are present on the user entity and are accessible from Microsoft Graph, but aren't included in the standard set - so when you just GET a user, you won't see them. To get the value of a particular custom attribute, it has to be queried explicitly, using $select:

https://graph.microsoft.com/v1.0/users/[user ID]?$select=extension_[extension app ID]_GameMaster

Where [user ID] is the id of the user and [extension app ID] is the application ID of the "b2c-extensions-app" of a particular tenant, without dashes (so this: 140fcb03-b8c9-414e-99ce-5f2e547adbb5 would become this: 140fcb03b8c9414e99ce5f2e547adbb5).

There's no way to achieve this with azuread Terraform provider.

➖ Update redirect URI

It's possible to manage redirect URIs with Terraform, because the app registration contains redirect_uris array:

web {
   redirect_uris = ["http://localhost/"]
}

But I realized that for my use case, it's impractical to manage this list with Terraform, because each URI represents an independent environment and is generated per-deployment. That means that it won't be stored in IaC code and it shouldn't affect other environments - just add/remove one item to/from the list.

❌ Destroy

All resources defined in Terraform get destroyed, including the Azure resource of the B2C Tenant and the Azure Resource Group. That's not enough to remove the tenant itself, though.

Error: checking availability of domain_name: the specified domain "xxxx.onmicrosoft.com" is unavailable: AlreadyExists (The given domain name is not available.)

If you kept your tenant open in the web browser, you will still be able to browse it and see the b2c-extensions-app application (everything else should be removed).

To destroy the tenant properly, go through manual deletion steps.

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

📧 codez@deedx.cz