Skip to content

Instantly share code, notes, and snippets.

@GuyPaddock
Last active February 1, 2024 22:06
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save GuyPaddock/bc72ea62a3f5dd1c77aafdc72cd3c8d4 to your computer and use it in GitHub Desktop.
Save GuyPaddock/bc72ea62a3f5dd1c77aafdc72cd3c8d4 to your computer and use it in GitHub Desktop.
PowerShell Script to Renew the SSL Certificate Used by the Application Proxy for an Azure AD Enterprise Application
################################################################################
# Script that renews a Let's Encrypt certificate for the Application Proxy of an
# Enterprise Application.
################################################################################
# This script can be run standalone from your local machine to automate the
# steps of a specific ACME renewal for Azure AD App Proxy application.
#
# Prior to using this script, you will need to ensure you have a DNS zone setup
# that points to your Azure AD App Proxy deployment. Azure AD will give you a
# hostname that will need to be referenced by a CNAME record in the zone. So,
# for example, if you are exposing one web app at "www.example.com" and another
# at "dev.example.com" (apex domains are not supported), Azure AD App Proxy
# will give you hostnames like "www-example.msappproxy.net" and
# "dev-example.msappproxy.net", respectively, that you need to point the "www"
# and "dev" subdomains of the "example.com" DNS zone to. And, in addition,
# "example.com" must be using the DNS servers that Azure provides for the zone.
# This only works for DNS that is publicly accessible (i.e. accessible over the
# public internet).
#
# This PowerShell script automatically takes care of requesting ACME
# challenges, adding TXT records for the challenge to the target DNS zone, and
# updating what cert the App Proxy is using upon a successful verification.
#
# Example usage:
# .\RenewAzureAdProxyCert.ps1 `
# -AppRegistrationName "Name of My Site" `
# -Domain "dev.example.com" `
# -ZoneName "example.com" `
# -ZoneResourceGroup "name-of-resource-group-containing-dns-zone" `
# -AcmeServiceName LetsEncrypt
#
# The script was originally intended for use in an Azure Automation Runbook to
# automate renewals on a schedule, but as of 2022-03-17, this DOES NOT WORK due
# to an apparent bug in Azure AD graph permissions:
# https://docs.microsoft.com/en-us/answers/questions/63429/getapplicationproxyapplication-notadminrolenoenoug.html
#
# A support request (121011824006205) was filed with Microsoft, but they
# indicated that this is a documented limitation. See:
# https://docs.microsoft.com/en-us/azure/active-directory/app-proxy/application-proxy-faq#can-a-service-principal-manage-application-proxy-using-powershell-or-microsoft-graph-apis-
#
# Be sure to uncomment the appropriate sections of this script (below) based on
# what your use case is.
#
# Requires the following modules available locally/in the automation account:
# - ACME-PS
# - Az.Accounts
# - Az.Dns
# - Az.Resources
# - AzureAd
#
# Based on:
# - https://github.com/PKISharp/ACME-PS/blob/master/samples/AzureRunbookExample.ps1
# - https://github.com/intelequia/letsencrypt-aw/blob/master/letsencryptaw_v2.ps1
# - https://blog.yevrag35.com/2018/09/automate-appproxy-and-lets-encrypt.html
#
[OutputType([string])]
param(
[Parameter(Mandatory)]
[String] $AppRegistrationName,
[Parameter(Mandatory)]
[String] $Domain,
[Parameter(Mandatory)]
[String] $ZoneName,
[Parameter(Mandatory)]
[String] $ZoneResourceGroup,
[Parameter(Mandatory)]
[String] $RegistrationEmail,
[ValidateSet('LetsEncrypt', 'LetsEncrypt-Staging', IgnoreCase = $false)]
[String] $AcmeServiceName = "LetsEncrypt-Staging"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$PSDefaultParameterValues['*:ErrorAction']='Stop'
Import-Module 'ACME-PS';
# Your email addresses, where acme services will send informations.
$contactMailAddresses = @($RegistrationEmail);
# This directory is used to store your account key and service directory urls as well as orders and related data
$acmeStateDir = "C:\Temp\AcmeState";
# This directory contains the certificates.
$certExportDir = "C:\Temp\certificates";
# This path will be used to export your certificate file.
$certExportPath = $certExportDir + "\certificate.pfx";
"*** STARTING with Service Name: $AcmeServiceName"
# Option 1: UNCOMMENT this when running LOCALLY as a standalone script.
<#
If ((Get-AzContext -ErrorAction SilentlyContinue) -eq $null) {
"*** Authenticating with Azure Account Management API (for DNS updates)"
Login-AzAccount
}
Else {
"*** Re-using loaded Azure Account Management credentials"
}
$azureAdAccessTokens = `
[Microsoft.Open.Azure.AD.CommonLibrary.AzureSession]::AccessTokens;
If (($azureAdAccessTokens -eq $null) -Or ($azureAdAccessTokens.Count -eq 0)) {
"*** Authenticating with Azure AD Management API (for app proxy updates)"
Connect-AzureAD
}
Else {
"*** Re-using loaded Azure AD Management credentials"
}
#>
# Option 2: UNCOMMENT this section when using this in an Automation RUNBOOK
# BUGBUG: This DOES NOT WORK. See notes at the top of this script.
<#
$connection = Get-AutomationConnection -Name AzureRunAsConnection
# Log-in to Az Account Management Graph API (for DNS updates)
Login-AzAccount -ServicePrincipal `
-Tenant $connection.TenantID `
-ApplicationId $connection.ApplicationID `
-CertificateThumbprint $connection.CertificateThumbprint
# Log-in to Azure AD Management Graph API (for app proxy updates)
Connect-AzureAD `
-TenantId $connection.TenantID `
-ApplicationId $connection.ApplicationID `
-CertificateThumbprint $connection.CertificateThumbprint
#>
try
{
###
### 1. Create an new account
### https://github.com/PKISharp/ACMESharpCore-PowerShell/blob/master/samples/CreateAccount.ps1
###
If (Test-Path $acmeStateDir -PathType Container) {
"*** Re-using existing ACME account..."
# Load URLs from service directory
Get-ACMEServiceDirectory -State $acmeStateDir -ServiceName $AcmeServiceName;
# Retrieve the first anti-replay nonce
New-ACMENonce -State $acmeStateDir;
}
Else {
"*** Creating ACME account..."
# Create the state object - will be saved to disk
New-ACMEState -Path $acmeStateDir;
# Load URLs from service directory
Get-ACMEServiceDirectory -State $acmeStateDir -ServiceName $AcmeServiceName;
# Retrieve the first anti-replay nonce
New-ACMENonce -State $acmeStateDir;
# Create an account key and store it to the state
New-ACMEAccountKey -State $acmeStateDir -Force;
# Register account key with acme service
New-ACMEAccount -State $acmeStateDir -EmailAddresses $contactMailAddresses -AcceptTOS;
}
###
### 2. Create a new order
### https://github.com/PKISharp/ACMESharpCore-PowerShell/blob/master/samples/CreateOrderS.ps1
###
"*** Creating certificate order..."
# This dns names will be used as identifier
$dnsIdentifiers = New-ACMEIdentifier $Domain;
# Create a new order
$order = New-ACMEOrder -State $acmeStateDir -Identifiers $dnsIdentifiers;
Write-Host ($order | Format-List | Out-String)
###
### 3. Fullfill challenge
### https://github.com/PKISharp/ACMESharpCore-PowerShell/blob/master/samples/CreateOrderS.ps1
###
"*** Updating DNS to fullfill challenge..."
# Fetch the authorizations for that order
$authorizations = @(Get-ACMEAuthorization -State $acmeStateDir -Order $order);
foreach ($authz in $authorizations) {
# Select a challenge to fullfill
$challenge = Get-ACMEChallenge -State $acmeStateDir -Authorization $authZ -Type "dns-01";
# Inspect the challenge data (uncomment, if you want to see the object)
# Depending on the challenge-type this will include different properties
"Challenge Data:"
$challenge.Data;
$challengeRecordName = $challenge.Data.TxtRecordName
$challengeNonce = $challenge.Data.Content
$challengeHostName = $challengeRecordName.Replace("." + $ZoneName, "")
# Remove any existing TXT record that conflicts with the challenge
$oldChallengeRecordSet = `
Get-AzDnsRecordSet `
-Name $challengeHostName `
-RecordType TXT `
-ResourceGroupName $ZoneResourceGroup `
-ZoneName $ZoneName `
-ea SilentlyContinue
if ($oldChallengeRecordSet -ne $null) {
"Removing existing challenge record set $challengeRecordName"
Remove-AzDnsRecordSet -RecordSet $oldChallengeRecordSet
}
# Create the TXT record requested by the challenge
$challengeRecords = @()
$challengeRecords += New-AzDnsRecordConfig -Value $challengeNonce
New-AzDnsRecordSet `
-Name $challengeHostName `
-RecordType TXT `
-ResourceGroupName $ZoneResourceGroup `
-TTL 300 `
-ZoneName $ZoneName `
-DnsRecords $challengeRecords
"*** Waiting 10 seconds for DNS propagation..."
Start-Sleep -Seconds 10;
# Signal the ACME server that the challenge is ready
$challenge | Complete-ACMEChallenge -State $acmeStateDir;
}
###
### 4. Issue certificate
### https://github.com/PKISharp/ACMESharpCore-PowerShell/blob/master/samples/IssueCertificateA.ps1
###
"*** Requesting certificate..."
# Wait a little bit and update the order, until we see the status 'ready' or 'invalid'
while ($order.Status -notin ("ready", "invalid")) {
Start-Sleep -Seconds 5;
$order | Update-ACMEOrder -State $acmeStateDir -PassThru;
}
if ($order.Status -eq "invalid") {
throw "Your order has been marked as invalid - certificate cannot be issued."
}
# Complete the order - this will issue a certificate signing request
Complete-ACMEOrder -State $acmeStateDir -Order $order -GenerateCertificateKey;
# Now we wait until the ACME service provides the certificate url
while (-not $order.CertificateUrl) {
Start-Sleep -Seconds 15
$order | Update-ACMEOrder -State $acmeStateDir -PassThru
}
# Generate a random, safe password for the PFX file.
$randomPassword = [System.Web.Security.Membership]::GeneratePassword(21, 5)
$securePassword = ConvertTo-SecureString $randomPassword -AsPlainText -Force
"Exporting certificate..."
$certificatePath = [System.IO.Path]::GetDirectoryName($certExportPath)
New-Item $certificatePath -ItemType Directory -ea Continue
# As soon as the url shows up we can create the PFX
Export-ACMECertificate -State $acmeStateDir `
-Order $order `
-Path $certExportPath `
-Password $securePassword
"🚀 Wohoo! Applying new SSL Binding to MS App Proxy..."
# Get 'real' ObjectId of website
$webAppId = (Get-AzADApplication -DisplayName $AppRegistrationName -ea Stop).ObjectId
# Now import pfx to Azure for WebApp to use.
Set-AzureADApplicationProxyApplicationCustomDomainCertificate `
-ObjectId $webAppId `
-PfxFilePath $certExportPath `
-Password $securePassword
"Cleaning up..."
Remove-Item $certExportDir -Recurse
}
catch {
Write-Error -Message $_.Exception
throw $_.Exception
}
@mateuszdrab
Copy link

mateuszdrab commented May 4, 2022

One of the comments in https://docs.microsoft.com/en-us/answers/questions/63429/getapplicationproxyapplication-notadminrolenoenoug.html was mine.

Can't believe this still hasn't been fixed 😣

Long time ago, I wrote my own script do update the application proxy, tested it all and then pushed into a pipeline with a service principal and got pretty damn annoyed when I encountered this limitation. I just installed a 2 year certificate then and forgot about it.

Unfortunately, it still exists, and renewal time is coming up. I wanted to automate this, now cert-manager handles my wildcard certificate and it would be easy to setup a cronjob - but not if the SP bug is still there.

@Joly0
Copy link

Joly0 commented Oct 16, 2023

Hey @GuyPaddock Thanks for the Script, though i am getting this error:
`Unable to find type [Microsoft.Open.Azure.AD.CommonLibrary.AzureSession].
At line:1 char:1

  • [Microsoft.Open.Azure.AD.CommonLibrary.AzureSession]
  •   + CategoryInfo          : InvalidOperation: (Microsoft.Open....ry.AzureSession:TypeName) [], RuntimeException
      + FullyQualifiedErrorId : TypeNotFound`
    
    

Any idea why this is happening?

@Joly0
Copy link

Joly0 commented Oct 19, 2023

And another thing i have noticed, is when using "-AcmeServiceName LetsEncrypt" i get this error:

.\RenewAzureAdProxyCert.ps1 : AcmeHttpException: Server returned Problem (Status: 400).
Type: urn:ietf:params:acme:error:malformed
KeyID header contained an invalid account URL: "https://acme-staging-v02.api.letsencrypt.org/acme/acct/198752319"
At line:1 char:1
+ .\RenewAzureAdProxyCert.ps1 -AppRegistrationName test.mytestsite. ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
    + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,RenewAzureAdProxyCert.ps1

This doesnt happen when using LetsEncrypt-Staging or leaving the parameter out completely

@msundman78
Copy link

msundman78 commented Nov 8, 2023

Thanks for the script! Updating the AppProxy certificate finally seems to be supported using Graph Beta API. Using an Azure Automation PS 5.1 Runbook with System-assigned managed identity I was now able to get it to work.

Assign your automation account with Directory.ReadWrite.All permissions:
(Application.ReadWrite.All and OnPremisesPublishingProfiles.ReadWrite.All does not work)

spObjectId=$(az ad sp list --display-name "<Automation Account Name>" --query "[].id" -o tsv)
spGraphId=$(az ad sp list --filter "AppId eq '00000003-0000-0000-c000-000000000000'" --query "[].id" -o tsv)
appRoleId="19dbc75e-c2e2-444c-a770-ec69d8559fc7"
az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/${spGraphId}/appRoleAssignedTo" \
  --body "{\"principalId\": \"${spObjectId}\", \"appRoleId\": \"${appRoleId}\", \"resourceId\": \"${spGraphId}\"}"

Add the following two PS modules to your Automation Account:

  • Microsoft.Graph.Authentication
  • Microsoft.Graph.Beta.Applications

Update the script to login using a managed identity:

Connect-AzAccount -Identity
Connect-MgGraph -Identity 

Update the script to use MgGraph to update the AppProxy certificate:

$webAppId = (Get-MgApplication -Filter "displayName eq '$AppRegistrationName'" -ea Stop).Id
$params = @{
    onPremisesPublishing = @{
        verifiedCustomDomainKeyCredential = @{
            type="X509CertAndPassword";
            key = [convert]::ToBase64String((Get-Content $certExportPath -Encoding Byte));
        };
        verifiedCustomDomainPasswordCredential = @{ value = $randomPassword };
    }
}
Update-MgBetaApplication -ApplicationId $webAppId -BodyParameter $params

Sidenote: ACME-PS 1.5.6 has a bug in the Export-ACMECertificate function. Fixed now in 1.5.7 release yesterday.

Big thanks to "timja" who pointed me in the right direction here:
microsoftgraph/msgraph-sdk-powershell#2076

@GuyPaddock
Copy link
Author

Thank you, @msundman78!

@rdbahm
Copy link

rdbahm commented Feb 1, 2024

@msundman78 or @GuyPaddock I spent quite a while beating my head against the wall, but I think I've had a breakthrough that allows this to work without Directory.ReadWrite.All, but I'd love if someone else confirms that it works.

Try removing the Directory.ReadWrite.All permission and adding Application.ReadWrite.OwnedBy. Before you say it, yes, Application.ReadWrite.All should be a superset of these permissions - but as far as I can tell, it does behave differently (and more permissively) than the "All" permission in this instance. I found that I didn't even have to set the Managed Identity as an owner of the Application in question - simply having Application.ReadWrite.OwnedBy was enough to allow the certificate to be updated, though again, I'm hoping to verify it.

If it helps, my Managed Identity currently has:
Directory.Read.All
OnPremisesPublishing.ReadWrite.All
Application.ReadWrite.OwnedBy
The Directory and OPP rights are just there to enumerate the apps and publishing information, and I haven't diagnosed if they're necessary yet. One problem at a time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment