diff --git a/src/NuGetGallery.Core/Services/CloudBlobClientWrapper.cs b/src/NuGetGallery.Core/Services/CloudBlobClientWrapper.cs
index 7083a039e9..0b2455e8d9 100644
--- a/src/NuGetGallery.Core/Services/CloudBlobClientWrapper.cs
+++ b/src/NuGetGallery.Core/Services/CloudBlobClientWrapper.cs
@@ -2,6 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
+using System.Linq;
+using System.Security.Cryptography.X509Certificates;
using Azure;
using Azure.Core;
using Azure.Identity;
@@ -57,6 +59,19 @@ public static CloudBlobClientWrapper UsingMsi(
return new CloudBlobClientWrapper(storageConnectionString, tokenCredential, readAccessGeoRedundant, requestTimeout);
}
+ public static CloudBlobClientWrapper UsingServicePrincipal(
+ string storageConnectionString,
+ string appID,
+ string subjectAlternativeName,
+ string tenantId,
+ string authorityHost,
+ bool readAccessGeoRedundant = false,
+ TimeSpan? requestTimeout = null)
+ {
+ var tokenCredential = GetCredentialUsingServicePrincipal(appID, subjectAlternativeName, tenantId, authorityHost);
+ return new CloudBlobClientWrapper(storageConnectionString, tokenCredential, readAccessGeoRedundant, requestTimeout);
+ }
+
public ISimpleCloudBlob GetBlobFromUri(Uri uri)
{
// For Azure blobs, the query string is assumed to be the SAS token.
@@ -217,5 +232,36 @@ private string GetSecondaryConnectionString()
.Replace($"AccountName={primaryAccountName};", $"AccountName={secondaryAccountName};");
return secondaryConnectionString;
}
+
+ ///
+ /// Gets credential using the Service Principal. If the resource is in a different tenant, this is how to access it.
+ /// The ServicePrincipal needs to be a "Storage Table/Blob/Queue Data Contributor" role on the storage account. Owner isn't enough.
+ ///
+ /// ClientCertificatCredential to be used to communicate with Storage.
+ private static ClientCertificateCredential GetCredentialUsingServicePrincipal(string appID, string subjectAlternativeName, string tenantId, string authorityHost)
+ {
+ X509Certificate2 clientCert;
+
+ // Azure.Identity library doesn't support referencing cert by Store + Subject name, so we need to load it ourselves.
+ using (X509Store store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
+ {
+ store.Open(OpenFlags.ReadOnly);
+
+ X509Certificate2Collection certs = store.Certificates.Find(X509FindType.FindBySubjectName, subjectAlternativeName, true);
+
+ if (certs.Count == 0)
+ {
+ throw new InvalidOperationException($"Unable to find certificate with subject name '{subjectAlternativeName}'");
+ }
+
+ // As an exception to comment in GetKeyVaultCertsAsync method, this X509Certificate2 object does not have to be disposed
+ // because it is referencing a platform certificate from CurrentUser certificate store, so no temporary files are created for this object.
+ clientCert = certs.Cast()
+ .Where(c => c.NotBefore < DateTime.UtcNow && c.NotAfter > DateTime.UtcNow)
+ .OrderBy(x => x.NotAfter).Last();
+ }
+
+ return new ClientCertificateCredential(tenantId, appID, clientCert, new ClientCertificateCredentialOptions { AuthorityHost = new Uri(authorityHost), SendCertificateChain = true });
+ }
}
}
\ No newline at end of file