Skip to content
  • English Deutsch

benjamin-abt.com

Performance is a feature | Cloud - AI - Azure - .NET - Sustainability

  • Home
  • Blog
  • Portfolio
    • Overview
    • Workshops & Training
    • Publications
  • Contact
  • Home
  • Blog

Automating Microsoft Entra External ID: CIAM Provisioning and Branding

Automating Microsoft Entra External ID: CIAM Provisioning and Branding
November 10, 2026 60 min read BEN ABT
Azure Microsoft Entra ID Security Powershell Architecture

Customer Identity and Access Management (CIAM) provides the identity foundation for applications used by customers: registration, sign-in, account recovery, federation with other identity providers, and the policies that protect those processes. Unlike workforce identity, which primarily serves employees and internal resources, CIAM must support people outside an organization’s administrative control. A customer portal, online shop or subscription service needs an accessible registration process while protecting accounts and personal information.

The importance of CIAM becomes apparent long before an application reaches millions of accounts. Password recovery, verification messages, authentication protocols and protection against account abuse are ongoing responsibilities. Implementing them separately in every application increases maintenance work and makes consistent security policies difficult. A dedicated identity platform centralizes these concerns and allows application teams to focus on their business workflows.

That separation has limits. The identity provider can establish who signed in and issue tokens; the application still has to validate those tokens and decide whether that person may access a particular order, contract or customer account. Authentication does not replace business authorization.

Several products address this space, with different operating models:

PlatformApproachArchitectural consideration
Microsoft Entra External IDManaged customer identity using external tenantsFits an Azure environment with established Entra administration and Microsoft Graph tooling.
Auth0Managed identity platform for application authenticationProvides hosted authentication and integrations across application stacks.
Amazon CognitoAWS identity service with user pools and identity poolsSeparates application user authentication from access to AWS resources.
KeycloakOpen-source identity and access managementSupports self-managed identity, with responsibility for hosting, upgrades and availability.

These are alternatives to evaluate against actual requirements, rather than interchangeable implementations. Relevant differences include supported identity providers, customization, regional availability, operational ownership and integration effort. The product foundations are documented in the Entra External ID overview , Auth0 overview , Amazon Cognito developer guide and Keycloak project documentation .

For Microsoft Entra External ID, a repeatable setup should include the customer-facing appearance as well as the directory itself. A tenant created through automation but branded manually still depends on undocumented portal changes.

A repeatable foundation for customer identity

The implementation described here uses a PowerShell setup script, a YAML configuration file and separate branding assets for development, staging and production. The examples use fictional names. The complete script and configuration are included below, alongside focused snippets explaining the individual operations.

The setup covers resource groups, external tenants, resource tags, deletion locks, verified tenant domains and company branding. Application registrations, user flows, federation, authentication policies and application integration are subsequent configuration steps.

The central design decision is to separate two management boundaries:

BoundaryAPIResponsibilities
Azure subscription associated with the workforce tenantAzure Resource Manager (ARM)Resource group, CIAM directory resource, tags and management lock
Newly created external tenantMicrosoft GraphDomain verification, default domain and organizational branding

The workforce tenant ID is an input to the setup. The external tenant ID is an output of provisioning. Reusing the workforce tenant ID for subsequent Graph requests would target the wrong directory.

In this implementation, each environment gets its own external tenant. This isolates customer accounts and tenant configuration between environments, at the cost of administering multiple directories. Development receives a green visual theme and staging an amber theme, each with an explicit environment label in the sign-in text so that recognition does not depend on color alone. Production uses the regular corporate colors and product name without an environment marker.

Configuration belongs beside the automation

The file layout keeps operational settings and visual assets together:

1setup/
2  ciam-directories.ps1
3  ciam-directories.yml
4  branding/
5    dev/
6    stg/
7    prd/

Shared configuration describes the subscription and tenant creation settings. Stage configuration describes names, domains and branding. The following excerpt shows that separation; additional validation, lock and asset settings are omitted:

YAML
 1tenantId: '00000000-0000-0000-0000-000000000000'
 2subscriptionId: '00000000-0000-0000-0000-000000000000'
 3resourceProvider: 'Microsoft.AzureActiveDirectory'
 4apiVersion: '2023-05-17-preview'
 5
 6management:
 7  resourceGroupLocation: 'germanywestcentral'
 8  pollIntervalSeconds: 30
 9  operationTimeoutMinutes: 45
10
11ciam:
12  location: 'Europe'
13  countryCode: 'DE'
14  sku:
15    name: 'Standard'
16    tier: 'A0'
17  tags:
18    workload: 'customer-portal-identity'
19    managedBy: 'ciam-directories.ps1'
20
21graph:
22  apiVersion: 'v1.0'
23  scopes:
24    - 'Domain.ReadWrite.All'
25    - 'OrganizationalBranding.ReadWrite.All'
26  useDeviceCode: false
27
28stages:
29  dev:
30    enabled: true
31    resourceGroupName: 'rg-customer-identity-dev'
32    resourceName: 'examplecustomersdev'
33    displayName: 'Example Customer Portal DEV'
34    customDomain:
35      name: 'login-dev.example.com'
36      setAsPrimary: true
37      waitForVerification: false
38      verificationPollIntervalSeconds: 30
39      verificationTimeoutMinutes: 30
40    branding:
41      enabled: true
42      locale: 'en-US'
43      backgroundColor: '#19A974'
44      headerBackgroundColor: '#0B6B4A'
45      signInPageText: 'Example Customer Portal - DEV'
46      usernameHintText: 'Email address'

The GUIDs and domain are placeholders. The script deliberately rejects example.com domains and REPLACE- values, so an unedited configuration cannot be provisioned by accident. Actual tenant names must be available, and domain verification requires control of the corresponding DNS zone.

The resource group location and the CIAM data location have different meanings. germanywestcentral locates the resource group metadata; Europe is the external tenant’s configured data geography. Selecting a German resource group does not itself select Germany as the directory’s data location. The ARM schema documents these tenant properties separately in the CIAM directory resource reference .

Validation happens before provisioning. The setup checks the selected stage, required values, names, colors, URLs and local image files. Asset paths resolve relative to the configuration file, avoiding dependence on the shell’s current working directory.

The configuration is suitable for version control because it contains settings rather than credentials. Tenant and subscription identifiers are not passwords; access tokens, client secrets and authenticated session data do not belong in this file.

Provision the external tenant through Azure Resource Manager

The Azure resource type is Microsoft.AzureActiveDirectory/ciamDirectories. The implementation pins its ARM calls to 2023-05-17-preview, making the API dependency explicit rather than silently changing contracts between runs. The preview designation belongs to this management API version.

The essential creation request has the following shape. Braced values in the URL represent deployment inputs:

HTTP
 1PUT https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.AzureActiveDirectory/ciamDirectories/{resourceName}?api-version=2023-05-17-preview
 2Authorization: Bearer {armAccessToken}
 3Content-Type: application/json
 4
 5{
 6  "location": "Europe",
 7  "sku": {
 8    "name": "Standard",
 9    "tier": "A0"
10  },
11  "properties": {
12    "createTenantProperties": {
13      "displayName": "Example Customer Portal DEV",
14      "countryCode": "DE"
15    }
16  },
17  "tags": {
18    "workload": "customer-portal-identity",
19    "environment": "dev"
20  }
21}

This request uses the documented ARM resource contract . The tenant ID is read from the resulting resource; it is not invented before creation.

The script sends these requests with Invoke-AzRestMethod. That cmdlet reports HTTP failures through the status code of its response instead of throwing an exception. The script therefore checks the status explicitly: a 404 on a read means that the directory does not exist yet, while every other non-success status stops the run. Without that check, a rejected request or an error body could be mistaken for an existing resource.

The script first ensures that the resource group exists, merges its managed tags with existing tags and applies the configured CanNotDelete lock. It then reads the directory resource, creates it if absent and updates managed settings when differences exist. Unrelated tags are preserved.

Tenant creation is asynchronous. An accepted request does not establish that the directory is ready for Graph operations. The implementation waits for the ARM operation and polls the resource with a bounded timeout, stopping on failure and proceeding only after provisioning has completed and a valid tenant ID is available.

On subsequent runs, the script rejects conflicting location and country settings rather than attempting to replace a directory. Microsoft also identifies the country/region choice as unchangeable after creation in its external tenant creation documentation . A directory containing customer identities is not a disposable application instance.

A resource group lock protects against accidental ARM deletion. It does not prevent administrators from changing branding or domain settings through Graph, so tenant permissions remain a separate concern.

Authenticate separately for tenant configuration

The reference implementation uses PowerShell 7 with Az.Accounts, Az.Resources and Microsoft.Graph.Authentication. It runs locally with an Azure login in the workforce tenant, followed by a separate delegated Graph login in the external tenant.

Azure authorization must cover resource creation and the configured management operations, including locks. Microsoft Graph authorization must cover domain and branding changes in the external tenant. An Azure subscription role does not automatically grant those directory permissions.

For delegated Graph access, the setup requests Domain.ReadWrite.All and OrganizationalBranding.ReadWrite.All. The administrator also needs the corresponding supported directory roles. Domain Name Administrator and Organizational Branding Administrator are the least privileged roles documented for the domain verification and branding update operations.

Before writes begin, the script checks the active Azure tenant and subscription and displays the selected target. Its -WhatIf mode validates local inputs and the Azure context and suppresses provisioning; it therefore also requires a prior Connect-AzAccount. This is useful for detecting incorrect configuration, but it does not prove that a later Graph login, domain verification or remote update will succeed.

This distinction also defines the automation model: repeatable operations with interactive authentication. Moving the process into an unattended pipeline requires a separate design for workload authentication, permissions in both tenants and DNS access.

Verify the custom domain as a resumable step

Domain ownership introduces a dependency outside Azure and Microsoft Graph. The setup can request verification records, but it cannot publish them without access to the DNS provider.

The implementation therefore treats domain configuration as a sequence that can resume after interruption:

  1. Read the configured domain from the external tenant and add it if absent.
  2. Retrieve and display its DNS verification records.
  3. Attempt verification, either once or repeatedly until the configured timeout.
  4. Read the domain again to confirm verification.
  5. Set isDefault to true and confirm the resulting state.

The corresponding Graph operations are:

1POST  /v1.0/domains
2GET   /v1.0/domains/{domainName}/verificationDnsRecords
3POST  /v1.0/domains/{domainName}/verify
4PATCH /v1.0/domains/{domainName}

The creation body contains the domain’s id; the final update contains {"isDefault": true}. Verification is performed by the service using the published DNS evidence, as described in the Graph domain verification API .

With waitForVerification: false, a missing verification record stops the run after printing the required DNS information. Once the record exists, the same stage can run again. In this implementation, branding follows successful domain configuration, so it has not yet been applied when domain verification stops execution.

Retries should distinguish expected DNS propagation from permission failures, throttling and malformed requests. Treating every verification error as a propagation delay can hide an actionable problem until the timeout expires. The reference script stops immediately on authorization and throttling responses and reports every other failed verification attempt as a warning while it waits.

A default tenant domain is not a custom login URL

Adding login.example.com to the directory and making it the default domain does not configure the browser to authenticate through that hostname.

A custom URL domain requires additional configuration. Microsoft’s documented approach includes associating the domain with the external tenant’s custom URL configuration, setting up Azure Front Door, configuring DNS and TLS, and updating the application’s authentication configuration. These steps are described in the custom URL domain guide .

The setup discussed here manages tenant domain ownership and the default domain. A deployment that also requires a branded authentication hostname must include the additional URL infrastructure and application changes as explicit work.

Apply branding as configuration

Branding has two parts: text and colors represented as properties, and images uploaded as binary streams. The setup manages both and creates the configured language localization when it does not already exist.

For text changes, the implementation compares current values with configured values and sends only the differences. A representative request is:

HTTP
 1PATCH https://graph.microsoft.com/v1.0/organization/{externalTenantId}/branding/localizations/en-US
 2Authorization: Bearer {graphAccessToken}
 3Content-Type: application/json
 4
 5{
 6  "backgroundColor": "#19A974",
 7  "headerBackgroundColor": "#0B6B4A",
 8  "signInPageText": "Example Customer Portal - DEV",
 9  "usernameHintText": "Email address"
10}

The endpoint matters. PATCH /organization/{id}/branding updates the tenant’s default branding object, which Microsoft’s examples address with Accept-Language: 0. A specific browser language is changed through /branding/localizations/{locale}, as described in the branding localization update API . The script reads, updates and uploads exclusively through the localization path, so text and images always target the same locale. If the localization does not exist yet, the script creates it; according to the localization creation API , this also creates the default branding object when the tenant has none.

The configuration also supports privacy and terms links. A reliable reconciliation policy needs to distinguish an omitted property from an explicit removal. In the reference implementation, empty optional values are skipped; clearing a value in YAML therefore does not necessarily clear an existing tenant value.

An en-US localization should also not be mistaken for the default branding used by every language. Browsers that request another language fall back to the default branding, which this setup creates implicitly but does not populate. If customers use other browser languages, the default branding or additional localizations need deliberate configuration and verification in the actual sign-in flow.

Upload images independently

The stage-specific asset configuration points to a background, banner logo, header logo, favicon and square logo variants:

YAML
1assets:
2  backgroundImage: 'branding/dev/background-image-1920x1080.jpg'
3  bannerLogo: 'branding/dev/banner-logo-245x36.png'
4  headerLogo: 'branding/dev/header-logo-245x36.png'
5  favicon: 'branding/dev/favicon-32x32.png'
6  squareLogo: 'branding/dev/square-logo-240x240.png'
7  squareLogoDark: 'branding/dev/square-logo-dark-240x240.png'

These filenames describe the dimensions chosen by the setup. The script inspects the actual images and validates their dimensions, formats and file sizes before starting deployment. The limits follow the company branding documentation , for example 300 KB for the background image, 50 KB for the banner logo and 10 KB for the header logo. A correctly named file is insufficient if its binary content has different dimensions.

Graph handles image uploads separately from JSON property updates. For example, a banner logo is sent as an image stream:

HTTP
1PUT https://graph.microsoft.com/v1.0/organization/{externalTenantId}/branding/localizations/en-US/bannerLogo
2Authorization: Bearer {graphAccessToken}
3Content-Type: image/png
4
5<binary PNG content>

This separation between PATCH for properties and PUT for image streams is defined by the branding localization update API .

The implementation computes a SHA-256 hash of each local image and compares it with the downloaded tenant asset. Matching content requires no upload. This makes an unchanged rerun quieter and allows individual assets to change independently. The comparison relies on the service returning the stored bytes unchanged; if an image were re-encoded on upload, that asset would simply be uploaded and reported as updated on every run.

An upload response alone does not verify the customer experience. The actual application sign-in flow should be checked for the intended locale, mobile layout, readable text, recognizable logos and footer links that render as intended. Different authentication surfaces can render different parts of the branding configuration.

Run the PowerShell orchestration

The complete implementation is available as ciam-directories.ps1 , with a matching ciam-directories.yml containing all three environments. Both files belong in the same setup directory. The configuration excerpt earlier in this article is intentionally shorter; the downloadable YAML includes the required asset paths, verification timeouts and deletion locks.

Before execution, the configuration needs real workforce tenant and subscription IDs, available tenant resource names, owned custom domains and meaningful ownership tags. The six branding images for each enabled target stage must exist at the configured paths. Corporate image files are not included in the download. This version uses System.Drawing to inspect images and is intended for PowerShell 7 on Windows.

The required modules can be installed in the current user’s PowerShell environment:

PowerShell
1Install-Module -Name Az.Accounts, Az.Resources, Microsoft.Graph.Authentication `
2    -Scope CurrentUser -Repository PSGallery

Authentication and execution take place in the same PowerShell session. The following commands assume the setup directory is the current directory and that the configuration and branding files have been prepared:

PowerShell
1[string] $workforceTenantId = '00000000-0000-0000-0000-000000000000'
2[string] $subscriptionId = '00000000-0000-0000-0000-000000000000'
3
4Connect-AzAccount -Tenant $workforceTenantId -Subscription $subscriptionId
5
6./ciam-directories.ps1 -Stage dev -WhatIf
7./ciam-directories.ps1 -Stage dev

The GUIDs in this command block also require replacement. Normal execution displays the target and requests confirmation before provisioning. The subsequent Graph login authenticates against the created external tenant. With DNS verification waiting disabled, an initial run can stop after displaying the required record; publishing that record and repeating the final command resumes configuration.

The same script selects staging or production explicitly, and -ConfigPath allows a separate configuration file:

PowerShell
1./ciam-directories.ps1 -Stage stg -ConfigPath ./ciam-directories.yml -WhatIf
2./ciam-directories.ps1 -Stage prd -ConfigPath ./ciam-directories.yml -WhatIf

The orchestration sequence

The orchestration below is the final section of the script. It runs after configuration validation, Azure context checks and the ShouldProcess confirmation. Its helper functions and script-scoped settings are defined in the complete implementation below; this excerpt is not a standalone script.

PowerShell
 1[string] $stageName = $Stage
 2[object] $stageConfiguration = $stageProperty.Value
 3[hashtable] $stageTags = Merge-Tags -Base $commonTags -Additional (
 4    Get-OptionalValue -Object $stageConfiguration -Name 'tags'
 5)
 6$stageTags['environment'] = $stageName
 7
 8[string] $resourceGroupName = Get-RequiredValue `
 9    -Object $stageConfiguration -Name 'resourceGroupName' `
10    -Context "stages.$stageName"
11
12[object] $resourceGroup = Ensure-ResourceGroup `
13    -Name $resourceGroupName `
14    -Location $script:ResourceGroupLocation `
15    -Tags $stageTags
16
17[object] $deleteLock = Get-RequiredObject `
18    -Object $stageConfiguration -Name 'deleteLock' `
19    -Context "stages.$stageName"
20
21[hashtable] $lockParameters = @{
22    ResourceGroupName = $resourceGroupName
23    ResourceGroupId = $resourceGroup.ResourceId
24    Enabled = ConvertTo-ConfigBoolean -Value $deleteLock.enabled `
25        -Context "stages.$stageName.deleteLock.enabled"
26    LockName = [string] $deleteLock.name
27    Message = [string] $deleteLock.message
28}
29[void] (Ensure-DeleteLock @lockParameters)
30
31[hashtable] $stageParameters = @{
32    StageConfiguration = $stageConfiguration
33    StageName = $stageName
34}
35[object] $ciamResource = Ensure-CiamDirectory @stageParameters -Tags $stageTags
36[string] $ciamTenantId = Get-PropertyValue `
37    -Object (Get-PropertyValue -Object $ciamResource -Name 'properties') `
38    -Name 'tenantId'
39[guid] $parsedTenantId = [guid]::Empty
40if (-not [guid]::TryParse($ciamTenantId, [ref] $parsedTenantId)) {
41    throw "CIAM resource '$stageName' did not return a valid tenant ID."
42}
43
44[void] (Ensure-CiamCustomDomain @stageParameters -CiamTenantId $ciamTenantId)
45[void] (Ensure-CiamBranding @stageParameters -CiamTenantId $ciamTenantId)

Ensure-CiamDirectory handles provisioning and returns the resulting resource. Ensure-CiamCustomDomain establishes the Graph session in that resource’s tenant and completes verification before Ensure-CiamBranding applies text and images. A failure stops the sequence rather than reporting a partially configured environment as complete.

Complete configuration and script

Both blocks are rendered directly from the files offered for download, so the displayed implementation and the downloadable source cannot drift apart. Each block starts as a short preview and can be expanded to the complete file. The YAML parser is included in the script and supports the mappings, lists and scalar values used by this configuration; no additional YAML module is required.

Complete YAML configuration for development, staging and production 379 lines Download
  1# Configuration for ciam-directories.ps1 in the same directory.
  2#
  3# Only values marked [SIGN-IN-VISIBLE] are intended to be visible to a
  4# customer during authentication. Their exact rendering depends on the
  5# selected sign-in flow and tenant branding. All other settings below are
  6# deployment, administration, billing, location, or resource metadata.
  7#
  8# The workforce tenant configured by tenantId is the tenant from which the
  9# script authenticates. The CIAM tenant IDs created by Azure are returned by
 10# the script after provisioning; they are not known before the first run.
 11
 12# Microsoft Entra workforce tenant ID used for authentication and tenant
 13# creation permissions. This is not the CIAM tenant ID created for a stage.
 14tenantId: '00000000-0000-0000-0000-000000000000'
 15
 16# Azure subscription that owns the resource groups and CIAM directory
 17# resources.
 18subscriptionId: '00000000-0000-0000-0000-000000000000'
 19
 20# Azure resource provider that exposes CIAM directory resources. Do not change
 21# this value.
 22resourceProvider: 'Microsoft.AzureActiveDirectory'
 23
 24# ARM API version used for CIAM create/update calls. The script validates this
 25# value against the current API version supported by this setup.
 26apiVersion: '2023-05-17-preview'
 27
 28management:
 29  # Azure region of each resource group. This is resource-group metadata and
 30  # is independent from the CIAM data-residency location.
 31  resourceGroupLocation: 'germanywestcentral'
 32
 33  # Seconds between status checks if Azure still reports a provisioning state
 34  # after the REST long-running operation has completed.
 35  pollIntervalSeconds: 30
 36
 37  # Maximum time, in minutes, used by the script while waiting for a CIAM
 38  # resource to become readable and reach the Succeeded state.
 39  operationTimeoutMinutes: 45
 40
 41ciam:
 42  # Microsoft Entra External ID data-residency location. This is not an Azure
 43  # region and is not displayed as customer sign-in text. It determines where
 44  # the CIAM tenant data is hosted and is immutable after tenant creation.
 45  location: 'Europe'
 46
 47  # Two-letter country/region selection used when creating the CIAM tenant.
 48  # This is tenant metadata, not customer sign-in text, and is immutable after
 49  # tenant creation.
 50  countryCode: 'DE'
 51
 52  sku:
 53    # CIAM billing/service tier used by the Azure resource. Supported values
 54    # are Standard, PremiumP1, and PremiumP2. This is administration/billing
 55    # metadata, not customer sign-in text.
 56    name: 'Standard'
 57
 58    # SKU tier required by the CIAM ARM resource API. CIAM currently uses A0
 59    # for all supported SKU names. Do not change this value.
 60    tier: 'A0'
 61
 62  tags:
 63    # Custom Azure resource tags applied to every CIAM resource and its
 64    # resource group. Tags are resource metadata, not customer sign-in text.
 65    workload: 'customer-portal-identity'
 66
 67    # Identifies the tool that manages the Azure resources.
 68    managedBy: 'ciam-directories.ps1'
 69
 70    # Business owner of the Azure resources. Replace this placeholder before
 71    # provisioning.
 72    owner: 'REPLACE-OWNER'
 73
 74    # Technical owner of the Azure resources. Replace this placeholder before
 75    # provisioning.
 76    technical-owner: 'REPLACE-TECHNICAL-OWNER'
 77
 78    # Source repository for the workload and its automation.
 79    repository: 'REPLACE-MY-REPOSITORY'
 80
 81graph:
 82  # Microsoft Graph API version used for custom-domain and branding
 83  # management. The v1.0 API is the current stable API; it is not the ARM API
 84  # version above.
 85  apiVersion: 'v1.0'
 86
 87  # Delegated Microsoft Graph permissions requested in the CIAM tenant. The
 88  # signed-in administrator needs the supported Domain Name Administrator and
 89  # Organizational Branding Administrator roles. These permissions are not
 90  # visible to customers at sign-in.
 91  scopes:
 92    - 'Domain.ReadWrite.All'
 93    - 'OrganizationalBranding.ReadWrite.All'
 94
 95  # If true, use device-code authentication for local execution. If false,
 96  # use the normal interactive browser authentication flow. This is local
 97  # authentication behavior and is not visible to customers.
 98  useDeviceCode: false
 99
100stages:
101  # Stage identifier used by the mandatory -Stage argument. It is also written
102  # to the environment tag.
103  dev:
104    # If false, this stage cannot be provisioned until it is enabled.
105    enabled: true
106
107    # Azure Resource Group name for this stage.
108    resourceGroupName: 'rg-customer-identity-dev'
109
110    # [SIGN-IN-VISIBLE] CIAM resource name and initial tenant subdomain. This
111    # value is used in the CIAM resource name and in the initial domain used by
112    # authentication, typically <resourceName>.onmicrosoft.com. For this stage
113    # the resulting primary domain is examplecustomersdev.onmicrosoft.com.
114    # It must be globally unique, alphanumeric, and no longer than 26 chars.
115    resourceName: 'examplecustomersdev'
116
117    # [SIGN-IN-VISIBLE] Human-readable CIAM tenant display name. It can appear
118    # in the customer sign-in/account-selection context and in tenant branding,
119    # depending on the selected sign-in flow.
120    displayName: 'Example Customer Portal DEV'
121
122    customDomain:
123      # [SIGN-IN-VISIBLE] Fully qualified custom domain that must become this
124      # CIAM tenant's primary/default domain, for example
125      # login-dev.example.com. The domain must be owned by you and must not be
126      # the initial *.onmicrosoft.com domain. Replace this placeholder.
127      name: 'REPLACE-CUSTOM-DOMAIN-DEV'
128
129      # The script always makes this verified custom domain the primary/default
130      # tenant domain. Keep this true for the required CIAM setup.
131      setAsPrimary: true
132
133      # If true, retry domain verification until the DNS record is visible or
134      # the timeout is reached. If false, print the required DNS records and
135      # stop when verification is not yet possible. This affects provisioning,
136      # not the customer sign-in display.
137      waitForVerification: false
138
139      # Seconds between Microsoft Graph verification attempts when waiting is
140      # enabled. DNS propagation can take longer than this interval.
141      verificationPollIntervalSeconds: 30
142
143      # Maximum number of minutes to wait for DNS verification when waiting is
144      # enabled.
145      verificationTimeoutMinutes: 30
146
147    branding:
148      # Enable Microsoft Entra External ID company branding for this stage.
149      # Branding is shown on Microsoft-hosted browser sign-in pages.
150      enabled: true
151
152      # RFC 1766 locale of the managed branding localization. Browsers with
153      # other languages fall back to the tenant's default branding, which this
154      # setup does not populate.
155      locale: 'en-US'
156
157      # [SIGN-IN-VISIBLE] Fallback page background color in hexadecimal RGB
158      # format. It is also used when the background image cannot load.
159      backgroundColor: '#19A974'
160
161      # [SIGN-IN-VISIBLE] Header background color in hexadecimal RGB format.
162      headerBackgroundColor: '#0B6B4A'
163
164      # [SIGN-IN-VISIBLE] Text shown below the sign-in box. Do not put secrets
165      # or sensitive operational information here.
166      signInPageText: 'Example Customer Portal - DEV'
167
168      # [SIGN-IN-VISIBLE] Hint shown in the username input field.
169      usernameHintText: 'Email address'
170
171      # [SIGN-IN-VISIBLE] Optional footer text and links. Leave a URL empty
172      # when the corresponding custom link should not be configured.
173      customPrivacyAndCookiesText: 'Privacy & Cookies'
174      customPrivacyAndCookiesUrl: ''
175      customTermsOfUseText: 'Terms of Use'
176      customTermsOfUseUrl: ''
177
178      assets:
179        # [SIGN-IN-VISIBLE] Main sign-in background. Required format is
180        # PNG/JPEG, 1920x1080 pixels, and at most 300 KB.
181        backgroundImage: 'branding/dev/background-image-1920x1080.jpg'
182
183        # [SIGN-IN-VISIBLE] Banner logo. PNG/JPEG, 245x36 pixels and at most
184        # 50 KB. The script enforces the exact recommended size.
185        bannerLogo: 'branding/dev/banner-logo-245x36.png'
186
187        # [SIGN-IN-VISIBLE] Header logo. PNG/JPEG, 245x36 pixels and at most
188        # 10 KB. The script enforces the exact recommended size.
189        headerLogo: 'branding/dev/header-logo-245x36.png'
190
191        # [SIGN-IN-VISIBLE] Browser favicon. Required format is PNG/JPEG,
192        # 32x32 pixels, and at most 5 KB.
193        favicon: 'branding/dev/favicon-32x32.png'
194
195        # Square logos used by Microsoft Entra surfaces. Required format is
196        # PNG/JPEG, 240x240 pixels, and at most 50 KB per file.
197        squareLogo: 'branding/dev/square-logo-240x240.png'
198        squareLogoDark: 'branding/dev/square-logo-dark-240x240.png'
199
200    # Resource-group delete protection. All stages are protected by default.
201    # The lock prevents deletion of this resource group and its resources.
202    deleteLock:
203      # Set to false only when this stage must intentionally run without the
204      # configured delete lock. An existing lock is not removed automatically.
205      enabled: true
206
207      # Azure management-lock name. It is visible under Resource group > Locks.
208      name: 'ciam-delete-lock-dev'
209
210      # Configurable note shown with the lock in Azure. It does not affect the
211      # customer sign-in experience.
212      message: 'Do not delete the DEV CIAM resource group or its resources.'
213
214    # Stage-specific resource tags. These are merged with ciam.tags.
215    tags:
216      environment: 'dev'
217
218  # Stage identifier used by the mandatory -Stage argument. It is also written
219  # to the environment tag.
220  stg:
221    # If false, this stage cannot be provisioned until it is enabled.
222    enabled: true
223
224    # Azure Resource Group name for this stage.
225    resourceGroupName: 'rg-customer-identity-stg'
226
227    # [SIGN-IN-VISIBLE] CIAM resource name and initial tenant subdomain. This
228    # value must be different from dev and prd and globally unique in
229    # Microsoft Entra. The resulting primary domain is
230    # examplecustomersstg.onmicrosoft.com.
231    resourceName: 'examplecustomersstg'
232
233    # [SIGN-IN-VISIBLE] Human-readable CIAM tenant display name. It can appear
234    # in the customer sign-in/account-selection context and in tenant branding.
235    displayName: 'Example Customer Portal STG'
236
237    customDomain:
238      # [SIGN-IN-VISIBLE] Fully qualified custom domain that must become this
239      # CIAM tenant's primary/default domain. Replace this placeholder.
240      name: 'REPLACE-CUSTOM-DOMAIN-STG'
241      # The custom domain is required to become the primary/default domain.
242      setAsPrimary: true
243      # Retry verification until DNS is visible when true; otherwise print the
244      # DNS records and stop so they can be configured at the registrar.
245      waitForVerification: false
246      # Seconds between verification attempts while waiting.
247      verificationPollIntervalSeconds: 30
248      # Maximum wait time in minutes while waiting for DNS verification.
249      verificationTimeoutMinutes: 30
250
251    branding:
252      # Enable Microsoft Entra External ID company branding for this stage.
253      enabled: true
254      # RFC 1766 locale used for this branding customization.
255      locale: 'en-US'
256      # [SIGN-IN-VISIBLE] Fallback page background color in hexadecimal RGB.
257      backgroundColor: '#F2C94C'
258      # [SIGN-IN-VISIBLE] Header background color in hexadecimal RGB.
259      headerBackgroundColor: '#B27A00'
260      # [SIGN-IN-VISIBLE] Text shown below the sign-in box.
261      signInPageText: 'Example Customer Portal - STG'
262      # [SIGN-IN-VISIBLE] Hint shown in the username input field.
263      usernameHintText: 'Email address'
264      # [SIGN-IN-VISIBLE] Optional footer text and links.
265      customPrivacyAndCookiesText: 'Privacy & Cookies'
266      customPrivacyAndCookiesUrl: ''
267      customTermsOfUseText: 'Terms of Use'
268      customTermsOfUseUrl: ''
269      assets:
270        # [SIGN-IN-VISIBLE] 1920x1080 PNG/JPEG background, max. 300 KB.
271        backgroundImage: 'branding/stg/background-image-1920x1080.jpg'
272        # [SIGN-IN-VISIBLE] 245x36 PNG/JPEG banner logo, max. 50 KB.
273        bannerLogo: 'branding/stg/banner-logo-245x36.png'
274        # [SIGN-IN-VISIBLE] 245x36 PNG/JPEG header logo, max. 10 KB.
275        headerLogo: 'branding/stg/header-logo-245x36.png'
276        # [SIGN-IN-VISIBLE] 32x32 PNG/JPEG favicon, max. 5 KB.
277        favicon: 'branding/stg/favicon-32x32.png'
278        # [SIGN-IN-VISIBLE] 240x240 PNG/JPEG square logos, max. 50 KB each.
279        squareLogo: 'branding/stg/square-logo-240x240.png'
280        squareLogoDark: 'branding/stg/square-logo-dark-240x240.png'
281
282    # Resource-group delete protection. All stages are protected by default.
283    deleteLock:
284      # Set to false only when this stage must intentionally run without the
285      # configured delete lock. An existing lock is not removed automatically.
286      enabled: true
287
288      # Azure management-lock name. It is visible under Resource group > Locks.
289      name: 'ciam-delete-lock-stg'
290
291      # Configurable note shown with the lock in Azure. It does not affect the
292      # customer sign-in experience.
293      message: 'Do not delete the STG CIAM resource group or its resources.'
294
295    # Stage-specific resource tags. These are merged with ciam.tags.
296    tags:
297      environment: 'stg'
298
299  # Stage identifier used by the mandatory -Stage argument. It is also written
300  # to the environment tag.
301  prd:
302    # If false, this stage cannot be provisioned until it is enabled.
303    enabled: true
304
305    # Azure Resource Group name for this stage.
306    resourceGroupName: 'rg-customer-identity-prd'
307
308    # [SIGN-IN-VISIBLE] CIAM resource name and initial tenant subdomain. This
309    # value must be different from dev and stg and globally unique in
310    # Microsoft Entra. The resulting primary domain is
311    # examplecustomers.onmicrosoft.com.
312    resourceName: 'examplecustomers'
313
314    # [SIGN-IN-VISIBLE] Human-readable CIAM tenant display name. It can appear
315    # in the customer sign-in/account-selection context and in tenant branding.
316    displayName: 'Example Customer Portal'
317
318    customDomain:
319      # [SIGN-IN-VISIBLE] Fully qualified custom domain that must become this
320      # CIAM tenant's primary/default domain. Replace this placeholder.
321      name: 'REPLACE-CUSTOM-DOMAIN-PRD'
322      # The custom domain is required to become the primary/default domain.
323      setAsPrimary: true
324      # Retry verification until DNS is visible when true; otherwise print the
325      # DNS records and stop so they can be configured at the registrar.
326      waitForVerification: false
327      # Seconds between verification attempts while waiting.
328      verificationPollIntervalSeconds: 30
329      # Maximum wait time in minutes while waiting for DNS verification.
330      verificationTimeoutMinutes: 30
331
332    branding:
333      # Enable Microsoft Entra External ID company branding for this stage.
334      enabled: true
335      # RFC 1766 locale used for this branding customization.
336      locale: 'en-US'
337      # [SIGN-IN-VISIBLE] Fallback page background color in hexadecimal RGB.
338      backgroundColor: '#1261A0'
339      # [SIGN-IN-VISIBLE] Header background color in hexadecimal RGB.
340      headerBackgroundColor: '#0B3D91'
341      # [SIGN-IN-VISIBLE] Text shown below the sign-in box. Production uses
342      # the regular product name without an environment marker.
343      signInPageText: 'Example Customer Portal'
344      # [SIGN-IN-VISIBLE] Hint shown in the username input field.
345      usernameHintText: 'Email address'
346      # [SIGN-IN-VISIBLE] Optional footer text and links.
347      customPrivacyAndCookiesText: 'Privacy & Cookies'
348      customPrivacyAndCookiesUrl: ''
349      customTermsOfUseText: 'Terms of Use'
350      customTermsOfUseUrl: ''
351      assets:
352        # [SIGN-IN-VISIBLE] 1920x1080 PNG/JPEG background, max. 300 KB.
353        backgroundImage: 'branding/prd/background-image-1920x1080.jpg'
354        # [SIGN-IN-VISIBLE] 245x36 PNG/JPEG banner logo, max. 50 KB.
355        bannerLogo: 'branding/prd/banner-logo-245x36.png'
356        # [SIGN-IN-VISIBLE] 245x36 PNG/JPEG header logo, max. 10 KB.
357        headerLogo: 'branding/prd/header-logo-245x36.png'
358        # [SIGN-IN-VISIBLE] 32x32 PNG/JPEG favicon, max. 5 KB.
359        favicon: 'branding/prd/favicon-32x32.png'
360        # [SIGN-IN-VISIBLE] 240x240 PNG/JPEG square logos, max. 50 KB each.
361        squareLogo: 'branding/prd/square-logo-240x240.png'
362        squareLogoDark: 'branding/prd/square-logo-dark-240x240.png'
363
364    # Resource-group delete protection. All stages are protected by default.
365    deleteLock:
366      # Set to false only when this stage must intentionally run without the
367      # configured delete lock. An existing lock is not removed automatically.
368      enabled: true
369
370      # Azure management-lock name. It is visible under Resource group > Locks.
371      name: 'ciam-delete-lock-prd'
372
373      # Configurable note shown with the lock in Azure. It does not affect the
374      # customer sign-in experience.
375      message: 'Do not delete the PRD CIAM resource group or its resources.'
376
377    # Stage-specific resource tags. These are merged with ciam.tags.
378    tags:
379      environment: 'prd'
Complete PowerShell orchestration script, including all helper functions 1315 lines Download
   1#requires -Version 7.0
   2#requires -Modules Az.Accounts, Az.Resources, Microsoft.Graph.Authentication
   3<#
   4.SYNOPSIS
   5Creates or updates the Microsoft Entra External ID (CIAM) directory for one
   6configured stage.
   7
   8.DESCRIPTION
   9Reads all settings from ciam-directories.yml. The script creates or updates
  10the configured resource group and the Microsoft.AzureActiveDirectory/
  11ciamDirectories resource using the pinned ARM API version configured in YAML.
  12
  13The same PUT request is used for creation and update. Existing tags that are
  14not managed by this file are preserved. Initial CIAM provisioning is
  15asynchronous, so the script polls the resource until provisioning has finished.
  16Each stage can also manage a resource-group-level CanNotDelete lock with a
  17stage-specific name and message. The lock is enabled by default in the YAML.
  18
  19After the CIAM resource exists, the script uses Microsoft Graph v1.0 in the
  20newly created CIAM tenant to add and verify the configured custom domain and to
  21set it as the tenant's primary/default domain. DNS ownership records are shown
  22when they are not yet available. The script never changes DNS because no DNS
  23provider is configured; rerunning the same stage after the records are added is
  24idempotent.
  25
  26The script also manages the configured External ID company branding
  27localization for the stage and uploads the configured image streams only when
  28their SHA-256 content hash differs from the current tenant asset.
  29
  30The script is intentionally local-only and uses Azure PowerShell. Authenticate
  31first with Az.Accounts:
  32
  33    Connect-AzAccount -Tenant <workforce-tenant-id> -Subscription <subscription-id>
  34
  35The custom-domain step then starts a separate delegated Microsoft Graph login
  36against the CIAM tenant and requests the configured Graph scopes
  37(Domain.ReadWrite.All and OrganizationalBranding.ReadWrite.All).
  38
  39.EXAMPLE
  40pwsh ./ciam-directories.ps1 -Stage dev
  41
  42.EXAMPLE
  43pwsh ./ciam-directories.ps1 -Stage prd -WhatIf
  44#>
  45[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
  46param(
  47    [Parameter(Mandatory)]
  48    [ValidateSet('dev', 'stg', 'prd')]
  49    [string] $Stage,
  50
  51    [string] $ConfigPath
  52)
  53
  54Set-StrictMode -Version Latest
  55$ErrorActionPreference = 'Stop'
  56$ProgressPreference = 'SilentlyContinue'
  57
  58if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
  59    $ConfigPath = Join-Path $PSScriptRoot 'ciam-directories.yml'
  60}
  61
  62function Write-Step {
  63    param([Parameter(Mandatory)][string] $Message)
  64    Write-Host "==> $Message" -ForegroundColor Cyan
  65}
  66
  67function Write-Status {
  68    param(
  69        [Parameter(Mandatory)][ValidateSet('created', 'updated', 'unchanged', 'skipped')][string] $Status,
  70        [string[]] $Details = @()
  71    )
  72
  73    $color = switch ($Status) {
  74        'created' { 'Green' }
  75        'updated' { 'Yellow' }
  76        default { 'DarkGray' }
  77    }
  78    Write-Host "    Status: $Status" -ForegroundColor $color
  79    foreach ($detail in @($Details | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) {
  80        Write-Host "      - $detail" -ForegroundColor $color
  81    }
  82}
  83
  84# The parser is deliberately self-contained so local execution does not need a
  85# PowerShell YAML module. It supports only the mapping/list/scalar subset used
  86# by the configuration; anchors, multi-line scalars and flow collections are
  87# not supported.
  88function Remove-YamlComment {
  89    param([string] $Value)
  90
  91    $singleQuoted = $false
  92    $doubleQuoted = $false
  93    for ($index = 0; $index -lt $Value.Length; $index++) {
  94        $character = $Value[$index]
  95        if ($singleQuoted) {
  96            if ($character -eq "'") { $singleQuoted = $false }
  97            continue
  98        }
  99        if ($doubleQuoted) {
 100            if ($character -eq '\') { $index++ }
 101            elseif ($character -eq '"') { $doubleQuoted = $false }
 102            continue
 103        }
 104        if ($character -eq "'") { $singleQuoted = $true }
 105        elseif ($character -eq '"') { $doubleQuoted = $true }
 106        elseif ($character -eq '#' -and ($index -eq 0 -or [char]::IsWhiteSpace($Value[$index - 1]))) {
 107            return $Value.Substring(0, $index).TrimEnd()
 108        }
 109    }
 110    return $Value.TrimEnd()
 111}
 112
 113function ConvertFrom-YamlScalar {
 114    param([Parameter(Mandatory)][string] $Value)
 115
 116    $trimmed = $Value.Trim()
 117    if ($trimmed -match "^'(.*)'$") { return $Matches[1].Replace("''", "'") }
 118    if ($trimmed -match '^".*"$') {
 119        try { return $trimmed | ConvertFrom-Json }
 120        catch { throw "Invalid quoted YAML scalar '$Value' in '$ConfigPath'." }
 121    }
 122    if ($trimmed -match '^(true|false)$') { return [bool]::Parse($trimmed) }
 123    if ($trimmed -match '^-?\d+$') { return [int64]$trimmed }
 124    return $trimmed
 125}
 126
 127function Get-YamlKeyValue {
 128    param([Parameter(Mandatory)][string] $Content, [Parameter(Mandatory)][int] $LineNumber)
 129
 130    if ($Content -notmatch '^(?<key>[A-Za-z][A-Za-z0-9_-]*):(?:\s*(?<value>.*))?$') {
 131        throw "Invalid YAML mapping at $ConfigPath line ${LineNumber}: $Content"
 132    }
 133    return [pscustomobject]@{ Key = $Matches['key']; Value = $Matches['value'] }
 134}
 135
 136function ConvertFrom-YamlNode {
 137    param([object[]] $Lines, [ref] $Index, [int] $Indent)
 138
 139    if ($Index.Value -ge $Lines.Count -or $Lines[$Index.Value].Indent -ne $Indent) {
 140        throw "Unexpected YAML indentation in '$ConfigPath'."
 141    }
 142    if ($Lines[$Index.Value].Content -match '^-' ) {
 143        return ConvertFrom-YamlList -Lines $Lines -Index $Index -Indent $Indent
 144    }
 145    return ConvertFrom-YamlMap -Lines $Lines -Index $Index -Indent $Indent
 146}
 147
 148function ConvertFrom-YamlMap {
 149    param([object[]] $Lines, [ref] $Index, [int] $Indent)
 150
 151    $map = [ordered]@{}
 152    while ($Index.Value -lt $Lines.Count) {
 153        $line = $Lines[$Index.Value]
 154        if ($line.Indent -lt $Indent -or $line.Content -match '^-' ) { break }
 155        if ($line.Indent -ne $Indent) { throw "Unexpected YAML indentation at $ConfigPath line $($line.LineNumber)." }
 156
 157        $entry = Get-YamlKeyValue -Content $line.Content -LineNumber $line.LineNumber
 158        $Index.Value++
 159        if ([string]::IsNullOrWhiteSpace($entry.Value)) {
 160            if ($Index.Value -ge $Lines.Count -or $Lines[$Index.Value].Indent -le $Indent) {
 161                throw "YAML mapping '$($entry.Key)' at $ConfigPath line $($line.LineNumber) requires a nested value."
 162            }
 163            $map[$entry.Key] = ConvertFrom-YamlNode -Lines $Lines -Index $Index -Indent $Lines[$Index.Value].Indent
 164        }
 165        else {
 166            $map[$entry.Key] = ConvertFrom-YamlScalar -Value $entry.Value
 167        }
 168    }
 169    return [pscustomobject] $map
 170}
 171
 172function ConvertFrom-YamlList {
 173    param([object[]] $Lines, [ref] $Index, [int] $Indent)
 174
 175    $items = [System.Collections.ArrayList]::new()
 176    while ($Index.Value -lt $Lines.Count) {
 177        $line = $Lines[$Index.Value]
 178        if ($line.Indent -lt $Indent -or $line.Content -notmatch '^-\s+(?<value>.*)$') { break }
 179        if ($line.Indent -ne $Indent) { throw "Unexpected YAML indentation at $ConfigPath line $($line.LineNumber)." }
 180
 181        $itemContent = $Matches['value']
 182        $Index.Value++
 183        if ($itemContent -notmatch '^[A-Za-z][A-Za-z0-9_-]*:(?:\s|$)') {
 184            [void] $items.Add((ConvertFrom-YamlScalar -Value $itemContent))
 185            continue
 186        }
 187
 188        $item = [ordered]@{}
 189        $entry = Get-YamlKeyValue -Content $itemContent -LineNumber $line.LineNumber
 190        if ([string]::IsNullOrWhiteSpace($entry.Value)) {
 191            if ($Index.Value -ge $Lines.Count -or $Lines[$Index.Value].Indent -le $Indent) {
 192                throw "YAML mapping '$($entry.Key)' at $ConfigPath line $($line.LineNumber) requires a nested value."
 193            }
 194            $item[$entry.Key] = ConvertFrom-YamlNode -Lines $Lines -Index $Index -Indent $Lines[$Index.Value].Indent
 195        }
 196        else {
 197            $item[$entry.Key] = ConvertFrom-YamlScalar -Value $entry.Value
 198        }
 199
 200        $propertyIndent = $null
 201        while ($Index.Value -lt $Lines.Count -and $Lines[$Index.Value].Indent -gt $Indent) {
 202            $propertyLine = $Lines[$Index.Value]
 203            if ($null -eq $propertyIndent) { $propertyIndent = $propertyLine.Indent }
 204            if ($propertyLine.Indent -ne $propertyIndent -or $propertyLine.Content -match '^-' ) {
 205                throw "Nested YAML lists are not supported at $ConfigPath line $($propertyLine.LineNumber)."
 206            }
 207            $property = Get-YamlKeyValue -Content $propertyLine.Content -LineNumber $propertyLine.LineNumber
 208            $Index.Value++
 209            if ([string]::IsNullOrWhiteSpace($property.Value)) {
 210                if ($Index.Value -ge $Lines.Count -or $Lines[$Index.Value].Indent -le $propertyIndent) {
 211                    throw "YAML mapping '$($property.Key)' at $ConfigPath line $($propertyLine.LineNumber) requires a nested value."
 212                }
 213                $item[$property.Key] = ConvertFrom-YamlNode -Lines $Lines -Index $Index -Indent $Lines[$Index.Value].Indent
 214            }
 215            else {
 216                $item[$property.Key] = ConvertFrom-YamlScalar -Value $property.Value
 217            }
 218        }
 219        [void] $items.Add([pscustomobject] $item)
 220    }
 221    return ,$items.ToArray()
 222}
 223
 224function Read-AzureConfiguration {
 225    if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) {
 226        throw "CIAM configuration was not found: $ConfigPath"
 227    }
 228    $script:ConfigDirectory = Split-Path -Parent (Resolve-Path -LiteralPath $ConfigPath -ErrorAction Stop).Path
 229
 230    $lines = [System.Collections.ArrayList]::new()
 231    $lineNumber = 0
 232    foreach ($rawLine in Get-Content -LiteralPath $ConfigPath) {
 233        $lineNumber++
 234        if ($rawLine -match '\t') { throw "Tabs are not supported in '$ConfigPath' (line $lineNumber)." }
 235        $content = Remove-YamlComment -Value $rawLine
 236        if ([string]::IsNullOrWhiteSpace($content)) { continue }
 237        [void] $lines.Add([pscustomobject]@{
 238                Indent = $rawLine.Length - $rawLine.TrimStart().Length
 239                Content = $content.Trim()
 240                LineNumber = $lineNumber
 241            })
 242    }
 243    if ($lines.Count -eq 0) { throw "Configuration '$ConfigPath' is empty." }
 244
 245    $index = 0
 246    $configuration = ConvertFrom-YamlNode -Lines $lines.ToArray() -Index ([ref] $index) -Indent $lines[0].Indent
 247    if ($index -ne $lines.Count) { throw "Unable to parse all YAML configuration in '$ConfigPath'." }
 248    return $configuration
 249}
 250
 251function Get-RequiredValue {
 252    param([Parameter(Mandatory)][object] $Object, [Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Context)
 253
 254    $property = $Object.PSObject.Properties[$Name]
 255    if ($null -eq $property -or $null -eq $property.Value -or
 256        ($property.Value -is [string] -and [string]::IsNullOrWhiteSpace($property.Value))) {
 257        throw "Missing required configuration value '$Context.$Name' in '$ConfigPath'."
 258    }
 259    return $property.Value
 260}
 261
 262function Get-OptionalValue {
 263    param([Parameter(Mandatory)][object] $Object, [Parameter(Mandatory)][string] $Name)
 264    $property = $Object.PSObject.Properties[$Name]
 265    if ($null -eq $property) { return $null }
 266    return $property.Value
 267}
 268
 269function Get-RequiredObject {
 270    param([Parameter(Mandatory)][object] $Object, [Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Context)
 271
 272    $value = Get-RequiredValue -Object $Object -Name $Name -Context $Context
 273    if ($value -is [string] -or $value -is [ValueType]) {
 274        throw "Configuration value '$Context.$Name' in '$ConfigPath' must be an object."
 275    }
 276    return $value
 277}
 278
 279function ConvertTo-ConfigBoolean {
 280    param([Parameter(Mandatory)][object] $Value, [Parameter(Mandatory)][string] $Context)
 281
 282    if ($Value -is [bool]) { return $Value }
 283    switch (([string] $Value).Trim().ToLowerInvariant()) {
 284        'true' { return $true }
 285        'yes' { return $true }
 286        '1' { return $true }
 287        'false' { return $false }
 288        'no' { return $false }
 289        '0' { return $false }
 290        default { throw "Configuration value '$Context' in '$ConfigPath' must be true or false." }
 291    }
 292}
 293
 294function Get-PropertyValue {
 295    param([object] $Object, [Parameter(Mandatory)][string] $Name)
 296    if ($null -eq $Object) { return $null }
 297    if ($Object -is [System.Collections.IDictionary]) {
 298        if (-not $Object.Contains($Name)) { return $null }
 299        return $Object[$Name]
 300    }
 301    $property = $Object.PSObject.Properties[$Name]
 302    if ($null -eq $property) { return $null }
 303    return $property.Value
 304}
 305
 306function Get-CiamDomainName {
 307    param(
 308        [Parameter(Mandatory)][object] $Resource,
 309        [Parameter(Mandatory)][string] $ResourceName
 310    )
 311
 312    $properties = Get-PropertyValue -Object $Resource -Name 'properties'
 313    $domainName = [string] (Get-PropertyValue -Object $properties -Name 'domainName')
 314    if ([string]::IsNullOrWhiteSpace($domainName)) {
 315        return "$ResourceName.onmicrosoft.com"
 316    }
 317    return $domainName
 318}
 319
 320function ConvertTo-StringHashtable {
 321    param([object] $Object)
 322
 323    $result = @{}
 324    if ($null -eq $Object) { return $result }
 325    if ($Object -is [System.Collections.IDictionary]) {
 326        foreach ($key in $Object.Keys) {
 327            $result[[string] $key] = [string] $Object[$key]
 328        }
 329        return $result
 330    }
 331    foreach ($property in $Object.PSObject.Properties) {
 332        $result[$property.Name] = [string] $property.Value
 333    }
 334    return $result
 335}
 336
 337function Merge-Tags {
 338    param([hashtable] $Base, [object] $Additional)
 339
 340    $result = @{}
 341    foreach ($entry in $Base.GetEnumerator()) { $result[$entry.Key] = [string] $entry.Value }
 342    if ($null -ne $Additional) {
 343        foreach ($entry in (ConvertTo-StringHashtable -Object $Additional).GetEnumerator()) {
 344            $result[$entry.Key] = [string] $entry.Value
 345        }
 346    }
 347    return $result
 348}
 349
 350function Invoke-AzRestJson {
 351    param(
 352        [Parameter(Mandatory)][ValidateSet('get', 'put')][string] $Method,
 353        [Parameter(Mandatory)][string] $Uri,
 354        [object] $Body,
 355        [switch] $WaitForCompletion
 356    )
 357
 358    $parameters = @{
 359        Uri = $Uri
 360        Method = $Method.ToUpperInvariant()
 361        ErrorAction = 'Stop'
 362    }
 363    if ($null -ne $Body) {
 364        $parameters.Payload = $Body | ConvertTo-Json -Depth 20 -Compress
 365    }
 366    if ($WaitForCompletion) {
 367        # Az follows Azure-AsyncOperation and returns the final resource. This
 368        # avoids guessing how long the initial CIAM provisioning will take.
 369        $parameters.WaitForCompletion = $true
 370        $parameters.PollFrom = 'AzureAsyncLocation'
 371        $parameters.FinalResultFrom = 'OriginalUri'
 372    }
 373
 374    $response = Invoke-AzRestMethod @parameters
 375    if ($null -eq $response) { return $null }
 376
 377    # Invoke-AzRestMethod reports HTTP errors through StatusCode instead of
 378    # throwing, so failed ARM requests must be detected explicitly. A missing
 379    # resource on GET is an expected state and is returned as $null.
 380    $statusCode = [int] $response.StatusCode
 381    if ($Method -eq 'get' -and $statusCode -eq 404) { return $null }
 382    if ($statusCode -lt 200 -or $statusCode -ge 300) {
 383        throw "Azure Resource Manager $($parameters.Method) '$Uri' failed with HTTP ${statusCode}: $($response.Content)"
 384    }
 385    if ([string]::IsNullOrWhiteSpace([string] $response.Content)) { return $null }
 386    return $response.Content | ConvertFrom-Json
 387}
 388
 389function Invoke-GraphJson {
 390    param(
 391        [Parameter(Mandatory)][ValidateSet('get', 'post', 'patch')][string] $Method,
 392        [Parameter(Mandatory)][string] $Uri,
 393        [object] $Body
 394    )
 395
 396    $parameters = @{
 397        Uri = $Uri
 398        Method = $Method.ToUpperInvariant()
 399        OutputType = 'PSObject'
 400        ErrorAction = 'Stop'
 401    }
 402    if ($null -ne $Body) {
 403        $parameters.Body = $Body | ConvertTo-Json -Depth 20 -Compress
 404        $parameters.ContentType = 'application/json'
 405    }
 406    return Invoke-MgGraphRequest @parameters
 407}
 408
 409function Invoke-GraphBinary {
 410    param(
 411        [Parameter(Mandatory)][ValidateSet('get', 'put')][string] $Method,
 412        [Parameter(Mandatory)][string] $Uri,
 413        [byte[]] $Body,
 414        [string] $ContentType,
 415        [string] $OutputFilePath
 416    )
 417
 418    $parameters = @{
 419        Uri = $Uri
 420        Method = $Method.ToUpperInvariant()
 421        ErrorAction = 'Stop'
 422    }
 423    if ($null -ne $Body) {
 424        $parameters.Body = $Body
 425        $parameters.ContentType = $ContentType
 426    }
 427    if (-not [string]::IsNullOrWhiteSpace($OutputFilePath)) { $parameters.OutputFilePath = $OutputFilePath }
 428    return Invoke-MgGraphRequest @parameters
 429}
 430
 431function Connect-CiamGraph {
 432    param([Parameter(Mandatory)][string] $TenantId)
 433
 434    $currentGraphContext = Get-MgContext -ErrorAction SilentlyContinue
 435    $currentGraphTenantId = [string] (Get-PropertyValue -Object $currentGraphContext -Name 'TenantId')
 436    $currentGraphScopes = @((Get-PropertyValue -Object $currentGraphContext -Name 'Scopes')) | ForEach-Object { [string] $_ }
 437    $missingScopes = @($script:GraphScopes | Where-Object { $_ -notin $currentGraphScopes })
 438    if ($currentGraphTenantId -ne $TenantId -or $missingScopes.Count -gt 0) {
 439        if (-not [string]::IsNullOrWhiteSpace($currentGraphTenantId)) {
 440            Disconnect-MgGraph -ErrorAction SilentlyContinue
 441        }
 442    }
 443    else {
 444        return
 445    }
 446
 447    Write-Step "Connect Microsoft Graph to CIAM tenant '$TenantId'"
 448    $parameters = @{
 449        TenantId = $TenantId
 450        Scopes = $script:GraphScopes
 451        NoWelcome = $true
 452        ErrorAction = 'Stop'
 453    }
 454    if ($script:GraphUseDeviceCode) { $parameters.UseDeviceCode = $true }
 455    Connect-MgGraph @parameters | Out-Null
 456
 457    $context = Get-MgContext -ErrorAction Stop
 458    if ([string] $context.TenantId -ne $TenantId) {
 459        throw "Microsoft Graph connected to tenant '$($context.TenantId)' instead of CIAM tenant '$TenantId'."
 460    }
 461}
 462
 463function Get-GraphDomain {
 464    param([Parameter(Mandatory)][string] $DomainName)
 465
 466    $escapedDomain = [uri]::EscapeDataString($DomainName)
 467    $uri = "https://graph.microsoft.com/$script:GraphApiVersion/domains/$escapedDomain"
 468    try {
 469        return Invoke-GraphJson -Method get -Uri $uri
 470    }
 471    catch {
 472        $errorText = $_ | Out-String
 473        if ($errorText -match '(?i)(404|Request_ResourceNotFound|ResourceNotFound)') { return $null }
 474        throw
 475    }
 476}
 477
 478function Get-GraphVerificationRecords {
 479    param([Parameter(Mandatory)][string] $DomainName)
 480
 481    $escapedDomain = [uri]::EscapeDataString($DomainName)
 482    $uri = "https://graph.microsoft.com/$script:GraphApiVersion/domains/$escapedDomain/verificationDnsRecords"
 483    $response = Invoke-GraphJson -Method get -Uri $uri
 484    $records = Get-PropertyValue -Object $response -Name 'value'
 485    if ($null -eq $records) { return @() }
 486    return @($records)
 487}
 488
 489function Get-DnsRecordValue {
 490    param([Parameter(Mandatory)][object] $Record)
 491
 492    foreach ($propertyName in @('text', 'mailExchange', 'canonicalName', 'target', 'value')) {
 493        $value = [string] (Get-PropertyValue -Object $Record -Name $propertyName)
 494        if (-not [string]::IsNullOrWhiteSpace($value)) { return $value }
 495    }
 496    return ($Record | ConvertTo-Json -Depth 10 -Compress)
 497}
 498
 499function Write-GraphVerificationRecords {
 500    param(
 501        [Parameter(Mandatory)][string] $DomainName,
 502        [Parameter(Mandatory)][object[]] $Records
 503    )
 504
 505    Write-Host "    DNS verification records for '$DomainName':" -ForegroundColor Yellow
 506    foreach ($record in $Records) {
 507        $recordType = [string] (Get-PropertyValue -Object $record -Name 'recordType')
 508        $label = [string] (Get-PropertyValue -Object $record -Name 'label')
 509        if ([string]::IsNullOrWhiteSpace($label)) { $label = $DomainName }
 510        $ttl = [string] (Get-PropertyValue -Object $record -Name 'ttl')
 511        Write-Host "      $recordType $label $((Get-DnsRecordValue -Record $record)) (TTL: $ttl)" -ForegroundColor Yellow
 512    }
 513}
 514
 515function Ensure-CiamCustomDomain {
 516    param(
 517        [Parameter(Mandatory)][object] $StageConfiguration,
 518        [Parameter(Mandatory)][string] $StageName,
 519        [Parameter(Mandatory)][string] $CiamTenantId
 520    )
 521
 522    $customDomain = Get-RequiredObject -Object $StageConfiguration -Name 'customDomain' -Context "stages.$StageName"
 523    $domainName = ([string] (Get-RequiredValue -Object $customDomain -Name 'name' -Context "stages.$StageName.customDomain")).ToLowerInvariant()
 524    $waitForVerification = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $customDomain -Name 'waitForVerification' -Context "stages.$StageName.customDomain") -Context "stages.$StageName.customDomain.waitForVerification"
 525    $verificationPollIntervalSeconds = [int] (Get-RequiredValue -Object $customDomain -Name 'verificationPollIntervalSeconds' -Context "stages.$StageName.customDomain")
 526    $verificationTimeoutMinutes = [int] (Get-RequiredValue -Object $customDomain -Name 'verificationTimeoutMinutes' -Context "stages.$StageName.customDomain")
 527
 528    Connect-CiamGraph -TenantId $CiamTenantId
 529    Write-Step "Ensure custom primary domain '$domainName' for stage '$StageName'"
 530    $current = Get-GraphDomain -DomainName $domainName
 531    if ($null -eq $current) {
 532        $current = Invoke-GraphJson -Method post -Uri "https://graph.microsoft.com/$script:GraphApiVersion/domains" -Body ([ordered]@{ id = $domainName })
 533        if ($null -eq $current) { $current = Get-GraphDomain -DomainName $domainName }
 534        Write-Status -Status created -Details @("Custom domain: $domainName")
 535    }
 536
 537    $isVerified = [bool] (Get-PropertyValue -Object $current -Name 'isVerified')
 538    if (-not $isVerified) {
 539        $records = @(Get-GraphVerificationRecords -DomainName $domainName)
 540        if ($records.Count -gt 0) { Write-GraphVerificationRecords -DomainName $domainName -Records $records }
 541
 542        $deadline = (Get-Date).AddMinutes($verificationTimeoutMinutes)
 543        do {
 544            try {
 545                [void] (Invoke-GraphJson -Method post -Uri "https://graph.microsoft.com/$script:GraphApiVersion/domains/$([uri]::EscapeDataString($domainName))/verify")
 546            }
 547            catch {
 548                # Missing or not yet propagated DNS records are expected here.
 549                # Authorization and throttling failures are not DNS problems
 550                # and must not be hidden behind the verification timeout.
 551                $errorText = $_ | Out-String
 552                if ($errorText -match '(?i)(\b401\b|\b403\b|\b429\b|Authorization_RequestDenied|Forbidden|Unauthorized|TooManyRequests)') { throw }
 553                Write-Warning "Domain '$domainName' is not verified yet: $($_.Exception.Message)"
 554            }
 555            $current = Get-GraphDomain -DomainName $domainName
 556            $isVerified = [bool] (Get-PropertyValue -Object $current -Name 'isVerified')
 557            if ($isVerified) { break }
 558
 559            if (-not $waitForVerification) {
 560                Write-Status -Status skipped -Details @(
 561                    'Custom domain was added but is not verified.',
 562                    'Add the displayed DNS verification record and rerun the same stage.'
 563                )
 564                throw "Custom domain '$domainName' for stage '$StageName' is not verified. Add the required DNS record, then rerun with -Stage $StageName."
 565            }
 566            if ((Get-Date) -ge $deadline) {
 567                throw "Custom domain '$domainName' was not verified before the $verificationTimeoutMinutes minute timeout."
 568            }
 569            Start-Sleep -Seconds $verificationPollIntervalSeconds
 570        } while ($true)
 571    }
 572
 573    $isDefault = [bool] (Get-PropertyValue -Object $current -Name 'isDefault')
 574    if (-not $isDefault) {
 575        [void] (Invoke-GraphJson -Method patch -Uri "https://graph.microsoft.com/$script:GraphApiVersion/domains/$([uri]::EscapeDataString($domainName))" -Body ([ordered]@{ isDefault = $true }))
 576        $current = Get-GraphDomain -DomainName $domainName
 577        if (-not [bool] (Get-PropertyValue -Object $current -Name 'isDefault')) {
 578            throw "Microsoft Graph did not report custom domain '$domainName' as the default domain after the update."
 579        }
 580        Write-Status -Status updated -Details @(
 581            "Primary/default domain: $domainName",
 582            'Visible to users during sign-in where the selected CIAM flow displays the tenant domain.'
 583        )
 584    }
 585    else {
 586        Write-Status -Status unchanged -Details @(
 587            "Primary/default domain: $domainName",
 588            'Visible to users during sign-in where the selected CIAM flow displays the tenant domain.'
 589        )
 590    }
 591    return $current
 592}
 593
 594function Resolve-ConfiguredFilePath {
 595    param(
 596        [Parameter(Mandatory)][string] $Path,
 597        [Parameter(Mandatory)][string] $Context
 598    )
 599
 600    if ([string]::IsNullOrWhiteSpace($Path)) { throw "Configuration value '$Context' must not be empty." }
 601    $candidate = if ([System.IO.Path]::IsPathRooted($Path)) { $Path } else { Join-Path $script:ConfigDirectory $Path }
 602    try { return [System.IO.Path]::GetFullPath($candidate) }
 603    catch { throw "Configuration value '$Context' is not a valid file path: $Path" }
 604}
 605
 606function Get-GraphBrandingLocalizationUri {
 607    param(
 608        [Parameter(Mandatory)][string] $TenantId,
 609        [Parameter(Mandatory)][string] $Locale
 610    )
 611    return "https://graph.microsoft.com/$script:GraphApiVersion/organization/$TenantId/branding/localizations/$([uri]::EscapeDataString($Locale))"
 612}
 613
 614function Get-GraphBrandingLocalization {
 615    param(
 616        [Parameter(Mandatory)][string] $TenantId,
 617        [Parameter(Mandatory)][string] $Locale
 618    )
 619
 620    # The localization endpoint is used instead of /branding because PATCH
 621    # /branding updates the default branding, not the configured locale.
 622    $uri = Get-GraphBrandingLocalizationUri -TenantId $TenantId -Locale $Locale
 623    try {
 624        return Invoke-GraphJson -Method get -Uri $uri
 625    }
 626    catch {
 627        $errorText = $_ | Out-String
 628        if ($errorText -match '(?i)(404|Request_ResourceNotFound|ResourceNotFound)') { return $null }
 629        throw
 630    }
 631}
 632
 633function Get-BrandingAssetMetadata {
 634    param(
 635        [Parameter(Mandatory)][string] $Path,
 636        [Parameter(Mandatory)][string] $AssetName,
 637        [Parameter(Mandatory)][string] $Context
 638    )
 639
 640    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
 641        throw "Branding asset '$Context' was not found: $Path"
 642    }
 643    $extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant()
 644    if ($extension -notin @('.png', '.jpg', '.jpeg')) {
 645        throw "Branding asset '$Context' must be a PNG or JPEG file."
 646    }
 647    $fileName = [System.IO.Path]::GetFileName($Path)
 648    if ($fileName -notmatch '(?i)-(?<width>\d+)x(?<height>\d+)\.(png|jpg|jpeg)$') {
 649        throw "Branding asset '$Context' must include its dimensions in the filename, for example '-1920x1080'."
 650    }
 651
 652    Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue
 653    $image = [System.Drawing.Image]::FromFile($Path)
 654    try {
 655        $width = [int] $Matches['width']
 656        $height = [int] $Matches['height']
 657        if ($image.Width -ne $width -or $image.Height -ne $height) {
 658            throw "Branding asset '$Context' filename says ${width}x${height}, but the actual image is $($image.Width)x$($image.Height)."
 659        }
 660        return [pscustomobject]@{
 661            Path = $Path
 662            Extension = $extension
 663            Width = $width
 664            Height = $height
 665            Length = (Get-Item -LiteralPath $Path).Length
 666        }
 667    }
 668    finally { $image.Dispose() }
 669}
 670
 671function Get-BrandingAssetContentType {
 672    param([Parameter(Mandatory)][string] $Extension)
 673    if ($Extension -eq '.png') { return 'image/png' }
 674    return 'image/jpeg'
 675}
 676
 677function Assert-BrandingAsset {
 678    param(
 679        [Parameter(Mandatory)][string] $Path,
 680        [Parameter(Mandatory)][string] $AssetName,
 681        [Parameter(Mandatory)][string] $Context
 682    )
 683
 684    $metadata = Get-BrandingAssetMetadata -Path $Path -AssetName $AssetName -Context $Context
 685    $requirements = @{
 686        backgroundImage = @{ Width = 1920; Height = 1080; MaxBytes = 307200 }
 687        bannerLogo = @{ Width = 245; Height = 36; MaxBytes = 51200 }
 688        headerLogo = @{ Width = 245; Height = 36; MaxBytes = 10240 }
 689        favicon = @{ Width = 32; Height = 32; MaxBytes = 5120 }
 690        squareLogo = @{ Width = 240; Height = 240; MaxBytes = 51200 }
 691        squareLogoDark = @{ Width = 240; Height = 240; MaxBytes = 51200 }
 692    }
 693    $requirement = $requirements[$AssetName]
 694    if ($metadata.Width -ne $requirement.Width -or $metadata.Height -ne $requirement.Height) {
 695        throw "Branding asset '$Context' must be exactly $($requirement.Width)x$($requirement.Height) pixels."
 696    }
 697    if ($metadata.Length -gt $requirement.MaxBytes) {
 698        throw "Branding asset '$Context' is $($metadata.Length) bytes but must not exceed $($requirement.MaxBytes) bytes."
 699    }
 700    return $metadata
 701}
 702
 703function Get-GraphStreamHash {
 704    param([Parameter(Mandatory)][string] $Uri)
 705
 706    $temporaryFile = [System.IO.Path]::GetTempFileName()
 707    try {
 708        Remove-Item -LiteralPath $temporaryFile -Force -ErrorAction SilentlyContinue
 709        try {
 710            Invoke-GraphBinary -Method get -Uri $Uri -OutputFilePath $temporaryFile | Out-Null
 711        }
 712        catch {
 713            $errorText = $_ | Out-String
 714            if ($errorText -match '(?i)(404|Request_ResourceNotFound|ResourceNotFound)') { return $null }
 715            throw
 716        }
 717        if (-not (Test-Path -LiteralPath $temporaryFile -PathType Leaf)) { return $null }
 718        if ((Get-Item -LiteralPath $temporaryFile).Length -eq 0) { return $null }
 719        return (Get-FileHash -LiteralPath $temporaryFile -Algorithm SHA256).Hash
 720    }
 721    finally {
 722        if (Test-Path -LiteralPath $temporaryFile -PathType Leaf) {
 723            Remove-Item -LiteralPath $temporaryFile -Force -ErrorAction SilentlyContinue
 724        }
 725    }
 726}
 727
 728function Ensure-CiamBranding {
 729    param(
 730        [Parameter(Mandatory)][object] $StageConfiguration,
 731        [Parameter(Mandatory)][string] $StageName,
 732        [Parameter(Mandatory)][string] $CiamTenantId
 733    )
 734
 735    $branding = Get-RequiredObject -Object $StageConfiguration -Name 'branding' -Context "stages.$StageName"
 736    $enabled = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $branding -Name 'enabled' -Context "stages.$StageName.branding") -Context "stages.$StageName.branding.enabled"
 737    if (-not $enabled) {
 738        Write-Status -Status skipped -Details @('External ID branding is disabled for this stage.')
 739        return $null
 740    }
 741
 742    $locale = [string] (Get-RequiredValue -Object $branding -Name 'locale' -Context "stages.$StageName.branding")
 743    $localizationUri = Get-GraphBrandingLocalizationUri -TenantId $CiamTenantId -Locale $locale
 744    $brandingResource = Get-GraphBrandingLocalization -TenantId $CiamTenantId -Locale $locale
 745    $changes = [System.Collections.Generic.List[string]]::new()
 746    $brandingWasCreated = $false
 747    if ($null -eq $brandingResource) {
 748        # Creating the first localization also creates the default branding
 749        # object when the tenant does not have one yet.
 750        $createUri = "https://graph.microsoft.com/$script:GraphApiVersion/organization/$CiamTenantId/branding/localizations"
 751        [void] (Invoke-GraphJson -Method post -Uri $createUri -Body ([ordered]@{ id = $locale }))
 752        $brandingResource = Get-GraphBrandingLocalization -TenantId $CiamTenantId -Locale $locale
 753        $brandingWasCreated = $true
 754        [void] $changes.Add("Branding localization created: $locale")
 755    }
 756
 757    $desiredStrings = [ordered]@{
 758        backgroundColor = [string] (Get-RequiredValue -Object $branding -Name 'backgroundColor' -Context "stages.$StageName.branding")
 759        headerBackgroundColor = [string] (Get-RequiredValue -Object $branding -Name 'headerBackgroundColor' -Context "stages.$StageName.branding")
 760        signInPageText = [string] (Get-RequiredValue -Object $branding -Name 'signInPageText' -Context "stages.$StageName.branding")
 761        usernameHintText = [string] (Get-RequiredValue -Object $branding -Name 'usernameHintText' -Context "stages.$StageName.branding")
 762    }
 763    foreach ($optionalProperty in @('customPrivacyAndCookiesText', 'customPrivacyAndCookiesUrl', 'customTermsOfUseText', 'customTermsOfUseUrl')) {
 764        $optionalValue = Get-OptionalValue -Object $branding -Name $optionalProperty
 765        if ($null -ne $optionalValue -and -not [string]::IsNullOrWhiteSpace([string] $optionalValue)) {
 766            $desiredStrings[$optionalProperty] = [string] $optionalValue
 767        }
 768    }
 769
 770    $stringChanges = [ordered]@{}
 771    foreach ($entry in $desiredStrings.GetEnumerator()) {
 772        $currentValue = [string] (Get-PropertyValue -Object $brandingResource -Name $entry.Key)
 773        if ($currentValue -cne [string] $entry.Value) {
 774            $stringChanges[$entry.Key] = [string] $entry.Value
 775            [void] $changes.Add("Branding text setting updated: $($entry.Key)")
 776        }
 777    }
 778    if ($stringChanges.Count -gt 0) {
 779        [void] (Invoke-GraphJson -Method patch -Uri $localizationUri -Body $stringChanges)
 780        $brandingResource = Get-GraphBrandingLocalization -TenantId $CiamTenantId -Locale $locale
 781    }
 782
 783    $assets = Get-RequiredObject -Object $branding -Name 'assets' -Context "stages.$StageName.branding"
 784    $assetNames = @('backgroundImage', 'bannerLogo', 'headerLogo', 'favicon', 'squareLogo', 'squareLogoDark')
 785    foreach ($assetName in $assetNames) {
 786        $assetRelativePath = [string] (Get-RequiredValue -Object $assets -Name $assetName -Context "stages.$StageName.branding.assets")
 787        $assetPath = Resolve-ConfiguredFilePath -Path $assetRelativePath -Context "stages.$StageName.branding.assets.$assetName"
 788        $metadata = Assert-BrandingAsset -Path $assetPath -AssetName $assetName -Context "stages.$StageName.branding.assets.$assetName"
 789        $assetUri = "$localizationUri/$assetName"
 790        $localHash = (Get-FileHash -LiteralPath $assetPath -Algorithm SHA256).Hash
 791        $remoteHash = Get-GraphStreamHash -Uri $assetUri
 792        if ($localHash -cne $remoteHash) {
 793            $bytes = [System.IO.File]::ReadAllBytes($assetPath)
 794            [void] (Invoke-GraphBinary -Method put -Uri $assetUri -Body $bytes -ContentType (Get-BrandingAssetContentType -Extension $metadata.Extension))
 795            [void] $changes.Add("Branding asset updated: $assetName")
 796        }
 797    }
 798
 799    if ($brandingWasCreated) {
 800        Write-Status -Status created -Details $changes.ToArray()
 801    }
 802    elseif ($changes.Count -gt 0) {
 803        Write-Status -Status updated -Details $changes.ToArray()
 804    }
 805    else {
 806        Write-Status -Status unchanged -Details @("Branding locale: $locale")
 807    }
 808    return $brandingResource
 809}
 810
 811function Get-ResourceGroup {
 812    param([Parameter(Mandatory)][string] $Name)
 813
 814    try {
 815        return Get-AzResourceGroup -Name $Name -ErrorAction Stop
 816    }
 817    catch {
 818        if ($_.Exception.Message -match '(?i)(ResourceGroupNotFound|was not found|could not be found|404)') { return $null }
 819        throw
 820    }
 821}
 822
 823function Ensure-ResourceGroup {
 824    param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Location, [Parameter(Mandatory)][hashtable] $Tags)
 825
 826    Write-Step "Ensure resource group '$Name'"
 827    $current = Get-ResourceGroup -Name $Name
 828
 829    if ($null -eq $current) {
 830        New-AzResourceGroup -Name $Name -Location $Location -Tag $Tags -ErrorAction Stop | Out-Null
 831        Write-Status -Status created -Details @("Location: $Location")
 832        return Get-ResourceGroup -Name $Name
 833    }
 834
 835    if ([string] $current.location -cne $Location) {
 836        throw "Resource group '$Name' already exists in location '$($current.location)' but YAML requires '$Location'. Resource-group locations are immutable."
 837    }
 838
 839    $changes = [System.Collections.Generic.List[string]]::new()
 840    $currentTags = ConvertTo-StringHashtable -Object (Get-PropertyValue -Object $current -Name 'tags')
 841    foreach ($entry in $Tags.GetEnumerator() | Sort-Object Key) {
 842        if (-not $currentTags.ContainsKey($entry.Key)) {
 843            [void] $changes.Add("Tag '$($entry.Key)' added: '$($entry.Value)'")
 844        }
 845        elseif ($currentTags[$entry.Key] -cne [string] $entry.Value) {
 846            [void] $changes.Add("Tag '$($entry.Key)': '$($currentTags[$entry.Key])' -> '$($entry.Value)'")
 847        }
 848    }
 849
 850    if ($changes.Count -gt 0) {
 851        $mergedTags = ConvertTo-StringHashtable -Object (Get-PropertyValue -Object $current -Name 'Tags')
 852        foreach ($entry in $Tags.GetEnumerator()) { $mergedTags[$entry.Key] = [string] $entry.Value }
 853        Set-AzResourceGroup -Id $current.ResourceId -Tag $mergedTags -ErrorAction Stop | Out-Null
 854        Write-Status -Status updated -Details $changes.ToArray()
 855    }
 856    else {
 857        Write-Status -Status unchanged
 858    }
 859    return Get-ResourceGroup -Name $Name
 860}
 861
 862function Get-DeleteLock {
 863    param(
 864        [Parameter(Mandatory)][string] $ResourceGroupName,
 865        [Parameter(Mandatory)][string] $LockName
 866    )
 867
 868    try {
 869        return @(Get-AzResourceLock -ResourceGroupName $ResourceGroupName -LockName $LockName -AtScope -ErrorAction Stop) | Select-Object -First 1
 870    }
 871    catch {
 872        if ($_.Exception.Message -match '(?i)(ResourceLockNotFound|was not found|could not be found|404)') { return $null }
 873        throw
 874    }
 875}
 876
 877function Ensure-DeleteLock {
 878    param(
 879        [Parameter(Mandatory)][string] $ResourceGroupName,
 880        [Parameter(Mandatory)][string] $ResourceGroupId,
 881        [Parameter(Mandatory)][bool] $Enabled,
 882        [Parameter(Mandatory)][string] $LockName,
 883        [Parameter(Mandatory)][string] $Message
 884    )
 885
 886    Write-Step "Ensure delete lock '$LockName' on resource group '$ResourceGroupName'"
 887    $current = Get-DeleteLock -ResourceGroupName $ResourceGroupName -LockName $LockName
 888
 889    if (-not $Enabled) {
 890        if ($null -eq $current) {
 891            Write-Status -Status skipped -Details @('Delete lock is disabled and no configured lock exists.')
 892        }
 893        else {
 894            Write-Status -Status skipped -Details @('Delete lock is disabled; the existing lock was intentionally left unchanged.')
 895        }
 896        return $current
 897    }
 898
 899    if ($null -eq $current) {
 900        New-AzResourceLock -Scope $ResourceGroupId -LockName $LockName -LockLevel CanNotDelete -LockNotes $Message -Force -ErrorAction Stop | Out-Null
 901        Write-Status -Status created -Details @('Level: CanNotDelete', "Message: $Message")
 902        return Get-DeleteLock -ResourceGroupName $ResourceGroupName -LockName $LockName
 903    }
 904
 905    $currentProperties = Get-PropertyValue -Object $current -Name 'Properties'
 906    $currentLevel = [string] (Get-PropertyValue -Object $currentProperties -Name 'Level')
 907    if ([string]::IsNullOrWhiteSpace($currentLevel)) {
 908        $currentLevel = [string] (Get-PropertyValue -Object $current -Name 'LockLevel')
 909    }
 910    $currentNotes = [string] (Get-PropertyValue -Object $currentProperties -Name 'Notes')
 911    if ([string]::IsNullOrWhiteSpace($currentNotes)) {
 912        $currentNotes = [string] (Get-PropertyValue -Object $current -Name 'LockNotes')
 913    }
 914
 915    $changes = [System.Collections.Generic.List[string]]::new()
 916    if ($currentLevel -cne 'CanNotDelete') {
 917        [void] $changes.Add("Level: '$currentLevel' -> 'CanNotDelete'")
 918    }
 919    if ($currentNotes -cne $Message) {
 920        [void] $changes.Add("Message: '$currentNotes' -> '$Message'")
 921    }
 922
 923    if ($changes.Count -eq 0) {
 924        Write-Status -Status unchanged -Details @('Level: CanNotDelete', "Message: $Message")
 925        return $current
 926    }
 927
 928    Set-AzResourceLock -Scope $ResourceGroupId -LockName $LockName -LockLevel CanNotDelete -LockNotes $Message -Force -ErrorAction Stop | Out-Null
 929    Write-Status -Status updated -Details $changes.ToArray()
 930    return Get-DeleteLock -ResourceGroupName $ResourceGroupName -LockName $LockName
 931}
 932
 933function Get-CiamDirectory {
 934    param([Parameter(Mandatory)][string] $ResourceGroupName, [Parameter(Mandatory)][string] $ResourceName)
 935
 936    $escapedResourceGroup = [uri]::EscapeDataString($ResourceGroupName)
 937    $escapedResourceName = [uri]::EscapeDataString($ResourceName)
 938    $uri = "https://management.azure.com/subscriptions/$script:SubscriptionId/resourceGroups/$escapedResourceGroup/providers/$script:ResourceProvider/ciamDirectories/${escapedResourceName}?api-version=$script:ApiVersion"
 939    # Invoke-AzRestJson returns $null when the resource does not exist yet.
 940    return Invoke-AzRestJson -Method get -Uri $uri
 941}
 942
 943function Wait-CiamDirectory {
 944    param(
 945        [Parameter(Mandatory)][string] $ResourceGroupName,
 946        [Parameter(Mandatory)][string] $ResourceName
 947    )
 948
 949    $deadline = (Get-Date).AddMinutes($script:OperationTimeoutMinutes)
 950    $lastState = $null
 951    do {
 952        $resource = Get-CiamDirectory -ResourceGroupName $ResourceGroupName -ResourceName $ResourceName
 953        if ($null -eq $resource) {
 954            if ((Get-Date) -ge $deadline) {
 955                throw "CIAM resource '$ResourceName' was not readable before the $script:OperationTimeoutMinutes minute timeout."
 956            }
 957            Write-Verbose "CIAM resource '$ResourceName' is not visible yet; retrying in $script:PollIntervalSeconds seconds."
 958            Start-Sleep -Seconds $script:PollIntervalSeconds
 959            continue
 960        }
 961
 962        $state = [string] (Get-PropertyValue -Object (Get-PropertyValue -Object $resource -Name 'properties') -Name 'provisioningState')
 963        if ([string]::IsNullOrWhiteSpace($state)) { return $resource }
 964        if ($state -ne $lastState) {
 965            Write-Host "    Provisioning state: $state" -ForegroundColor DarkCyan
 966            $lastState = $state
 967        }
 968        switch ($state.ToLowerInvariant()) {
 969            'succeeded' { return $resource }
 970            'failed' { throw "CIAM resource '$ResourceName' provisioning failed. Azure response: $($resource | ConvertTo-Json -Depth 20 -Compress)" }
 971            'canceled' { throw "CIAM resource '$ResourceName' provisioning was canceled." }
 972            'deleted' { throw "CIAM resource '$ResourceName' was deleted while waiting for provisioning." }
 973        }
 974
 975        if ((Get-Date) -ge $deadline) {
 976            throw "CIAM resource '$ResourceName' did not reach Succeeded before the $script:OperationTimeoutMinutes minute timeout. Last state: $state."
 977        }
 978        Start-Sleep -Seconds $script:PollIntervalSeconds
 979    } while ($true)
 980}
 981
 982function Ensure-CiamDirectory {
 983    param(
 984        [Parameter(Mandatory)][object] $StageConfiguration,
 985        [Parameter(Mandatory)][string] $StageName,
 986        [Parameter(Mandatory)][hashtable] $Tags
 987    )
 988
 989    $resourceGroupName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'resourceGroupName' -Context "stages.$StageName")
 990    $resourceName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'resourceName' -Context "stages.$StageName")
 991    $displayName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'displayName' -Context "stages.$StageName")
 992
 993    $current = Get-CiamDirectory -ResourceGroupName $resourceGroupName -ResourceName $resourceName
 994    if ($null -ne $current) {
 995        $currentState = [string] (Get-PropertyValue -Object (Get-PropertyValue -Object $current -Name 'properties') -Name 'provisioningState')
 996        if (-not [string]::IsNullOrWhiteSpace($currentState) -and $currentState -ine 'Succeeded') {
 997            $current = Wait-CiamDirectory -ResourceGroupName $resourceGroupName -ResourceName $resourceName
 998        }
 999    }
1000    $bodyTags = @{}
1001    if ($null -ne $current) {
1002        $bodyTags = ConvertTo-StringHashtable -Object (Get-PropertyValue -Object $current -Name 'tags')
1003    }
1004    foreach ($entry in $Tags.GetEnumerator()) { $bodyTags[$entry.Key] = [string] $entry.Value }
1005
1006    $body = [ordered]@{
1007        location = [string] $script:CiamLocation
1008        sku = [ordered]@{
1009            name = [string] $script:SkuName
1010            tier = [string] $script:SkuTier
1011        }
1012        properties = [ordered]@{
1013            createTenantProperties = [ordered]@{
1014                displayName = $displayName
1015                countryCode = [string] $script:CountryCode
1016            }
1017        }
1018        tags = $bodyTags
1019    }
1020
1021    Write-Step "Ensure CIAM directory '$resourceName' in resource group '$resourceGroupName'"
1022    if ($null -eq $current) {
1023        [void] (Invoke-CiamPut -ResourceGroupName $resourceGroupName -ResourceName $resourceName -Body $body)
1024        $result = Wait-CiamDirectory -ResourceGroupName $resourceGroupName -ResourceName $resourceName
1025        Write-Status -Status created -Details @(
1026            "Display name: $displayName",
1027            "Primary domain: $(Get-CiamDomainName -Resource $result -ResourceName $resourceName)",
1028            "Tenant ID: $((Get-PropertyValue -Object (Get-PropertyValue -Object $result -Name 'properties') -Name 'tenantId'))"
1029        )
1030        return $result
1031    }
1032
1033    $properties = Get-PropertyValue -Object $current -Name 'properties'
1034    $createProperties = Get-PropertyValue -Object $properties -Name 'createTenantProperties'
1035    $currentLocation = [string] (Get-PropertyValue -Object $current -Name 'location')
1036    $currentCountryCode = [string] (Get-PropertyValue -Object $createProperties -Name 'countryCode')
1037    if (-not [string]::IsNullOrWhiteSpace($currentLocation) -and $currentLocation -cne $script:CiamLocation) {
1038        throw "CIAM directory '$resourceName' already uses data location '$currentLocation', but YAML requires '$script:CiamLocation'. CIAM data location is immutable."
1039    }
1040    if (-not [string]::IsNullOrWhiteSpace($currentCountryCode) -and $currentCountryCode -cne $script:CountryCode) {
1041        throw "CIAM directory '$resourceName' already uses country code '$currentCountryCode', but YAML requires '$script:CountryCode'. The country selection is immutable."
1042    }
1043
1044    $changes = [System.Collections.Generic.List[string]]::new()
1045    # createTenantProperties describe the tenant at creation time. Compare the
1046    # display name only when ARM reports it, so a response without these
1047    # properties does not cause an update on every run.
1048    $currentDisplayName = [string] (Get-PropertyValue -Object $createProperties -Name 'displayName')
1049    if (-not [string]::IsNullOrWhiteSpace($currentDisplayName) -and $currentDisplayName -cne $displayName) {
1050        [void] $changes.Add("Display name: '$currentDisplayName' -> '$displayName'")
1051    }
1052    $currentSku = Get-PropertyValue -Object $current -Name 'sku'
1053    if ([string] (Get-PropertyValue -Object $currentSku -Name 'name') -cne $script:SkuName) {
1054        [void] $changes.Add("SKU: '$([string] (Get-PropertyValue -Object $currentSku -Name 'name'))' -> '$script:SkuName'")
1055    }
1056    if ([string] (Get-PropertyValue -Object $currentSku -Name 'tier') -cne $script:SkuTier) {
1057        [void] $changes.Add("SKU tier: '$([string] (Get-PropertyValue -Object $currentSku -Name 'tier'))' -> '$script:SkuTier'")
1058    }
1059    $currentTags = ConvertTo-StringHashtable -Object (Get-PropertyValue -Object $current -Name 'tags')
1060    foreach ($entry in $Tags.GetEnumerator() | Sort-Object Key) {
1061        if (-not $currentTags.ContainsKey($entry.Key)) {
1062            [void] $changes.Add("Tag '$($entry.Key)' added: '$($entry.Value)'")
1063        }
1064        elseif ($currentTags[$entry.Key] -cne [string] $entry.Value) {
1065            [void] $changes.Add("Tag '$($entry.Key)': '$($currentTags[$entry.Key])' -> '$($entry.Value)'")
1066        }
1067    }
1068
1069    if ($changes.Count -eq 0) {
1070        Write-Status -Status unchanged -Details @(
1071            "Primary domain: $(Get-CiamDomainName -Resource $current -ResourceName $resourceName)",
1072            "Tenant ID: $((Get-PropertyValue -Object $properties -Name 'tenantId'))"
1073        )
1074        return $current
1075    }
1076
1077    [void] (Invoke-CiamPut -ResourceGroupName $resourceGroupName -ResourceName $resourceName -Body $body)
1078    $result = Wait-CiamDirectory -ResourceGroupName $resourceGroupName -ResourceName $resourceName
1079    Write-Status -Status updated -Details ($changes.ToArray() + @(
1080        "Primary domain: $(Get-CiamDomainName -Resource $result -ResourceName $resourceName)",
1081        "Tenant ID: $((Get-PropertyValue -Object (Get-PropertyValue -Object $result -Name 'properties') -Name 'tenantId'))"
1082    ))
1083    return $result
1084}
1085
1086function Invoke-CiamPut {
1087    param(
1088        [Parameter(Mandatory)][string] $ResourceGroupName,
1089        [Parameter(Mandatory)][string] $ResourceName,
1090        [Parameter(Mandatory)][object] $Body
1091    )
1092
1093    $escapedResourceGroup = [uri]::EscapeDataString($ResourceGroupName)
1094    $escapedResourceName = [uri]::EscapeDataString($ResourceName)
1095    $uri = "https://management.azure.com/subscriptions/$script:SubscriptionId/resourceGroups/$escapedResourceGroup/providers/$script:ResourceProvider/ciamDirectories/${escapedResourceName}?api-version=$script:ApiVersion"
1096    return Invoke-AzRestJson -Method put -Uri $uri -Body $Body -WaitForCompletion
1097}
1098
1099function Assert-ResourceGroupName {
1100    param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Context)
1101    if ($Name -notmatch '^[A-Za-z0-9._()\-]{1,90}$') {
1102        throw "Configuration value '$Context.resourceGroupName' must be a valid Azure resource-group name."
1103    }
1104}
1105
1106function Assert-CustomDomainName {
1107    param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Context)
1108
1109    if ($Name -match '(?i)(REPLACE|example\.com|\.onmicrosoft\.com$)') {
1110        throw "Configuration value '$Context' must be replaced with a real custom domain and must not be the initial onmicrosoft.com domain."
1111    }
1112    if ($Name.Length -gt 253 -or $Name -notmatch '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$') {
1113        throw "Configuration value '$Context' must be a fully qualified custom domain such as login-dev.contoso.com."
1114    }
1115}
1116
1117function Assert-ConfiguredStage {
1118    param([Parameter(Mandatory)][object] $StageConfiguration, [Parameter(Mandatory)][string] $StageName)
1119
1120    $enabled = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $StageConfiguration -Name 'enabled' -Context "stages.$StageName") -Context "stages.$StageName.enabled"
1121    if (-not $enabled) { throw "Stage '$StageName' is disabled in '$ConfigPath'." }
1122
1123    $resourceGroupName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'resourceGroupName' -Context "stages.$StageName")
1124    $resourceName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'resourceName' -Context "stages.$StageName")
1125    [void] (Get-RequiredValue -Object $StageConfiguration -Name 'displayName' -Context "stages.$StageName")
1126    $customDomain = Get-RequiredObject -Object $StageConfiguration -Name 'customDomain' -Context "stages.$StageName"
1127    $customDomainName = [string] (Get-RequiredValue -Object $customDomain -Name 'name' -Context "stages.$StageName.customDomain")
1128    $setAsPrimary = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $customDomain -Name 'setAsPrimary' -Context "stages.$StageName.customDomain") -Context "stages.$StageName.customDomain.setAsPrimary"
1129    if (-not $setAsPrimary) { throw "Configuration value 'stages.$StageName.customDomain.setAsPrimary' must be true. Every stage must use its custom domain as the primary/default domain." }
1130    [void] (ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $customDomain -Name 'waitForVerification' -Context "stages.$StageName.customDomain") -Context "stages.$StageName.customDomain.waitForVerification")
1131    $verificationPollIntervalSeconds = [int] (Get-RequiredValue -Object $customDomain -Name 'verificationPollIntervalSeconds' -Context "stages.$StageName.customDomain")
1132    $verificationTimeoutMinutes = [int] (Get-RequiredValue -Object $customDomain -Name 'verificationTimeoutMinutes' -Context "stages.$StageName.customDomain")
1133    $deleteLock =Get-RequiredObject -Object $StageConfiguration -Name 'deleteLock' -Context "stages.$StageName"
1134    [void] (ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $deleteLock -Name 'enabled' -Context "stages.$StageName.deleteLock") -Context "stages.$StageName.deleteLock.enabled")
1135    $lockName = [string] (Get-RequiredValue -Object $deleteLock -Name 'name' -Context "stages.$StageName.deleteLock")
1136    $lockMessage = [string] (Get-RequiredValue -Object $deleteLock -Name 'message' -Context "stages.$StageName.deleteLock")
1137    Assert-ResourceGroupName -Name $resourceGroupName -Context "stages.$StageName"
1138    if ($resourceName -notmatch '^[A-Za-z0-9]{1,26}$') {
1139        throw "Configuration value 'stages.$StageName.resourceName' must contain only letters and numbers and be at most 26 characters."
1140    }
1141    Assert-CustomDomainName -Name $customDomainName -Context "stages.$StageName.customDomain.name"
1142    if ($verificationPollIntervalSeconds -lt 5 -or $verificationPollIntervalSeconds -gt 300) {
1143        throw "Configuration value 'stages.$StageName.customDomain.verificationPollIntervalSeconds' must be between 5 and 300."
1144    }
1145    if ($verificationTimeoutMinutes -lt 1 -or $verificationTimeoutMinutes -gt 180) {
1146        throw "Configuration value 'stages.$StageName.customDomain.verificationTimeoutMinutes' must be between 1 and 180."
1147    }
1148
1149    $branding = Get-RequiredObject -Object $StageConfiguration -Name 'branding' -Context "stages.$StageName"
1150    $brandingEnabled = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $branding -Name 'enabled' -Context "stages.$StageName.branding") -Context "stages.$StageName.branding.enabled"
1151    if ($brandingEnabled) {
1152        $locale = [string] (Get-RequiredValue -Object $branding -Name 'locale' -Context "stages.$StageName.branding")
1153        $backgroundColor = [string] (Get-RequiredValue -Object $branding -Name 'backgroundColor' -Context "stages.$StageName.branding")
1154        $headerBackgroundColor = [string] (Get-RequiredValue -Object $branding -Name 'headerBackgroundColor' -Context "stages.$StageName.branding")
1155        $signInPageText = [string] (Get-RequiredValue -Object $branding -Name 'signInPageText' -Context "stages.$StageName.branding")
1156        $usernameHintText = [string] (Get-RequiredValue -Object $branding -Name 'usernameHintText' -Context "stages.$StageName.branding")
1157        if ($locale -notmatch '^[a-z]{2}-[A-Z]{2}$') { throw "Configuration value 'stages.$StageName.branding.locale' must be an RFC 1766 locale such as en-US." }
1158        if ($backgroundColor -notmatch '^#[0-9A-Fa-f]{6}$' -or $headerBackgroundColor -notmatch '^#[0-9A-Fa-f]{6}$') {
1159            throw "Configuration values 'stages.$StageName.branding.backgroundColor' and 'headerBackgroundColor' must be hexadecimal RGB colors such as #1261A0."
1160        }
1161        if ($signInPageText.Length -gt 1024) { throw "Configuration value 'stages.$StageName.branding.signInPageText' must not exceed 1024 characters." }
1162        if ($usernameHintText.Length -gt 64) { throw "Configuration value 'stages.$StageName.branding.usernameHintText' must not exceed 64 characters." }
1163        foreach ($optionalProperty in @('customPrivacyAndCookiesText', 'customTermsOfUseText')) {
1164            $optionalValue = Get-OptionalValue -Object $branding -Name $optionalProperty
1165            if ($null -ne $optionalValue -and ([string] $optionalValue).Length -gt 256) {
1166                throw "Configuration value 'stages.$StageName.branding.$optionalProperty' must not exceed 256 characters."
1167            }
1168        }
1169        foreach ($optionalProperty in @('customPrivacyAndCookiesUrl', 'customTermsOfUseUrl')) {
1170            $optionalValue = Get-OptionalValue -Object $branding -Name $optionalProperty
1171            if ($null -ne $optionalValue -and -not [string]::IsNullOrWhiteSpace([string] $optionalValue)) {
1172                if (([string] $optionalValue).Length -gt 128 -or ([string] $optionalValue) -notmatch '^https?://') {
1173                    throw "Configuration value 'stages.$StageName.branding.$optionalProperty' must be an HTTP(S) URL with at most 128 characters."
1174                }
1175            }
1176        }
1177
1178        $assets = Get-RequiredObject -Object $branding -Name 'assets' -Context "stages.$StageName.branding"
1179        foreach ($assetName in @('backgroundImage', 'bannerLogo', 'headerLogo', 'favicon', 'squareLogo', 'squareLogoDark')) {
1180            $assetRelativePath = [string] (Get-RequiredValue -Object $assets -Name $assetName -Context "stages.$StageName.branding.assets")
1181            $assetPath = Resolve-ConfiguredFilePath -Path $assetRelativePath -Context "stages.$StageName.branding.assets.$assetName"
1182            [void] (Assert-BrandingAsset -Path $assetPath -AssetName $assetName -Context "stages.$StageName.branding.assets.$assetName")
1183        }
1184    }
1185    if ($lockName -notmatch '^[A-Za-z0-9._-]{1,64}$') {
1186        throw "Configuration value 'stages.$StageName.deleteLock.name' must contain only letters, numbers, periods, underscores, or hyphens and be at most 64 characters."
1187    }
1188    if ($lockMessage -match '[\r\n]' -or [string]::IsNullOrWhiteSpace($lockMessage)) {
1189        throw "Configuration value 'stages.$StageName.deleteLock.message' must not be empty or contain line breaks."
1190    }
1191}
1192
1193$configuration = Read-AzureConfiguration
1194$script:TenantId = [string] (Get-RequiredValue -Object $configuration -Name 'tenantId' -Context 'root')
1195$script:SubscriptionId = [string] (Get-RequiredValue -Object $configuration -Name 'subscriptionId' -Context 'root')
1196$script:ResourceProvider = [string] (Get-RequiredValue -Object $configuration -Name 'resourceProvider' -Context 'root')
1197$script:ApiVersion = [string] (Get-RequiredValue -Object $configuration -Name 'apiVersion' -Context 'root')
1198
1199if ($script:TenantId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { throw "Configuration value 'tenantId' must be a tenant GUID." }
1200if ($script:SubscriptionId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { throw "Configuration value 'subscriptionId' must be a subscription GUID." }
1201if ($script:ResourceProvider -cne 'Microsoft.AzureActiveDirectory') { throw "Configuration value 'resourceProvider' must be 'Microsoft.AzureActiveDirectory'." }
1202if ($script:ApiVersion -cne '2023-05-17-preview') { throw "Configuration value 'apiVersion' must be the pinned CIAM API version '2023-05-17-preview'." }
1203
1204$management = Get-RequiredObject -Object $configuration -Name 'management' -Context 'root'
1205$script:ResourceGroupLocation = [string] (Get-RequiredValue -Object $management -Name 'resourceGroupLocation' -Context 'management')
1206$script:PollIntervalSeconds = [int] (Get-RequiredValue -Object $management -Name 'pollIntervalSeconds' -Context 'management')
1207$script:OperationTimeoutMinutes = [int] (Get-RequiredValue -Object $management -Name 'operationTimeoutMinutes' -Context 'management')
1208if ($script:PollIntervalSeconds -lt 5 -or $script:PollIntervalSeconds -gt 60) { throw "Configuration value 'management.pollIntervalSeconds' must be between 5 and 60." }
1209if ($script:OperationTimeoutMinutes -lt 1 -or $script:OperationTimeoutMinutes -gt 180) { throw "Configuration value 'management.operationTimeoutMinutes' must be between 1 and 180." }
1210
1211$graph = Get-RequiredObject -Object $configuration -Name 'graph' -Context 'root'
1212$script:GraphApiVersion = [string] (Get-RequiredValue -Object $graph -Name 'apiVersion' -Context 'graph')
1213$configuredGraphScopes = Get-RequiredValue -Object $graph -Name 'scopes' -Context 'graph'
1214$script:GraphScopes = @($configuredGraphScopes | ForEach-Object { [string] $_ })
1215$script:GraphUseDeviceCode = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $graph -Name 'useDeviceCode' -Context 'graph') -Context 'graph.useDeviceCode'
1216if ($script:GraphApiVersion -cne 'v1.0') { throw "Configuration value 'graph.apiVersion' must be 'v1.0' for the current stable Microsoft Graph domain API." }
1217foreach ($requiredGraphScope in @('Domain.ReadWrite.All', 'OrganizationalBranding.ReadWrite.All')) {
1218    if ($requiredGraphScope -notin $script:GraphScopes) { throw "Configuration value 'graph.scopes' must include '$requiredGraphScope'." }
1219}
1220
1221$ciam = Get-RequiredObject -Object $configuration -Name 'ciam' -Context 'root'
1222$script:CiamLocation = [string] (Get-RequiredValue -Object $ciam -Name 'location' -Context 'ciam')
1223$script:CountryCode = ([string] (Get-RequiredValue -Object $ciam -Name 'countryCode' -Context 'ciam')).ToUpperInvariant()
1224$sku = Get-RequiredObject -Object $ciam -Name 'sku' -Context 'ciam'
1225$script:SkuName = [string] (Get-RequiredValue -Object $sku -Name 'name' -Context 'ciam.sku')
1226$script:SkuTier = [string] (Get-RequiredValue -Object $sku -Name 'tier' -Context 'ciam.sku')
1227$commonTags = ConvertTo-StringHashtable -Object (Get-RequiredObject -Object $ciam -Name 'tags' -Context 'ciam')
1228
1229if ($script:CiamLocation -notin @('United States', 'Europe', 'Asia Pacific', 'Australia')) { throw "Configuration value 'ciam.location' must be United States, Europe, Asia Pacific, or Australia." }
1230if ($script:CountryCode -notmatch '^[A-Z]{2}$') { throw "Configuration value 'ciam.countryCode' must be a two-letter country code." }
1231if ($script:SkuName -notin @('Standard', 'PremiumP1', 'PremiumP2')) { throw "Configuration value 'ciam.sku.name' must be Standard, PremiumP1, or PremiumP2." }
1232if ($script:SkuTier -cne 'A0') { throw "Configuration value 'ciam.sku.tier' must be A0 for CIAM." }
1233
1234$stages = Get-RequiredObject -Object $configuration -Name 'stages' -Context 'root'
1235$stageProperty = $stages.PSObject.Properties[$Stage]
1236if ($null -eq $stageProperty) { throw "Stage '$Stage' was not found in '$ConfigPath'. Configured stages: $($stages.PSObject.Properties.Name -join ', ')." }
1237Assert-ConfiguredStage -StageConfiguration $stageProperty.Value -StageName $Stage
1238
1239$context = Get-AzContext
1240if ($null -eq $context) {
1241    throw "No Azure PowerShell context is available. Run 'Connect-AzAccount -Tenant $script:TenantId -Subscription $script:SubscriptionId' first."
1242}
1243if ([string] $context.Subscription.Id -ne $script:SubscriptionId) {
1244    Set-AzContext -SubscriptionId $script:SubscriptionId -ErrorAction Stop | Out-Null
1245    $context = Get-AzContext
1246}
1247$activeTenantId = [string] (Get-PropertyValue -Object (Get-PropertyValue -Object $context -Name 'Tenant') -Name 'Id')
1248if ([string]::IsNullOrWhiteSpace($activeTenantId) -or $activeTenantId -ne $script:TenantId) {
1249    throw "The active Azure PowerShell tenant '$activeTenantId' does not match configured tenant '$script:TenantId'. Run 'Connect-AzAccount -Tenant $script:TenantId' and try again."
1250}
1251
1252Write-Host ''
1253Write-Host 'Review CIAM target' -ForegroundColor Yellow
1254Write-Host "  Tenant:        $script:TenantId"
1255Write-Host "  Subscription:  $($context.Subscription.Name) ($($context.Subscription.Id))"
1256Write-Host "  API version:   $script:ApiVersion"
1257Write-Host "  Graph API:     $script:GraphApiVersion ($($script:GraphScopes -join ', '))"
1258Write-Host "  Data location: $script:CiamLocation"
1259Write-Host "  Country code:  $script:CountryCode"
1260Write-Host "  Stage:         $Stage"
1261Write-Host ''
1262
1263if (-not $PSCmdlet.ShouldProcess("CIAM directory for stage $Stage", 'Create or update')) {
1264    Write-Host 'Configuration was suppressed. No Azure resources were changed.' -ForegroundColor Yellow
1265    return
1266}
1267
1268[string] $stageName = $Stage
1269[object] $stageConfiguration = $stageProperty.Value
1270[hashtable] $stageTags = Merge-Tags -Base $commonTags -Additional (
1271    Get-OptionalValue -Object $stageConfiguration -Name 'tags'
1272)
1273$stageTags['environment'] = $stageName
1274
1275[string] $resourceGroupName = Get-RequiredValue `
1276    -Object $stageConfiguration -Name 'resourceGroupName' `
1277    -Context "stages.$stageName"
1278
1279[object] $resourceGroup = Ensure-ResourceGroup `
1280    -Name $resourceGroupName `
1281    -Location $script:ResourceGroupLocation `
1282    -Tags $stageTags
1283
1284[object] $deleteLock = Get-RequiredObject `
1285    -Object $stageConfiguration -Name 'deleteLock' `
1286    -Context "stages.$stageName"
1287
1288[hashtable] $lockParameters = @{
1289    ResourceGroupName = $resourceGroupName
1290    ResourceGroupId = $resourceGroup.ResourceId
1291    Enabled = ConvertTo-ConfigBoolean -Value $deleteLock.enabled `
1292        -Context "stages.$stageName.deleteLock.enabled"
1293    LockName = [string] $deleteLock.name
1294    Message = [string] $deleteLock.message
1295}
1296[void] (Ensure-DeleteLock @lockParameters)
1297
1298[hashtable] $stageParameters = @{
1299    StageConfiguration = $stageConfiguration
1300    StageName = $stageName
1301}
1302[object] $ciamResource = Ensure-CiamDirectory @stageParameters -Tags $stageTags
1303[string] $ciamTenantId = Get-PropertyValue `
1304    -Object (Get-PropertyValue -Object $ciamResource -Name 'properties') `
1305    -Name 'tenantId'
1306[guid] $parsedTenantId = [guid]::Empty
1307if (-not [guid]::TryParse($ciamTenantId, [ref] $parsedTenantId)) {
1308    throw "CIAM resource '$stageName' did not return a valid tenant ID."
1309}
1310
1311[void] (Ensure-CiamCustomDomain @stageParameters -CiamTenantId $ciamTenantId)
1312[void] (Ensure-CiamBranding @stageParameters -CiamTenantId $ciamTenantId)
1313
1314Write-Host ''
1315Write-Host 'CIAM provisioning completed.' -ForegroundColor Green

Operate the setup as a sequence of recoverable changes

There is no transaction spanning resource group creation, tenant provisioning, DNS verification and Graph branding. A failed run can leave a successfully provisioned tenant with unfinished domain or branding configuration.

The appropriate recovery is usually to resolve the failing prerequisite and rerun the same environment. Deleting the tenant to start over risks destroying identity state and changes identifiers on which applications depend.

The setup’s created, updated, unchanged and skipped messages make these outcomes easier to distinguish. A useful operational review checks that:

  • ARM reports successful provisioning and the expected external tenant ID.
  • Graph authentication targets that external tenant.
  • The custom domain is verified and has the intended default status.
  • Branding properties and image content match the selected environment.
  • A second run with unchanged configuration avoids unnecessary writes.
  • A real sign-in flow displays the intended branding after application configuration is complete.

For broader automation, transient service failures need bounded retries, throttling responses need their prescribed delay, and logs need enough context to diagnose the failing operation without recording access tokens.

Identity configuration deserves the same discipline as application infrastructure

CIAM centralizes a demanding part of application security, but the identity environment still needs deliberate configuration and operation. Tenant separation, explicit API versions, versioned branding assets and resumable domain verification make that environment easier to review and maintain.

Azure Resource Manager provides the directory foundation. Microsoft Graph configures the customer-facing tenant. Keeping their authentication contexts and responsibilities explicit produces a repeatable setup with clear recovery points, while leaving application authorization and complete sign-in behavior visible as separate responsibilities.

Related articles

Top 10 recommendations for .NET developers working with Azure Cosmos DB

Jul 10, 2026 - 11 min read

Top 10 recommendations for .NET developers working with Azure Cosmos DB

Azure Cosmos DB has a habit of exposing architectural weaknesses very early. Classic data-access patterns that work perfectly fine with …

Production Rate Limiting in ASP.NET Core

Nov 3, 2026 - 16 min read

Production Rate Limiting in ASP.NET Core

Most ASP.NET Core rate-limiting examples stop after registering a fixed window. That is enough to demonstrate the middleware, but it is not …

Distroless, Chiseled or Alpine: Choosing a .NET Base Image

Oct 27, 2026 - 10 min read

Distroless, Chiseled or Alpine: Choosing a .NET Base Image

Distroless, Chiseled and Alpine are often compared as three equivalent Linux distributions. They are not. Alpine is a distribution built …


BEN ABT

BEN ABT

Ben is Chief Engineering Officer and Principal Software Engineer at Medialesson.de, with a strong focus on .NET and Microsoft Azure. In his professional role, he designs and builds highly scalable platforms for hybrid and cloud-based scenarios. He also advises companies and C-levels on migrating to the sovereign cloud.

He runs myCSharp.de, the largest and most active German-speaking C# community forum, actively contributes to both his own and external open-source projects and regularly shares his knowledge through his blog, at community events, conferences and as a writer for technical magazines.

Let's Work Together

Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

New Platforms

Modernization

Training & Consulting

Contact Me
X Facebook LinkedIn WhatsApp
Back

Open for work!

Contact Me
BEN ABT BEN ABT Software Engineer · Microsoft MVP Passionate about .NET, Azure, performance optimization and sustainable software engineering.

Medialesson GmbH Logo

Microsoft MVP Logo

Recent posts

View all
Automating Microsoft Entra External ID: CIAM Provisioning and Branding

Automating Microsoft Entra External ID: CIAM Provisioning …

Nov 10, 2026
Production Rate Limiting in ASP.NET Core

Production Rate Limiting in ASP.NET Core

Nov 3, 2026
The Big Decimal Bugfix with .NET 11

The Big Decimal Bugfix with .NET 11

Nov 2, 2026
Distroless, Chiseled or Alpine: Choosing a .NET Base Image

Distroless, Chiseled or Alpine: Choosing a .NET Base Image

Oct 27, 2026
Stop Using nginx as Your Default Static File Server in Docker

Stop Using nginx as Your Default Static File Server in …

Oct 24, 2026

Tags

.NET (195) Csharp (99) ASP.NET Core (49) Azure (42) DevOps (17) Docker (15) Microsoft (14) Security (12) EF Core (11) Microsoft MVP (11) AI (10) GitHub (10) GitHub Copilot (10) Architecture (9) ASP.NET (9) Synology (9) .NET 11 (8) Raspberry (8) Azure Functions (7) Cloudflare (7)
benjamin-abt.com

About BEN

Benjamin Abt is a Software Engineer, Platform Architect and Microsoft MVP specializing in .NET, Azure, cloud architecture, performance optimization and sustainable software development.

Connect

  • Follow me on GitHub
  • Follow me on LinkedIn
  • Follow me on Mastodon
  • Microsoft MVP profile
  • Follow SchwabenCode on GitHub
  • Follow me on X

Quick Links

  • Portfolio
  • Blog
  • Workshops & Training
  • Publications
  • Get in Touch
  • Sitemap
Open for Work

© Copyright Benjamin Abt & SchwabenCode | 2011 - 2026

Content licensed under CC BY 4.0 unless otherwise noted.

  • Impressum
  • Privacy
  • Contact