diff --git a/Directory.Build.props b/Directory.Build.props index f7aae31c8..462ff2d99 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -81,7 +81,7 @@ 8.15.0 4.82.0 - 11.0.0 + 11.1.0 3.3.0 4.7.2 4.6.0 diff --git a/src/Microsoft.Identity.Web.Certificate/CertificateDescription.cs b/src/Microsoft.Identity.Web.Certificate/CertificateDescription.cs index b0a380cb3..152147936 100644 --- a/src/Microsoft.Identity.Web.Certificate/CertificateDescription.cs +++ b/src/Microsoft.Identity.Web.Certificate/CertificateDescription.cs @@ -151,6 +151,29 @@ public static CertificateDescription FromStoreWithDistinguishedName( }; } + /// + /// Creates a certificate description from a certificate subject name (such as "MyCert") + /// and store location (Certificate Manager on Windows, for instance). + /// The provided subject name is matched as a substring against the Subject field of each + /// certificate in the store; the most recently issued matching certificate is selected. + /// + /// Certificate subject name (or substring of the subject) to search for in the store. + /// Store location where to find the certificate. + /// Store name where to find the certificate. + /// A certificate description. + public static CertificateDescription FromStoreWithSubjectName( + string certificateSubjectName, + StoreLocation certificateStoreLocation = StoreLocation.CurrentUser, + StoreName certificateStoreName = StoreName.My) + { + return new CertificateDescription + { + SourceType = CertificateSource.StoreWithSubjectName, + CertificateStorePath = $"{certificateStoreLocation}/{certificateStoreName}", + CertificateSubjectName = certificateSubjectName, + }; + } + /// /// Defines where and how to import the private key of an X.509 certificate. /// diff --git a/src/Microsoft.Identity.Web.Certificate/CertificateSource.cs b/src/Microsoft.Identity.Web.Certificate/CertificateSource.cs index 97827eeee..fad81b584 100644 --- a/src/Microsoft.Identity.Web.Certificate/CertificateSource.cs +++ b/src/Microsoft.Identity.Web.Certificate/CertificateSource.cs @@ -37,5 +37,11 @@ public enum CertificateSource /// From the certificate store, described by its distinguished name. /// StoreWithDistinguishedName = 5, + + /// + /// From the certificate store, described by its subject name. + /// The provided value is matched as a substring against the certificate's Subject field. + /// + StoreWithSubjectName = 13, } } diff --git a/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs b/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs index 428a5cb84..a6c677ba4 100644 --- a/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs +++ b/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs @@ -34,6 +34,7 @@ public DefaultCredentialsLoader(ILogger? logger) { CredentialSource.Path, new FromPathCertificateLoader() }, { CredentialSource.StoreWithThumbprint, new StoreWithThumbprintCertificateLoader() }, { CredentialSource.StoreWithDistinguishedName, new StoreWithDistinguishedNameCertificateLoader() }, + { CredentialSource.StoreWithSubjectName, new StoreWithSubjectNameCertificateLoader() }, { CredentialSource.Base64Encoded, new Base64EncodedCertificateLoader() }, { CredentialSource.SignedAssertionFromManagedIdentity, new SignedAssertionFromManagedIdentityCredentialLoader(_logger) }, { CredentialSource.SignedAssertionFilePath, new SignedAssertionFilePathCredentialsLoader(_logger) } diff --git a/src/Microsoft.Identity.Web.Certificate/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.Certificate/InternalAPI.Unshipped.txt index 7dc5c5811..54602deca 100644 --- a/src/Microsoft.Identity.Web.Certificate/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.Certificate/InternalAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +Microsoft.Identity.Web.StoreWithSubjectNameCertificateLoader +Microsoft.Identity.Web.StoreWithSubjectNameCertificateLoader.CredentialSource.get -> Microsoft.Identity.Abstractions.CredentialSource +Microsoft.Identity.Web.StoreWithSubjectNameCertificateLoader.LoadIfNeededAsync(Microsoft.Identity.Abstractions.CredentialDescription! credentialDescription, Microsoft.Identity.Abstractions.CredentialSourceLoaderParameters? _) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.StoreWithSubjectNameCertificateLoader.StoreWithSubjectNameCertificateLoader() -> void diff --git a/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt index 7dc5c5811..92c4cc043 100644 --- a/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.Identity.Web.CertificateSource.StoreWithSubjectName = 13 -> Microsoft.Identity.Web.CertificateSource +static Microsoft.Identity.Web.CertificateDescription.FromStoreWithSubjectName(string! certificateSubjectName, System.Security.Cryptography.X509Certificates.StoreLocation certificateStoreLocation = System.Security.Cryptography.X509Certificates.StoreLocation.CurrentUser, System.Security.Cryptography.X509Certificates.StoreName certificateStoreName = System.Security.Cryptography.X509Certificates.StoreName.My) -> Microsoft.Identity.Web.CertificateDescription! diff --git a/src/Microsoft.Identity.Web.Certificate/StoreWithSubjectNameCertificateLoader.cs b/src/Microsoft.Identity.Web.Certificate/StoreWithSubjectNameCertificateLoader.cs new file mode 100644 index 000000000..ee31a1b45 --- /dev/null +++ b/src/Microsoft.Identity.Web.Certificate/StoreWithSubjectNameCertificateLoader.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.Identity.Abstractions; + +namespace Microsoft.Identity.Web +{ + internal sealed class StoreWithSubjectNameCertificateLoader : ICredentialSourceLoader + { + public CredentialSource CredentialSource => CredentialSource.StoreWithSubjectName; + + public Task LoadIfNeededAsync(CredentialDescription credentialDescription, CredentialSourceLoaderParameters? _) + { + credentialDescription.Certificate = LoadFromStoreWithSubjectName( + credentialDescription.CertificateSubjectName!, + credentialDescription.CertificateStorePath!); + credentialDescription.CachedValue = credentialDescription.Certificate; + return Task.CompletedTask; + } + + private static X509Certificate2? LoadFromStoreWithSubjectName( + string certificateSubjectName, + string storeDescription = CertificateConstants.PersonalUserCertificateStorePath) + { + StoreLocation certificateStoreLocation = StoreLocation.CurrentUser; + StoreName certificateStoreName = StoreName.My; + CertificateLoaderHelper.ParseStoreLocationAndName( + storeDescription, + ref certificateStoreLocation, + ref certificateStoreName); + + X509Certificate2? cert; + using (X509Store x509Store = new X509Store( + certificateStoreName, + certificateStoreLocation)) + { + cert = CertificateLoaderHelper.FindCertificateByCriterium( + x509Store, + X509FindType.FindBySubjectName, + certificateSubjectName); + } + + return cert; + } + } +} diff --git a/tests/Microsoft.Identity.Web.Test/Certificates/CertificateDescriptionTests.cs b/tests/Microsoft.Identity.Web.Test/Certificates/CertificateDescriptionTests.cs index ab297a02b..deddbf910 100644 --- a/tests/Microsoft.Identity.Web.Test/Certificates/CertificateDescriptionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/Certificates/CertificateDescriptionTests.cs @@ -67,6 +67,17 @@ public void TestFromStoreWithThumbprint(string certificateThumbprint, StoreLocat Assert.Equal(certificateThumbprint, certificateDescription.CertificateThumbprint); } + [Theory] + [InlineData("TestCert", StoreLocation.LocalMachine, StoreName.Root)] + public void TestFromStoreWithSubjectName(string certificateSubjectName, StoreLocation storeLocation, StoreName storeName) + { + CertificateDescription certificateDescription = + CertificateDescription.FromStoreWithSubjectName(certificateSubjectName, storeLocation, storeName); + Assert.Equal(CertificateSource.StoreWithSubjectName, certificateDescription.SourceType); + Assert.Equal(certificateSubjectName, certificateDescription.CertificateSubjectName); + Assert.Equal($"{storeLocation}/{storeName}", certificateDescription.CertificateStorePath); + } + [Fact] public void TestFromCertificate() { diff --git a/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCertificateLoaderTests.cs b/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCertificateLoaderTests.cs index d45d75b2e..37eb7c16c 100644 --- a/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCertificateLoaderTests.cs +++ b/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCertificateLoaderTests.cs @@ -23,6 +23,7 @@ public class DefaultCertificateLoaderTests // [InlineData(CertificateSource.Path, @"c:\temp\WebAppCallingWebApiCert.pfx", "")] // [InlineData(CertificateSource.StoreWithDistinguishedName, "CurrentUser/My", "CN=WebAppCallingWebApiCert")] // [InlineData(CertificateSource.StoreWithThumbprint, "CurrentUser/My", "962D129A859174EE8B5596985BD18EFEB6961684")] + // [InlineData(CertificateSource.StoreWithSubjectName, "CurrentUser/My", "MyCertificate")] #pragma warning disable xUnit1012 // Null should only be used for nullable parameters [InlineData(CertificateSource.Base64Encoded, null, TestConstants.CertificateX5c)] #pragma warning restore xUnit1012 // Null should only be used for nullable parameters @@ -51,6 +52,10 @@ public void TestDefaultCertificateLoader(CertificateSource certificateSource, st certificateDescription.CertificateDistinguishedName = referenceOrValue; certificateDescription.CertificateStorePath = container; break; + case CertificateSource.StoreWithSubjectName: + certificateDescription = CertificateDescription.FromStoreWithSubjectName(referenceOrValue); + certificateDescription.CertificateStorePath = container; + break; default: certificateDescription = new CertificateDescription(); break; @@ -234,10 +239,10 @@ public void TestBackwardCompatibilityExistingConstructors() Assert.NotNull(loader3.CredentialSourceLoaders); Assert.NotNull(loader4.CredentialSourceLoaders); - Assert.True(loader1.CredentialSourceLoaders.Count >= 7); // Should have at least 7 built-in loaders - Assert.True(loader2.CredentialSourceLoaders.Count >= 7); - Assert.True(loader3.CredentialSourceLoaders.Count >= 7); - Assert.True(loader4.CredentialSourceLoaders.Count >= 7); + Assert.True(loader1.CredentialSourceLoaders.Count >= 8); // Should have at least 8 built-in loaders + Assert.True(loader2.CredentialSourceLoaders.Count >= 8); + Assert.True(loader3.CredentialSourceLoaders.Count >= 8); + Assert.True(loader4.CredentialSourceLoaders.Count >= 8); } [Fact] @@ -254,7 +259,7 @@ public void TestCustomLoaderWithNonConflictingCredentialSources() // Assert - should have all built-in loaders plus the custom one Assert.NotNull(loader.CredentialSourceLoaders); - Assert.True(loader.CredentialSourceLoaders.Count >= 8); // 7 built-in + 1 custom + Assert.True(loader.CredentialSourceLoaders.Count >= 9); // 8 built-in + 1 custom // Verify built-in loaders are still present Assert.True(loader.CredentialSourceLoaders.ContainsKey(CredentialSource.KeyVault)); @@ -305,6 +310,66 @@ public void TestConstructorWithBothCustomSignedAssertionProvidersAndCredentialSo Assert.NotNull(certificateLoader.CustomSignedAssertionCredentialSourceLoaders); } + [Fact] + public void TestFromStoreWithSubjectNameCreatesCorrectDescription() + { + // Arrange & Act + var description = CertificateDescription.FromStoreWithSubjectName( + "MyCertificate", + StoreLocation.LocalMachine, + StoreName.My); + + // Assert + Assert.Equal(CertificateSource.StoreWithSubjectName, description.SourceType); + Assert.Equal("MyCertificate", description.CertificateSubjectName); + Assert.Equal("LocalMachine/My", description.CertificateStorePath); + } + + [Fact] + public void TestFromStoreWithSubjectNameDefaultStoreLocation() + { + // Arrange & Act + var description = CertificateDescription.FromStoreWithSubjectName("MyCertificate"); + + // Assert + Assert.Equal(CertificateSource.StoreWithSubjectName, description.SourceType); + Assert.Equal("MyCertificate", description.CertificateSubjectName); + Assert.Equal("CurrentUser/My", description.CertificateStorePath); + } + + [Fact] + public void TestStoreWithSubjectNameLoaderIsRegistered() + { + // Act + var loader = new DefaultCredentialsLoader(); + + // Assert + Assert.True(loader.CredentialSourceLoaders.ContainsKey(CredentialSource.StoreWithSubjectName)); + } + + [Fact] + public async Task TestStoreWithSubjectNameLoaderIsUsed() + { + // Arrange + var customLoaders = new List + { + new MockCredentialSourceLoader(CredentialSource.StoreWithSubjectName, "subject-name-mock") + }; + var loader = new DefaultCredentialsLoader(customLoaders, null); + var credentialDescription = new CredentialDescription + { + SourceType = CredentialSource.StoreWithSubjectName, + CertificateSubjectName = "MyCertificate", + CertificateStorePath = CertificateConstants.PersonalUserCertificateStorePath + }; + + // Act + await loader.LoadCredentialsIfNeededAsync(credentialDescription); + + // Assert + Assert.Equal("subject-name-mock", credentialDescription.CachedValue); + } + /// /// Mock credential source loader for testing ///