Post

Azure B2C Custom Policies Deployment - Azure DevOps

Update 08.08.2023

After testing for a while, I found a few issues for example validation failed if we add more tokens to replace. so for fixing this, we need to tweak a few things in the policy.

  • Add new folder ´templates´ inside pipelines
  • Add a new YML file, azure-b2c-jobs.yml, and add following
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
parameters:
  - name: PoliciesFolderPath
    type: string
    default: false
  - name: KeysToReplace
    type: string
    default: false
  - name: VmImage
    default: "ubuntu-latest"

jobs:
  - job: ValidateAndDeploy
    pool:
      vmImage: $
    steps:
      - checkout: self
      - task: PowerShell@2
        displayName: Replace Tokens
        inputs:
          filePath: "pipelines/scripts/ReplaceToken.ps1"
          arguments: -folderPath $  -keysToReplace $
          pwsh: true

      - task: PowerShell@2
        displayName: Validate policies
        inputs:
          filePath: "pipelines/scripts/ValidateXml.ps1"
          arguments: $
          pwsh: true

      - task: PowerShell@2
        displayName: Deploy policies
        inputs:
          filePath: "pipelines/scripts/Deploy.ps1"
          arguments: $(ClientId) $(ClientSecret) $(TenantId) $
          pwsh: true

  • Update the azure-pipeline.yml file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
trigger: none

variables:
  - group: GraphApiCreds
stages:
  - stage: Release_QA
    displayName: Release QA
    variables:
      - group: QAPolicy
    jobs:
      - template: templates/azure-b2c-jobs.yml
        parameters:
          PoliciesFolderPath: "Policies"
          KeysToReplace: "TENANTNAME,DEPLOYMENTMODE"
  - stage: Release_Production
    displayName: Release Production
    variables:
      - group: ProductionPolicy
    jobs:
      - template: templates/azure-b2c-jobs.yml
        parameters:
          PoliciesFolderPath: "Policies"
          KeysToReplace: "TENANTNAME,DEPLOYMENTMODE"

After creating a build and deployment task 4 years back, I am back with another trick.

Why not use the old task?

Even though it was a fun project, it is very hard to maintain it, so I decided to make it simpler and use Powershell instead.

Steps

  • Please follow steps from Microsoft and note down applicationId, clientSecret and tenantId.
  • Create the following folder structure

    Folder Structure

  • Deploy.ps1 contains the script to deploy the files in azure b2c, it takes clientId, clientSectet, tenantId, and folderPath as input.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
param(
    [Parameter(Mandatory = $true)]
    [string]$clientID,

    [Parameter(Mandatory = $true)]
    [string]$clientSecret,

    [Parameter(Mandatory = $true)]
    [string]$tenantId,

    [Parameter(Mandatory = $true)]
    [string]$folderPath
)

try {

    $xmlFiles = Get-ChildItem -Path $folderPath -Force | Where-Object -FilterScript {
        $_.Extension -eq ".xml"
    }

    if ($xmlFiles.Count -eq 0) {
        Write-Warning "No XML files found in the specified path $($folderPath)"
        exit
    }

    $body = @{grant_type = "client_credentials"; scope = "https://graph.microsoft.com/.default"; client_id = $ClientID; client_secret = $ClientSecret }

    $response = Invoke-RestMethod -Uri https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token -Method Post -Body $body
    $token = $response.access_token

    $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
    $headers.Add("Content-Type", 'application/xml')
    $headers.Add("Authorization", 'Bearer ' + $token)

    Foreach ($xmlfile in $xmlFiles) {
        $filePath = $folderPath +'/'+ $xmlfile.Name
        # Check if file exists
        $FileExists = Test-Path -Path $filePath -PathType Leaf

        if ($FileExists) {
            $policycontent = Get-Content $filePath -Encoding UTF8

            # Get the policy name from the XML document
            $match = Select-String -InputObject $policycontent  -Pattern '(?<=\bPolicyId=")[^"]*'

            If ($match.matches.groups.count -ge 1) {
                $PolicyId = $match.matches.groups[0].value

                Write-Host "Uploading the" $PolicyId "policy..."

                $graphuri = 'https://graph.microsoft.com/beta/trustframework/policies/' + $PolicyId + '/$value'
                $content = [System.Text.Encoding]::UTF8.GetBytes($policycontent)
                $response = Invoke-RestMethod -Uri $graphuri -Method Put -Body $content -Headers $headers -ContentType "application/xml; charset=utf-8"

                Write-Host "Policy" $PolicyId "uploaded successfully."
            }
        }
        else {
            $warning = "File " + $filePath + " couldn't be not found."
            Write-Warning -Message $warning
        }
    }
}
catch {
    Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__

    $_

    $streamReader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
    $streamReader.BaseStream.Position = 0
    $streamReader.DiscardBufferedData()
    $errResp = $streamReader.ReadToEnd()
    $streamReader.Close()

    $ErrResp

    exit 1
}

exit 0


  • ReplaceToken.ps1 contains the script to replace the token within custom policies, it reads the values from libraries.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
param(
    [Parameter(Mandatory = $true)]
    [string]$folderPath,

    [Parameter(Mandatory = $true)]
    [string[]]$keysToReplace
)


if ($keysToReplace.Count -eq 0) {
    Write-Warning "no key found to replace"
    exit 1
}
$xmlFiles = Get-ChildItem -Path $folderPath -Force | Where-Object -FilterScript {
    $_.Extension -eq ".xml"
    Write-Host $_
}

if ($xmlFiles.Count -eq 0) {
    Write-Warning "No XML files found in the specified path"
    exit 1
}
Foreach ($file in $xmlFiles) {
    $filePath = $folderPath +'/'+ $file.Name
    # Check if file exists
    $FileExists = Test-Path -Path $filePath -PathType Leaf

    if ($FileExists) {
        $policycontent = Get-Content $filePath -Encoding UTF8
        foreach ($key in $keysToReplace) {
            $policycontent = $policycontent.Replace($key, $(Get-Content env:$key))
        }
        Set-Content $filepath -Value $policycontent -Force
    }
    else {
        $warning = "File " + $filePath + " couldn't be not found."
        Write-Warning -Message $warning
    }
}


  • ValidateXml.ps1 file contains script to validate the XML file against the XSD. it give if there are any error in a line and position.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
param (
    [Parameter(Mandatory = $true)]
    [string]$xmlFolderPath
)


$xmlFiles = Get-ChildItem -Path $xmlFolderPath -Force | Where-Object -FilterScript {
    $_.Extension -eq ".xml"
}

if ($xmlFiles.Count -eq 0) {
    Write-Warning "No XML files found in the specified path"
    exit
}


$handler = [System.Xml.Schema.ValidationEventHandler] {
    $copy = $_
    switch ($args.Severity) {
        Error {
            Write-Error "ERROR: line $($copy.Exception.LineNumber)"
            Write-Error "position $($copy.Exception.LinePosition)"
            Write-Error $copy.Message
            throw
        }
        Warning {
            Write-Warning "Warning:: " + $copy.Message
            break
        }
    }
}

try {
    Push-Location (Split-Path $xmlFolderPath)
    $isValid = $true
    $failedFiles = @()
    $xsd = "https://raw.githubusercontent.com/Azure-Samples/active-directory-b2c-custom-policy-starterpack/master/TrustFrameworkPolicy_0.3.0.0.xsd"
    Invoke-WebRequest -Uri $xsd -OutFile "TrustFrameworkPolicy_0.3.0.0.xsd"
    foreach ($xmlfile in $xmlFiles) {
        Write-Host "Validating $($xmlFolderPath + $xmlfile.Name)" -ForegroundColor Cyan

        $settings = new-object System.Xml.XmlReaderSettings
        $null = $settings.Schemas.Add("http://schemas.microsoft.com/online/cpim/schemas/2013/06", "$(Get-Location)/TrustFrameworkPolicy_0.3.0.0.xsd")
        $null = $settings.ValidationType = [System.Xml.ValidationType]::Schema


        $settings.add_ValidationEventHandler($handler)
        $reader = [System.Xml.XmlReader]::Create($xmlfile, $settings)
        $document = new-object System.Xml.XmlDocument
        try {
            $null = $document.Load($reader)
        }
        catch {
            $isValid = $false
            $failedFiles += $xmlfile.Name
        }
        $null = $reader.Close()
    }
    if ($isValid -eq $false) {
        Write-Error "Validation failed for $($failedFiles)"
        exit 1
    }
}
catch {
    throw
}
finally {
    Remove-Item -Path "TrustFrameworkPolicy_0.3.0.0.xsd"
    Pop-Location
}


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
trigger: none

variables:
  - group: GraphApiCreds
stages:
  - stage: Validate
    displayName: Validate
    pool:
      vmImage: ubuntu-latest
    jobs:
      - job: Validate
        steps:
          - task: PowerShell@2
            displayName: Validate policies
            inputs:
              filePath: "pipelines/scripts/ValidateXml.ps1"
              arguments: "Policies"
              pwsh: true
          - publish: $(System.DefaultWorkingDirectory)/Policies
            artifact: drop

  - stage: DeployQA
    displayName: Deploy To QA
    pool:
      vmImage: ubuntu-latest
    variables:
      - group: ProductionPolicy
    dependsOn: [Validate]
    jobs:
      - deployment: DeployQA
        displayName: Deploy to QA
        environment: QA
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self
                - download: current
                  artifact: drop
                  patterns: "**/*.xml"
                - task: PowerShell@2
                  displayName: Replace Tokens
                  inputs:
                    filePath: "pipelines/scripts/ReplaceToken.ps1"
                    arguments: -folderPath $(Pipeline.Workspace)/drop  -keysToReplace TENANTNAME
                    pwsh: true
                - task: PowerShell@2
                  displayName: Deploy policies
                  inputs:
                    filePath: "pipelines/scripts/Deploy.ps1"
                    arguments: $(ClientId) $(ClientSecret) $(TenantId) $(Pipeline.Workspace)/drop
                    pwsh: true
  • In yml file, at the moment I have only one key to replace(-keysToReplace), but you can add multiple keys here and it needs to be comma separated.
  • We need to have at least 2 variable groups, one for graph API credentials and the other one for keys and values.
  • Create a new variable group (inside the library) GraphApiCreds and add ClientId, ClientSecret, and TenantId
  • Create another variable group QAPolicy and add the keys you want to replace with their values. for example TenantName and tenant name value.
  • Create an environment and name it QA, we can add approvals here or you can skip this part.
  • Now in Azure DevOps create a new pipeline and point it to the YML file, and validate it.

Conclusion

Hope this blog post will help you automate the custom policies, if you face any issues please leave a comment here. cheers.

This post is licensed under CC BY 4.0 by the author.