Search code examples
azureazure-web-app-servicessl-certificateazure-sdk-.netazure-webapps

Problems installing a TLS certificate on an Azure App Service using the latest SDK


(Note: either a VB.NET or a C# answer will be fine.)

I'm having trouble installing an uploaded certificate using the new SDK.

I'm using the REST API for the upload, as there doesn't appear to be a way to do that part with the new SDK. Apparently uploading was possible using the older, soon-to-be-deprecated version, as indicated here, but time marches cruelly onward and we must keep up.

The problem is that I'm getting a 404 when I push the update with the new site data:

Status: 404 (Not Found) - Certificate 370A4097E85476AE65545702B410866882AD4541 was not found.

That thumbprint matches the pfx cert's thumbprint, so we know that that part's working.

I'm getting a 202 for the upload, so that part is working as well. But I would have expected the cert to show up in the portal, which it doesn't. So that's a bit odd.

My full code is below, but for brevity here are the relevant parts:

Upload

oApiCert = New Certificate With {.Location = oWebSiteData.Location.DisplayName}
oApiCert.Properties.CanonicalName = sCanonicalName
oApiCert.Properties.ServerFarmId = oWebSiteData.AppServicePlanId.ToString
oApiCert.Properties.Password = My.Resources.PfxPassword
oApiCert.Properties.PfxBlob = oX509Cert.Export(X509ContentType.Pfx, My.Resources.PfxPassword)
oApiCert.Properties.HostNames.AddRange(oX509Cert.SanHostNames)

sJsonContent = JsonConvert.SerializeObject(oApiCert, JsonHelper.DefaultSerializationSettings)
oContent = New StringContent(sJsonContent, Encoding.UTF8, "application/json")

oClient = New HttpClient
oClient.DefaultRequestHeaders.Add("Authorization", "Bearer " & oToken.Token)
oClient.BaseAddress = New Uri(My.Resources.ManagementEndpoint)

oResponse = Await oClient.PutAsync(sUrl, oContent)
oResponse.EnsureSuccessStatusCode()

Update

oSslStates = oWebSiteData.HostNameSslStates

oHostNames = oHostNames.Intersect(oWebSiteData.HostNames).ToList
oHostNames.ForEach(Sub(HostName)
                     oSslState = oSslStates.FirstOrDefault(Function(SslState) SslState.Name = HostName)

                     If oSslState Is Nothing Then
                       oSslState = New HostNameSslState With {.Name = HostName}
                       oSslStates.Add(oSslState)
                     End If

                     oSslState.Thumbprint = BinaryData.FromObjectAsJson(oX509Cert.Thumbprint)
                     oSslState.SslState = HostNameBindingSslState.SniEnabled
                     oSslState.ToUpdate = True
                   End Sub)

oSitePatch = New SitePatchInfo

For Each oState In oSslStates
  If oState.ToUpdate Then
    oSitePatch.HostNameSslStates.Add(oState)
  End If
Next

oWebSiteResource.Update(oSitePatch) ' <-- 404 here '

This code is ported from the LetsEncrypt-SiteExtension package, found here. That library uses the old SDK.

I'm not necessarily stuck on doing it exactly this way, if there's another way that works. The trouble is that documentation and code samples for the SDK are woefully sparse.

How can I upload and install a third-party TLS certificate on an Azure App Service, using the new SDK? Has anyone found success with this?

Full Code

Imports System
Imports System.Collections.Generic
Imports System.Linq
Imports System.Net.Http
Imports System.Security.Cryptography.X509Certificates
Imports System.Text
Imports System.Threading.Tasks
Imports Azure
Imports Azure.Core
Imports Azure.Identity
Imports Azure.ResourceManager
Imports Azure.ResourceManager.AppService
Imports Azure.ResourceManager.AppService.Models
Imports Azure.ResourceManager.Resources
Imports Matrix.Common
Imports Newtonsoft.Json
Imports Nito.AsyncEx

Friend Module Program
  Friend Sub Main()
    AsyncContext.Run(Async Function()
                       Await UploadCert()
                     End Function)
  End Sub



  Private Async Function UploadCert() As Task
    Dim oWebSiteResponse As Response(Of WebSiteResource)
    Dim oWebSiteResource As WebSiteResource
    Dim oResourceGroup As ResourceGroupResource
    Dim sCanonicalName As String
    Dim oSubscription As SubscriptionResource
    Dim oWebSiteData As WebSiteData
    Dim sJsonContent As String
    Dim oCredential As ClientSecretCredential
    Dim oSslStates As IList(Of HostNameSslState)
    Dim oHostNames As List(Of String)
    Dim aHostNames As String()
    Dim oSitePatch As SitePatchInfo
    Dim oArmClient As ArmClient
    Dim oResponse As HttpResponseMessage
    Dim oX509Cert As X509Certificate2
    Dim oSslState As HostNameSslState
    Dim aScopeUrl As String()
    Dim sCertName As String
    Dim oContext As TokenRequestContext
    Dim oContent As StringContent
    Dim oApiCert As Certificate
    Dim oClient As HttpClient
    Dim oToken As AccessToken
    Dim oUrl As List(Of String)
    Dim sUrl As String

    oX509Cert = Utils.GetCertificate(My.Resources.CertFriendlyName)
    aHostNames = My.Resources.HostNames.Split(",", StringSplitOptions.RemoveEmptyEntries)
    oHostNames = New List(Of String)(aHostNames)
    sCanonicalName = oHostNames.First
    sCertName = $"{sCanonicalName}-{oX509Cert.Thumbprint}"

    oUrl = New List(Of String) From {
      "subscriptions",
      My.Resources.SubscriptionId,
      "resourceGroups",
      My.Resources.ResourceGroup,
      "providers",
      "Microsoft.Web",
      "certificates",
      sCertName
    }

    sUrl = String.Join("/", oUrl)
    sUrl = $"/{sUrl}?api-version=2022-03-01"

    oCredential = New ClientSecretCredential(My.Resources.TenantId, My.Resources.ClientId, My.Resources.ClientSecret)
    oArmClient = New ArmClient(oCredential)
    oSubscription = Await oArmClient.GetDefaultSubscriptionAsync
    oResourceGroup = Await oSubscription.GetResourceGroups.GetAsync(My.Resources.ResourceGroup)
    oWebSiteResponse = Await oResourceGroup.GetWebSiteAsync(My.Resources.AppServiceName)
    oWebSiteResource = oWebSiteResponse.Value
    oWebSiteData = oWebSiteResource.Data
    aScopeUrl = New String() {$"{My.Resources.ManagementEndpoint}.default"}
    oContext = New TokenRequestContext(aScopeUrl)
    oToken = Await oCredential.GetTokenAsync(oContext)

    oApiCert = New Certificate With {.Location = oWebSiteData.Location.DisplayName}
    oApiCert.Properties.CanonicalName = sCanonicalName
    oApiCert.Properties.ServerFarmId = oWebSiteData.AppServicePlanId.ToString
    oApiCert.Properties.Password = My.Resources.PfxPassword
    oApiCert.Properties.PfxBlob = oX509Cert.Export(X509ContentType.Pfx, My.Resources.PfxPassword)
    oApiCert.Properties.HostNames.AddRange(oX509Cert.SanHostNames)

    sJsonContent = JsonConvert.SerializeObject(oApiCert, JsonHelper.DefaultSerializationSettings)
    oContent = New StringContent(sJsonContent, Encoding.UTF8, "application/json")

    oClient = New HttpClient
    oClient.DefaultRequestHeaders.Add("Authorization", "Bearer " & oToken.Token)
    oClient.BaseAddress = New Uri(My.Resources.ManagementEndpoint)

    oResponse = Await oClient.PutAsync(sUrl, oContent)
    oResponse.EnsureSuccessStatusCode()

    oSslStates = oWebSiteData.HostNameSslStates

    oHostNames = oHostNames.Intersect(oWebSiteData.HostNames).ToList
    oHostNames.ForEach(Sub(HostName)
                         oSslState = oSslStates.FirstOrDefault(Function(SslState) SslState.Name = HostName)

                         If oSslState Is Nothing Then
                           oSslState = New HostNameSslState With {.Name = HostName}
                           oSslStates.Add(oSslState)
                         End If

                         oSslState.Thumbprint = BinaryData.FromObjectAsJson(oX509Cert.Thumbprint)
                         oSslState.SslState = HostNameBindingSslState.SniEnabled
                         oSslState.ToUpdate = True
                       End Sub)

    oSitePatch = New SitePatchInfo

    For Each oState In oSslStates
      If oState.ToUpdate Then
        oSitePatch.HostNameSslStates.Add(oState)
      End If
    Next

    oWebSiteResource.Update(oSitePatch) ' <-- 404 here '
  End Function
End Module

Solution

  • Goodness... that sure was an adventure!

    OK, after much pain I've hit on the answer. (Side note: it'll be nice to know the SDK equivalent to that Create/Update Certificate REST API, so I'm still looking...)

    The trick (workaround?) is to upload the cert to a Key Vault first, and then from there deploy it to our App Service.

    Once the cert is in the vault, all we have to do next is provide four values in the (case-insensitive) JSON body of the PUT call:

    1. .Location e.g. East US
    2. .Properties.KeyVaultSecretName the name of the cert in the vault
    3. .Properties.ServerFarmId i.e. AppService.AppServicePlanId.ToString
    4. .Properties.KeyVaultId i.e. KeyVault.Value.Id

    And that's it. No byte array, no password, nothing more than just this.

    Except for one other thing... we have to grant the appropriate Access Policies on the vault:

    • Our Service Principal = CERTIFICATE IMPORT
    • Microsoft Azure App Service principal = SECRET GET

    The first is for uploading the cert to the vault and the second is for deployment.

    The resource identifier for the Microsoft Azure App Service principal is abfa0a7c-a6b6-4736-8310-5855508787cd, except for the Azure Gov cloud environment, where it's 6a02c803-dafd-4136-b4c3-5a6f318b4714.

    Like so:

    enter image description here

    Upload it to the vault like this:

    Dim oCertClient As CertificateClient
    Dim oVaultUri As Uri
    Dim oOptions As ImportCertificateOptions
    Dim aPfxCert As Byte()
    
    oVaultUri = New Uri($"https://{My.Resources.VaultName}.vault.azure.net")
    oCertClient = New CertificateClient(oVaultUri, Credential)
    
    aPfxCert = X509Cert.Export(X509ContentType.Pfx, My.Resources.PfxPassword)
    oOptions = New ImportCertificateOptions(CertName, aPfxCert) With {.Password = My.Resources.PfxPassword}
    
    Await oCertClient.ImportCertificateAsync(oOptions)
    

    Now we can deploy it to our App Service:

    oApiCert = New Upload.Certificate With {.Location = WebSiteResource.Data.Location.DisplayName}
    oApiCert.Properties.KeyVaultSecretName = CertName
    oApiCert.Properties.ServerFarmId = WebSiteResource.Data.AppServicePlanId.ToString
    oApiCert.Properties.KeyVaultId = oKeyVaultResponse.Value.Id.ToString
    
    aScopeUrls = New String() {$"{My.Resources.ManagementEndpoint}.default"}
    oContext = New TokenRequestContext(aScopeUrls)
    oToken = Await Credential.GetTokenAsync(oContext)
    
    oHttpClient = New HttpClient
    oHttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {oToken.Token}")
    oHttpClient.BaseAddress = New Uri(My.Resources.ManagementEndpoint)
    
      ...
    
    sJsonContent = JsonConvert.SerializeObject(oApiCert, JsonHelper.DefaultSerializationSettings)
    oContent = New StringContent(sJsonContent, Encoding.UTF8, "application/json")
    
    oHttpResponse = Await oHttpClient.PutAsync(sUrl, oContent)
    oHttpResponse.EnsureSuccessStatusCode()
    

    I discovered all of this when I stumbled on this seven-year-old blog post (three cheers for preserving valuable content over the years). Also, here's a QuickStart Template for the concept.

    One thing that's worth noting about the post: it claims that an unprotected PFX is necessary for this to work. In my testing, however, I've encountered the opposite: for me at least, it works well with a password-protected PFX. I'm not going to put any time into trying to figure out that discrepancy; I'm just going to go with it and keep my PFXs protected. So be it. YMMV.

    In case it might help someone, here's the full working code:

    Imports System
    Imports System.Collections.Generic
    Imports System.Linq
    Imports System.Net.Http
    Imports System.Security.Cryptography.X509Certificates
    Imports System.Text
    Imports System.Threading.Tasks
    Imports Azure
    Imports Azure.Core
    Imports Azure.Identity
    Imports Azure.ResourceManager
    Imports Azure.ResourceManager.AppService
    Imports Azure.ResourceManager.AppService.Models
    Imports Azure.ResourceManager.KeyVault
    Imports Azure.ResourceManager.Resources
    Imports Azure.Security.KeyVault.Certificates
    Imports Matrix.Common
    Imports Newtonsoft.Json
    Imports Nito.AsyncEx
    
    Friend Module Program
      Friend Sub Main()
        AsyncContext.Run(Async Function()
                           Await InstallNewCert()
                         End Function)
      End Sub
    
      Private Async Function InstallNewCert() As Task
        Dim oWebSiteResponse As Response(Of WebSiteResource)
        Dim oResourceGroup As ResourceGroupResource
        Dim oSubscription As SubscriptionResource
        Dim oCredential As ClientSecretCredential
        Dim oArmClient As ArmClient
        Dim oX509Cert As X509Certificate2
        Dim sCertName As String
    
        oCredential = New ClientSecretCredential(My.Resources.TenantId, My.Resources.ClientId, My.Resources.ClientSecret)
        oX509Cert = Utils.GetCertificate(My.Resources.CertFriendlyName)
    
        sCertName = oX509Cert.SanHostNames.Where(Function(HostName)
                                                   Return _
                                                     Not HostName.Contains("*"c) AndAlso
                                                     HostName.Occurs("."c) = 1
                                                 End Function).First
    
        sCertName = sCertName.Split("."c).First
        sCertName = $"{sCertName}-{Guid.NewGuid}"
    
        oArmClient = New ArmClient(oCredential)
        oSubscription = Await oArmClient.GetDefaultSubscriptionAsync
        oResourceGroup = Await oSubscription.GetResourceGroups.GetAsync(My.Resources.SecondaryResourceGroup)
        oWebSiteResponse = Await oResourceGroup.GetWebSiteAsync(My.Resources.AppServiceName)
    
        Await UploadCertToVault(oCredential, oX509Cert, sCertName)
        Await DeployCertFromVault(oSubscription, oCredential, oWebSiteResponse.Value, sCertName)
        Await UpdateBindings(oWebSiteResponse.Value, oX509Cert)
      End Function
    
      Private Async Function UploadCertToVault(
          Credential As ClientSecretCredential,
          X509Cert As X509Certificate2,
          CertName As String) As Task
    
        Dim oCertClient As CertificateClient
        Dim oVaultUri As Uri
        Dim oOptions As ImportCertificateOptions
        Dim aPfxCert As Byte()
    
        oVaultUri = New Uri($"https://{My.Resources.VaultName}.vault.azure.net")
        oCertClient = New CertificateClient(oVaultUri, Credential)
    
        aPfxCert = X509Cert.Export(X509ContentType.Pfx, My.Resources.PfxPassword)
        oOptions = New ImportCertificateOptions(CertName, aPfxCert) With {.Password = My.Resources.PfxPassword}
    
        Await oCertClient.ImportCertificateAsync(oOptions)
      End Function
    
      Private Async Function DeployCertFromVault(
          Subscription As SubscriptionResource,
          Credential As ClientSecretCredential,
          WebSiteResource As WebSiteResource,
          CertName As String) As Task
    
        Dim oKeyVaultResponse As Response(Of KeyVaultResource)
        Dim oResourceGroup As ResourceGroupResource
        Dim oHttpResponse As HttpResponseMessage
        Dim sJsonContent As String
        Dim oHttpClient As HttpClient
        Dim aScopeUrls As String()
        Dim oContext As TokenRequestContext
        Dim oApiCert As Upload.Certificate
        Dim oContent As StringContent
        Dim oToken As AccessToken
        Dim oUrl As List(Of String)
        Dim sUrl As String
    
        oResourceGroup = Await Subscription.GetResourceGroups.GetAsync(My.Resources.PrimaryResourceGroup)
        oKeyVaultResponse = Await oResourceGroup.GetKeyVaultAsync(My.Resources.VaultName)
    
        oApiCert = New Upload.Certificate With {.Location = WebSiteResource.Data.Location.DisplayName}
        oApiCert.Properties.KeyVaultSecretName = CertName
        oApiCert.Properties.ServerFarmId = WebSiteResource.Data.AppServicePlanId.ToString
        oApiCert.Properties.KeyVaultId = oKeyVaultResponse.Value.Id.ToString
    
        aScopeUrls = New String() {$"{My.Resources.ManagementEndpoint}.default"}
        oContext = New TokenRequestContext(aScopeUrls)
        oToken = Await Credential.GetTokenAsync(oContext)
    
        oHttpClient = New HttpClient
        oHttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {oToken.Token}")
        oHttpClient.BaseAddress = New Uri(My.Resources.ManagementEndpoint)
    
        oUrl = New List(Of String) From {
          "subscriptions",
          My.Resources.SubscriptionId,
          "resourceGroups",
          My.Resources.PrimaryResourceGroup,
          "providers",
          "Microsoft.Web",
          "certificates",
          CertName
        }
    
        sUrl = String.Join("/", oUrl)
        sUrl = $"/{sUrl}?api-version=2022-03-01"
    
        sJsonContent = JsonConvert.SerializeObject(oApiCert, JsonHelper.DefaultSerializationSettings)
        oContent = New StringContent(sJsonContent, Encoding.UTF8, "application/json")
    
        oHttpResponse = Await oHttpClient.PutAsync(sUrl, oContent)
        oHttpResponse.EnsureSuccessStatusCode()
      End Function
    
      Private Async Function UpdateBindings(
          WebSiteResource As WebSiteResource,
          X509Cert As X509Certificate2) As Task
    
        Dim oSslStates As IList(Of HostNameSslState)
        Dim oSitePatch As SitePatchInfo
        Dim oSslState As HostNameSslState
    
        oSslStates = WebSiteResource.Data.HostNameSslStates
        oSitePatch = New SitePatchInfo
    
        With X509Cert.SanHostNames.Intersect(WebSiteResource.Data.HostNames).ToList
          .ForEach(Sub(HostName)
                     oSslState = oSslStates.FirstOrDefault(Function(SslState) SslState.Name = HostName)
    
                     If oSslState Is Nothing Then
                       oSslState = New HostNameSslState With {.Name = HostName}
                       oSslStates.Add(oSslState)
                     End If
    
                     oSslState.Thumbprint = BinaryData.FromObjectAsJson(X509Cert.Thumbprint)
                     oSslState.SslState = HostNameBindingSslState.SniEnabled
                     oSslState.ToUpdate = True
                   End Sub)
        End With
    
        For Each oState In oSslStates
          If oState.ToUpdate Then
            oSitePatch.HostNameSslStates.Add(oState)
          End If
        Next
    
        Await WebSiteResource.UpdateAsync(oSitePatch)
      End Function
    End Module