(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
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:
.Location
e.g. East US.Properties.KeyVaultSecretName
the name of the cert in the vault.Properties.ServerFarmId
i.e. AppService.AppServicePlanId.ToString.Properties.KeyVaultId
i.e. KeyVault.Value.IdAnd 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:
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:
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