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