Update Redirect URIs from Azure DevOps
azure b2c azure-devops powershell

Automatically updating Redirect URIs of an application registered in Azure AD B2C, from Azure DevOps pipeline, with PowerShell Core.
November 30, 2021

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

There's also a GitHub repository with full scripts.

Scenario

Imagine a situation where developers are able to deploy on demand cloud environments to test their feature branches, using Azure DevOps pipelines. Each environment is assigned a name, based on a Git branch, and a frontend FQDN (fully-qualified domain name). Let's say that the web application is using Azure AD B2C for user authentication, so the Redirect URIs section of the app registration needs to be updated with the new environment's FQDN after each deployment. And then also removed, once the environment is destroyed.

image-20211129150135083

So the ultimate goal of this scenario is to enable an Azure DevOps pipeline to update Redirect URIs of a single-page application (SPA) registration within Azure AD B2C. It shouldn't come as a surprise that this process has some complexity to it.

My solution uses PowerShell Core, which works on Windows, Linux and Mac, and I tested my scripts on Windows and WSL Linux.

tl;dr

Create a self-signed certificate, use the public key to register a service principal in B2C, grant the Application.ReadWrite.All Graph permissions.

Store the private key in Secure files in ADO.

In the pipeline use the DownloadSecureFile@1 task and authenticate to Graph using Connect-MgGraph -ClientID "$(b2cAdoClientId)" -TenantId "$tenantId" -Certificate $Certificate.

Add the new URI to the RedirectUris collection ($app.Spa.RedirectUris += "$newUri") and update the app (Update-MgApplication -ApplicationId $app.Id -Spa $app.Spa).

Microsoft Graph authentication

In order to call Microsoft Graph and make changes in a B2C application registration, we first need a service principal with the right permissions. Microsoft Graph PowerShell modules support two modes of authentication:

The app-only access, which we're going to use, requires authentication with a client ID and a certificate (instead of password). The application is registered in B2C with public key and the DevOps pipeline uses the corresponding private key to authenticate Graph requests.

So what needs to happen before we even begin updating the application registration in B2C?

  1. Create a certificate (public and private key)
  2. Create and app registration with the certificate for credentials
  3. Create a service principal for this app registration
  4. Grant Microsoft Graph admin consent
  5. Upload the certificate to Azure DevOps secure files
  6. Update Azure DevOps variable groups with client ID and certificate name & password

Create a certificate

The easy way to get a certificate is to create a self-signed one with PowerShell.

$CertificateName = "e2e dev"
$ExportDirectoryPath = "./"

# Remove spaces from the certificate name and also all file names. Not absolutely necessary, just to get safer values in the pipeline.
$CertificateName = $CertificateName.Replace(" ", "_")

$cerPath = Join-Path -Path $ExportDirectoryPath -ChildPath "$($CertificateName).cer"
$pfxPath = Join-Path -Path $ExportDirectoryPath -ChildPath "$($CertificateName).pfx"

# Certificate expires after 10 years.
$cert = New-SelfSignedCertificate `
  -Subject "CN=$CertificateName" `
  -CertStoreLocation "Cert:\CurrentUser\My" `
  -KeyExportPolicy Exportable `
  -KeySpec Signature `
  -KeyLength 2048 `
  -KeyAlgorithm RSA `
  -HashAlgorithm SHA256 `
  -NotAfter (Get-Date).AddYears(10)

Export-Certificate `
  -Cert $cert `
  -FilePath $cerPath

# Get-RandomPassword is a custom function defined elsewhere.
$generatedPassword = (Get-RandomPassword -Length 16)
$mypwd = ConvertTo-SecureString -String $generatedPassword -Force -AsPlainText

Export-PfxCertificate `
  -Cert $cert `
  -FilePath $pfxPath `
  -Password $mypwd

# Cleanup local store.
$certInstore = Get-ChildItem -Path "Cert:\CurrentUser\My" | Where-Object { $_.Subject -Match $CertificateName } | Select-Object Thumbprint, FriendlyName
Remove-Item -Path Cert:\CurrentUser\My\$($certInstore.Thumbprint) -DeleteKey

I was a bit worried about the certificate store API and it's Linux support, but turns out that this approach works in PowerShell Core on both Windows and Linux. In the end you'll get a CER file (public key), PFX file (private key) and password for the private key.

For completeness, this is the function I used to generate the password:

# Based on: https://devblogs.microsoft.com/scripting/generating-a-new-password-with-windows-powershell/
function Get-RandomPassword {
  param(
    [int] $Length
  )

  # Selection of PowerShell/ADO-safe characters for the password.
  $ascii = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".ToCharArray()

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

  return $TempPassword
}

(There were some issues with characters like ^ used in a DevOps pipeline, so I simplified the charset to include only "safe" characters.)

Create app registration with certificate authentication

Moving on, we start interacting with the target B2C tenant (where the application to be updated lives). This requires the Microsoft.Graph PowerShell module, authenticated for this particular tenant. See in the previous post how the authentication works.

Let's create an app registration and service principal. It will need access to app registrations within the B2C tenant, which translates into the Application.ReadWrite.All Microsoft Graph scope.

$graphResourceId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph well-known ID
$applicationReadWriteAll = @{
  Id   = "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9" # "Application.ReadWrite.All", well-known ID across all B2C tenants
  Type = "Role"
}

These GUIDs are constant values. Check the previous post which explains how they work and how to get the right value.

With these value set, we can create the app registration:

# Load public key into memory
# $CertPath is the full filesystem path to the CER file. It can come from the previous step, or can be provided as a parameter.
#	$CertPath = $createdCert.cerPath | Resolve-Path
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertPath)

# Inline (PowerShell dictionary) version doesn't work in this case...
$graphRequiredResourceAccess = New-Object -TypeName Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess
$graphRequiredResourceAccess.ResourceAccess = $applicationReadWriteAll
$graphRequiredResourceAccess.ResourceAppId = $graphResourceId

# Create app registration
#   "AzureADMyOrg" is a constant, don't change
#   "RedirectUris" cannot be empty - using localhost to start with
Write-Host "Creating app registration..."
$appRegistration = New-MgApplication `
  -DisplayName $AppName `
  -SignInAudience "AzureADMyOrg" `
  -Web @{ RedirectUris = "http://localhost"; } `
  -RequiredResourceAccess $graphRequiredResourceAccess `
  -AdditionalProperties @{} `
  -KeyCredentials @(@{ Type = "AsymmetricX509Cert"; Usage = "Verify"; Key = $certificate.RawData })

# Create corresponding service principal
$appServicePrincipal = New-MgServicePrincipal -AppId $appRegistration.AppId -AdditionalProperties @{}

Since the service principal will have tenant-wide access, it's necessary to provide admin consent for the scope:

# AppId 00000003-0000-0000-c000-000000000000 is the Microsoft Graph application
$graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"

New-MgServicePrincipalAppRoleAssignment `
  -ServicePrincipalId $appServicePrincipal.Id `
  -ResourceId $graphServicePrincipal.Id `
  -AppRoleId $applicationReadWriteAll.Id `
  -PrincipalId $appServicePrincipal.Id

Update Azure DevOps variable group with client ID and certificate information

To work with Azure DevOps resources, we can use Azure CLI or REST APIs. I chose the CLI route and authenticated with a PAT (Personal Access Token).

This PAT needs to have access to Variable Groups and Secure Files. How to: Create a PAT

image-20211130145148003

image-20211130145202613

Security warning: These scopes are quite sensitive, because they allow anyone who gets hold of the token to read settings in variable groups. If you run this script only once, it might be a good idea to remove these permissions or remove the token until needed again.

$PatToken | az devops login --organization $OrganizationUrl

The command to modify a variable group is az pipelines variable-group update, but unfortunately it doesn't accept the group's name as a parameter and requires the ID. Let's do a lookup for that first:

$variableGroupId = (az pipelines variable-group list --organization $OrganizationUrl --project $Project | ConvertFrom-Json | Where-Object { $_.Name -eq "$($VariableGroupName)" }).Id

The --organization and --project parameters are used frequently, so if you aren't switching between projects often, set default values appropriately:

az devops configure --defaults organization=https://dev.azure.com/myorganization project=MyWebApp

Optionally, if the variable group doesn't exist yet, we might want to create it instead of failing the script:

# Create the variable group if it doesn't exist.
if ($variableGroupId -eq $null) {
  Write-Host "Creating variable group $VariableGroupName..."
  # New variable group cannot be empty - initializing with temporary variable.
  # The other vars are created in a ForEach loop, because we need both non-secret and secret variables (which cannot be created during `variable-group create/update`).
  $variableGroupId = (az pipelines variable-group create --name "$VariableGroupName" --variables "delete=me" --organization $OrganizationUrl --project $Project | ConvertFrom-Json).Id
}

One small inconvenience is that the variable group cannot be empty and because later in the script I'm using a loop to populate individual variables, I had to set up a temporary value of delete=me. This will be removed in the end.

If you're not using secret variables, you could skip the following step and just declare all values in the --variables parameter (like this: --variables="key=value key2=value2").

In my case individual variables are defined as a dictionary of dictionaries, so that they could also be passed from the outside as parameter. The values are:

$Variables = @{
  b2cAdoClientId = @{ value = $appRegistration.AppId; secret = "false" };
  b2cAdoClientCertName = @{ value = $createdCert.certName; secret = "false" };
  b2cAdoClientCertPassword = @{ value = $createdCert.password; secret = "true" }
}

Variables are created in a loop, iterating through keys of the dictionary. This approach supports both plaintext and secret values. If the variable already exists, it will be updated with a new value.

$Variables.Keys | % {
  Write-Host "Creating variable $_ ..."
  az pipelines variable-group variable create --organization $OrganizationUrl --project $Project --group-id $variableGroupId --name $_ --value "$($Variables[$_].value)" --secret "$($Variables[$_].secret)"

  if ($LASTEXITCODE -eq 1) {
    # Creation failed, the variable might exist - let's try update
    Write-Host "Creation failed. Trying update of existing variable..."
    az pipelines variable-group variable update --organization $OrganizationUrl --project $Project --group-id $variableGroupId --name $_ --value "$($Variables[$_].value)" --secret "$($Variables[$_].secret)"
  }
}

Finally, remove the temporary variable:

az pipelines variable-group variable delete --group-id $variableGroupId --name "delete" --organization $OrganizationUrl --project $Project -y

Upload certificate to Azure DevOps secure files

The private key of our service principal certificate (the PFX file) will be uploaded to the Secure Files section of Azure DevOps Library, so that pipelines can then download and use it securely.

image-20211130184038155

Similar to variable groups, ADO has a REST API for secure files. Unfortunately, it's a bit harder to access, because currently:

For these reasons, we have to use a low-level HTTP call to upload the certificate.

$SecureFileName = "FileName_FromSecureFiles"

$secureFilesBaseUri = "$OrganizationUrl/$Project/_apis/distributedtask/securefiles"
$uploadSecureFileUri = "$($secureFilesBaseUri)?api-version=5.0-preview.1&name=$SecureFileName"

$base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f "",$PatToken)))
$headers = @{
    Authorization=("Basic {0}" -f $base64AuthInfo)
}

try {
  Invoke-RestMethod -Uri $uploadSecureFileUri -Method Post -ContentType "application/octet-stream" -Headers $headers -InFile "$SecureFileLocalPath"
}
catch {
  Write-Host "Error when creating new secure file, there's a chance it already exists."
  
  # Get existing secure file ID from the list of all files
  $secureFiles = (Invoke-RestMethod -Uri "$($secureFilesBaseUri)?api-version=5.0-preview.1" -Method Get -ContentType "application/octet-stream" -Headers $headers)
  $secureFileId = ($secureFiles.value | Where-Object { $_.name -eq "$SecureFileName" }).id
  $secureFileId

  Write-Host "Deleting the file..."
  Invoke-RestMethod -Uri "$($secureFilesBaseUri)/$($secureFileId)?api-version=5.0-preview.1" -Method Delete -ContentType "application/octet-stream" -Headers $headers

  Write-Host "Uploading again..."
  Invoke-RestMethod -Uri $uploadSecureFileUri -Method Post -ContentType "application/octet-stream" -Headers $headers -InFile "$SecureFileLocalPath"
}

If the file already exists, this script removes it, and tries to upload a new version (there's no update operation on the existing one). This might not be desireable in all cases, so keep that in mind when running the code.

Update Redirect URIs

With the preparation done, we can get to the main part - updating redirect URIs from Azure DevOps pipelines - which is almost disappointingly simple.

Moving to Azure DevOps YAML, we first have to download the secure file - our certificate:

- task: DownloadSecureFile@1
  name: graphSpCertificate
  displayName: 'Download certificate for Microsoft Graph service principal'
  inputs:
    secureFile: '$(b2cAdoClientCertName)'

Ensure that the secureFile value the same as the secure file name.

Then install the Microsoft.Graph PowerShell module:

- task: PowerShell@2
  displayName: 'Install Graph PowerShell module'
  inputs:
    targetType: 'inline'
    script: |
      # Graph module shouldn't be preinstalled on the agent, but checking anyway.
      if (Get-Module -ListAvailable -Name Microsoft.Graph) {
        Write-Host "*** Module Microsoft.Graph exists."
      }
      else {
        Write-Host "*** Installing Microsoft.Graph module..."
        Install-Module Microsoft.Graph -Force
      }      

To make changes in the application registration, our client must authenticate using the certificate:

$CertPath = "$(graphSpCertificate.secureFilePath)"
Write-Host "*** Certificate path: " $CertPath
$Certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertPath, "$(b2cAdoClientCertPassword)")

$tenantId = "$(b2cTenantName).onmicrosoft.com"

# Connecting using app-only authentication with in-memory certificate.
Connect-MgGraph -ClientID "$(b2cAdoClientId)" -TenantId "$tenantId" -Certificate $Certificate

Using the -Certificate parameter, there's no need to import the certificate to the local store first. We simply load it into memory and then use.

Next is to find the app that we're trying to modify:

$app = Get-MgApplication | Where-Object { $_.AppId -eq "$(b2cUIClientID)" }

This is a bit of a catch. The Get-MgApplication commandlet has a parameter called -ApplicationId, but that's not the same as App ID / Client ID which we have in the variable group! To get the right app registration we have to look it up in the list.

If your tenant contains more than 100 applications, beware of result paging (which for this API defaults to 100 objects per page). If there are more results, there's a chance that this script won't find your app without using the -Top & -Skip parameters or -Filter.

Once the application is retrieved, it's time to modify it. My application is a SPA (Single Page Application), so I'll be changing redirect URIs on the .Spa property:

if ($app.Spa.RedirectUris.Contains("$newUri")) {
  Write-Host "*** Redirect URI $newUri already present, skipping."
}
else {
  # Add FQDN to the registration
  $app.Spa.RedirectUris += "$newUri"

  # Update app registration
  Update-MgApplication -ApplicationId $app.Id -Spa $app.Spa
}

Removing the URI requires a bit of PowerShell trickery, because collections are immutable, so we basically have to copy all of the URIs, except the one that should be removed, into a new object:

$newRedirect = $app.Spa.RedirectUris | Where-Object { $_ -ne "$newUri" }

With the core functionality finished, we can think of reusing this code for both actions (adding and removing the redirect URI from the app registration). I solved it by simply introducing a type parameter in the pipeline:

parameters:
- name: "type"
  type: string
  values: ["add", "remove"]

And then decide the right course of action within the modification step. Full script:

- task: PowerShell@2
  displayName: 'Modify (${{ parameters.type }}) redirect URI in B2C app registration'
  inputs:
    targetType: 'inline'
    script: |
$frontDoorFqdn = "https://$(frontdoor_fqdn)"

Write-Host "*** Loading certificate..."
$CertPath = "$(graphSpCertificate.secureFilePath)"
Write-Host "*** Certificate path: " $CertPath
$Certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertPath, "$(b2cAdoClientCertPassword)")

$tenantId = "$(b2cTenantName).onmicrosoft.com"

Write-Host "*** Connecting Graph. Client ID: $(b2cAdoClientId), Tenant: $tenantId, Certificate: $($Certificate.Thumbprint) (from $CertPath)."

# Connecting using app-only authentication with in-memory certificate.
Connect-MgGraph -ClientID "$(b2cAdoClientId)" -TenantId "$tenantId" -Certificate $Certificate

# Get app registration
Write-Host "*** Getting UI app registration..."
$app = Get-MgApplication | Where-Object { $_.AppId -eq "$(b2cUIClientID)" } # look for app by AppId, which is different from the -ApplicationId parameter
$app

if ("${{ parameters.type }}" -eq "add") {
  # Check if redirect URI already exists
  if ($app.Spa.RedirectUris.Contains("$frontDoorFqdn")) {
    Write-Host "*** Redirect URI $frontDoorFqdn already present, skipping."
  }
  else {
    # Add FQDN to the registration
    $app.Spa.RedirectUris += "$frontDoorFqdn"
    Write-Host "*** Updated list of URIs:"
    $app.Spa.RedirectUris

    # Update app registration
    Write-Host "*** Updating UI app with additional URI $frontDoorFqdn..."
    Update-MgApplication -ApplicationId $app.Id -Spa $app.Spa
  }
}
elseif ("${{ parameters.type }}" -eq "remove") {
  if ($app.Spa.RedirectUris.Contains("$frontDoorFqdn")) {
    Write-Host "*** Removing $frontDoorFqdn from UI application's Redirect URIs..."
    $newRedirect = $app.Spa.RedirectUris | Where-Object { $_ -ne "$frontDoorFqdn" }
    $app.Spa.RedirectUris = $newRedirect
    Update-MgApplication -ApplicationId $app.Id -Spa $app.Spa
  }
  else {
    Write-Host "*** Redirect URI for $frontDoorFqdn not present."
  }
}
else {
  throw "*** Invalid type of action: ${{ parameters.type }}"
}

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

📧 codez@deedx.cz