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...
- Azure B2C custom user attributes with PowerShell Core
- Automating Azure B2C creation with PowerShell Core
- Update Redirect URIs from Azure DevOps
- Automating Azure B2C creation with Terraform
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).
- ✔️ Create B2C tenant
- ❌ Create custom user attribute
- ❌ Create user flows
- ✔️ Create application registration with certificate authentication
- ✔️ Create application registration with API and custom scope
- ✔️ Assign access to Microsoft Graph
- ✔️ Grant admin consent
- ❌ Import users with custom attributes
- ➖ Update redirect URI
- ❌ 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"
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.
✔️ Grant admin consent
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:
- set custom attribute value on a user
- 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:
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.
This is what Microsoft Graph returns when we query for "identities" directly:
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.
Feedback
Found something inaccurate or plain wrong? Was this content helpful to you? Let me know!
📧 codez@deedx.cz