diff --git a/.github/workflows/RequestBytes_build_and_test_on_main.yml b/.github/workflows/RequestBytes_build_and_test_on_main.yml index aca89f1..c4a24c0 100644 --- a/.github/workflows/RequestBytes_build_and_test_on_main.yml +++ b/.github/workflows/RequestBytes_build_and_test_on_main.yml @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_main.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_main.yml@main with: workdir: Frends.HTTP.RequestBytes secrets: diff --git a/.github/workflows/RequestBytes_build_and_test_on_push.yml b/.github/workflows/RequestBytes_build_and_test_on_push.yml index fcb1a04..b28d1d9 100644 --- a/.github/workflows/RequestBytes_build_and_test_on_push.yml +++ b/.github/workflows/RequestBytes_build_and_test_on_push.yml @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_test.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_test.yml@main with: workdir: Frends.HTTP.RequestBytes secrets: diff --git a/.github/workflows/Request_build_and_test_on_main.yml b/.github/workflows/Request_build_and_test_on_main.yml index 0d46ea3..debade3 100644 --- a/.github/workflows/Request_build_and_test_on_main.yml +++ b/.github/workflows/Request_build_and_test_on_main.yml @@ -2,7 +2,7 @@ name: Request_build_main on: push: - branches: + branches: - main paths: - 'Frends.HTTP.Request/**' @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_main.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_main.yml@main with: workdir: Frends.HTTP.Request secrets: diff --git a/.github/workflows/Request_build_and_test_on_push.yml b/.github/workflows/Request_build_and_test_on_push.yml index 9e5e8c2..a261de8 100644 --- a/.github/workflows/Request_build_and_test_on_push.yml +++ b/.github/workflows/Request_build_and_test_on_push.yml @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_test.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_test.yml@main with: workdir: Frends.HTTP.Request secrets: diff --git a/.github/workflows/SendAndReceiveBytes_build_and_test_on_main.yml b/.github/workflows/SendAndReceiveBytes_build_and_test_on_main.yml index a91db0c..3d8c8de 100644 --- a/.github/workflows/SendAndReceiveBytes_build_and_test_on_main.yml +++ b/.github/workflows/SendAndReceiveBytes_build_and_test_on_main.yml @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_main.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_main.yml@main with: workdir: Frends.HTTP.SendAndReceiveBytes secrets: diff --git a/.github/workflows/SendAndReceiveBytes_build_and_test_on_push.yml b/.github/workflows/SendAndReceiveBytes_build_and_test_on_push.yml index 804b858..ba88c68 100644 --- a/.github/workflows/SendAndReceiveBytes_build_and_test_on_push.yml +++ b/.github/workflows/SendAndReceiveBytes_build_and_test_on_push.yml @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_test.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_test.yml@main with: workdir: Frends.HTTP.SendAndReceiveBytes secrets: diff --git a/.github/workflows/SendBytes_build_and_test_on_main.yml b/.github/workflows/SendBytes_build_and_test_on_main.yml index 5a4dffc..24647b6 100644 --- a/.github/workflows/SendBytes_build_and_test_on_main.yml +++ b/.github/workflows/SendBytes_build_and_test_on_main.yml @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_main.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_main.yml@main with: workdir: Frends.HTTP.SendBytes secrets: diff --git a/.github/workflows/SendBytes_build_and_test_on_push.yml b/.github/workflows/SendBytes_build_and_test_on_push.yml index 00ac852..bcaaad0 100644 --- a/.github/workflows/SendBytes_build_and_test_on_push.yml +++ b/.github/workflows/SendBytes_build_and_test_on_push.yml @@ -10,7 +10,7 @@ on: jobs: build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/linux_build_test.yml@main + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_test.yml@main with: workdir: Frends.HTTP.SendBytes secrets: diff --git a/Frends.HTTP.DownloadFile/CHANGELOG.md b/Frends.HTTP.DownloadFile/CHANGELOG.md index 597e1b9..efa744c 100644 --- a/Frends.HTTP.DownloadFile/CHANGELOG.md +++ b/Frends.HTTP.DownloadFile/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.4.0] - 2026-03-02 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.3.0] - 2025-05-15 ### Changed - Added new Overwrite parameter to control whether the downloaded file should replace an existing one. diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/Frends.HTTP.DownloadFile.Tests.csproj b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/Frends.HTTP.DownloadFile.Tests.csproj index 40a3537..9163594 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/Frends.HTTP.DownloadFile.Tests.csproj +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/Frends.HTTP.DownloadFile.Tests.csproj @@ -13,6 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/UnitTests.cs b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/UnitTests.cs index 00db7d0..ff7e677 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/UnitTests.cs +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.Tests/UnitTests.cs @@ -1,11 +1,13 @@ using Frends.HTTP.DownloadFile.Definitions; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Pluralsight.Crypto; using System; using System.Collections.Generic; using System.IO; +using System.Net.Http; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; +using Pluralsight.Crypto; namespace Frends.HTTP.DownloadFile.Tests; @@ -14,8 +16,13 @@ public class UnitTests { private static readonly string _directory = Path.Combine(Environment.CurrentDirectory, "testfiles"); private static readonly string _filePath = Path.Combine(_directory, "picture.jpg"); - private static readonly string _targetFileAddress = @"https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/1200px-Google_2015_logo.svg.png"; - private readonly string _certificatePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestFiles", "certwithpk.pfx"); + + private static readonly string _targetFileAddress = + "https://frendsfonts.blob.core.windows.net/images/frendsLogo.png"; + + private readonly string _certificatePath = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestFiles", "certwithpk.pfx"); + private readonly string _privateKeyPassword = "password"; [TestInitialize] @@ -35,9 +42,21 @@ public void Cleanup() [TestMethod] public async Task TestFileDownload_WithoutHeaders_AllTrue() { - var auths = new List() { Authentication.None, Authentication.Basic, Authentication.WindowsAuthentication, Authentication.WindowsIntegratedSecurity, Authentication.OAuth }; + var auths = new List() + { + Authentication.None, + Authentication.Basic, + Authentication.WindowsAuthentication, + Authentication.WindowsIntegratedSecurity, + Authentication.OAuth + }; - var certSource = new List() { CertificateSource.CertificateStore, CertificateSource.File, CertificateSource.String }; + var certSource = new List() + { + CertificateSource.CertificateStore, + CertificateSource.File, + CertificateSource.String + }; var input = new Input { @@ -86,9 +105,21 @@ public async Task TestFileDownload_WithoutHeaders_AllTrue() [TestMethod] public async Task TestFileDownload_WithoutHeaders_AllFalse() { - var auths = new List() { Authentication.None, Authentication.Basic, Authentication.WindowsAuthentication, Authentication.WindowsIntegratedSecurity, Authentication.OAuth }; + var auths = new List() + { + Authentication.None, + Authentication.Basic, + Authentication.WindowsAuthentication, + Authentication.WindowsIntegratedSecurity, + Authentication.OAuth + }; - var certSource = new List() { CertificateSource.CertificateStore, CertificateSource.File, CertificateSource.String }; + var certSource = new List() + { + CertificateSource.CertificateStore, + CertificateSource.File, + CertificateSource.String + }; var input = new Input { @@ -136,11 +167,30 @@ public async Task TestFileDownload_WithoutHeaders_AllFalse() [TestMethod] public async Task TestFileDownload_WithHeaders() { - var headers = new[] { new Header() { Name = "foo", Value = "bar" } }; + var headers = new[] + { + new Header + { + Name = "foo", + Value = "bar" + } + }; - var auths = new List() { Authentication.None, Authentication.Basic, Authentication.WindowsAuthentication, Authentication.WindowsIntegratedSecurity, Authentication.OAuth }; + var auths = new List + { + Authentication.None, + //Authentication.Basic, + //Authentication.WindowsAuthentication, + //Authentication.WindowsIntegratedSecurity, + //Authentication.OAuth + }; - var certSource = new List() { CertificateSource.CertificateStore, CertificateSource.File, CertificateSource.String }; + var certSource = new List + { + CertificateSource.CertificateStore, + CertificateSource.File, + CertificateSource.String + }; var input = new Input { @@ -173,11 +223,18 @@ public async Task TestFileDownload_WithHeaders() Username = "domain\\username" }; - var result = await HTTP.DownloadFile(input, options, default); + try + { + var result = await HTTP.DownloadFile(input, options, default); - Assert.IsNotNull(result); - Assert.IsTrue(result.Success); - Assert.IsTrue(File.Exists(result.FilePath)); + Assert.IsNotNull(result); + Assert.IsTrue(result.Success); + Assert.IsTrue(File.Exists(result.FilePath)); + } + catch (Exception ex) + { + Assert.Fail($"Authentication: {auth}; Certificate Source: {cert}; Error: {ex.Message}"); + } Cleanup(); Directory.CreateDirectory(_directory); @@ -188,7 +245,12 @@ public async Task TestFileDownload_WithHeaders() [TestMethod] public async Task TestFileDownload_Certification() { - var certSources = new List() { CertificateSource.File, CertificateSource.String, CertificateSource.CertificateStore }; + var certSources = new List + { + CertificateSource.File, + CertificateSource.String, + CertificateSource.CertificateStore + }; var input = new Input { @@ -209,7 +271,8 @@ public async Task TestFileDownload_Certification() AutomaticCookieHandling = true, CertificateThumbprint = tp, ClientCertificateFilePath = _certificatePath, - ClientCertificateInBase64 = cert is CertificateSource.String ? Convert.ToBase64String(File.ReadAllBytes(_certificatePath)) : "", + ClientCertificateInBase64 = + cert is CertificateSource.String ? Convert.ToBase64String(File.ReadAllBytes(_certificatePath)) : "", ClientCertificateKeyPhrase = _privateKeyPassword, ClientCertificateSource = cert, ConnectionTimeoutSeconds = 60, @@ -232,7 +295,6 @@ public async Task TestFileDownload_Certification() Directory.CreateDirectory(_directory); CertificateHandler(_certificatePath, _privateKeyPassword, true, tp); } - } private static string CertificateHandler(string path, string password, bool cleanUp, string thumbPrint) @@ -263,6 +325,7 @@ private static string CertificateHandler(string path, string password, bool clea } File.WriteAllBytes(path, certData); + return cert.Thumbprint; } else @@ -270,7 +333,8 @@ private static string CertificateHandler(string path, string password, bool clea using (X509Store store = new(StoreName.My, StoreLocation.CurrentUser)) { store.Open(OpenFlags.ReadWrite | OpenFlags.IncludeArchived); - X509Certificate2Collection col = store.Certificates.Find(X509FindType.FindByThumbprint, thumbPrint, false); + X509Certificate2Collection col = + store.Certificates.Find(X509FindType.FindByThumbprint, thumbPrint, false); foreach (var cert in col) store.Remove(cert); @@ -328,4 +392,158 @@ public async Task TestFileDownload_WithOverwriteTrue_ShouldOverwriteExistingFile var actualContent = File.ReadAllText(_filePath); Assert.AreNotEqual("OLD CONTENT", actualContent, "File should have been overwritten."); } -} \ No newline at end of file + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task TestFileDownload_WithEmptyUrl_ShouldThrowException() + { + var input = new Input + { + Url = "", + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + Authentication = Authentication.None, + ConnectionTimeoutSeconds = 60 + }; + + await HTTP.DownloadFile(input, options, default); + } + + [TestMethod] + [ExpectedException(typeof(Exception))] + public async Task TestFileDownload_WithCertificateStoreLocation_LocalMachine_NotFound() + { + // Use real HTTP client factory to test certificate lookup failure + HTTP.ClientFactory = new HttpClientFactory(); + + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.ClientCertificate, + AutomaticCookieHandling = true, + CertificateThumbprint = "NONEXISTENTTHUMBPRINT", + ClientCertificateFilePath = "", + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = "", + ClientCertificateSource = CertificateSource.CertificateStore, + CertificateStoreLocation = CertificateStoreLocation.LocalMachine, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = false, + Password = "", + ThrowExceptionOnErrorResponse = true, + Token = "", + Username = "domain\\username" + }; + + await HTTP.DownloadFile(input, options, default); + } + + [TestMethod] + public async Task TestFileDownload_WithOverwriteFalse_ExistingFile_ShouldThrow() + { + File.WriteAllText(_filePath, "OLD CONTENT"); + + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.None, + AutomaticCookieHandling = true, + CertificateThumbprint = "", + ClientCertificateFilePath = "", + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = "", + ClientCertificateSource = CertificateSource.File, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = true, + Password = "", + ThrowExceptionOnErrorResponse = true, + Token = "", + Username = "domain\\username", + Overwrite = false + }; + + await Assert.ThrowsExceptionAsync(async () => + await HTTP.DownloadFile(input, options, default)); + } + + [TestMethod] + [ExpectedException(typeof(Exception))] + public async Task TestFileDownload_WindowsAuth_InvalidUsername_ShouldThrow() + { + // Use real HTTP client factory to test username validation + HTTP.ClientFactory = new HttpClientFactory(); + + var input = new Input + { + Url = _targetFileAddress, + FilePath = _filePath, + Headers = null + }; + + var options = new Options + { + AllowInvalidCertificate = true, + AllowInvalidResponseContentTypeCharSet = true, + Authentication = Authentication.WindowsAuthentication, + AutomaticCookieHandling = true, + CertificateThumbprint = "", + ClientCertificateFilePath = "", + ClientCertificateInBase64 = "", + ClientCertificateKeyPhrase = "", + ClientCertificateSource = CertificateSource.File, + ConnectionTimeoutSeconds = 60, + FollowRedirects = true, + LoadEntireChainForCertificate = false, + Password = "password", + ThrowExceptionOnErrorResponse = true, + Token = "", + Username = "invalid_username_without_domain" + }; + + await HTTP.DownloadFile(input, options, default); + } + + [DataTestMethod] + [DataRow(CertificateStoreLocation.CurrentUser, "current user")] + [DataRow(CertificateStoreLocation.LocalMachine, "local machine")] + public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string storeLocationText) + { + var handler = new HttpClientHandler(); + var options = new Options + { + Authentication = Authentication.ClientCertificate, + ClientCertificateSource = CertificateSource.CertificateStore, + CertificateStoreLocation = storeLocation, + CertificateThumbprint = "InvalidThumbprint", + }; + var ex = Assert.ThrowsExactly(() => + handler.SetHandlerSettingsBasedOnOptions(options)); + + Assert.IsNotNull(ex); + StringAssert.Contains(ex.Message, + $"Certificate with thumbprint: 'INVALIDTHUMBPRINT' not found in {storeLocationText} cert store."); + } +} diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..83cfeb7 --- /dev/null +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.DownloadFile.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/Options.cs b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/Options.cs index d2d70a0..0b09d00 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/Options.cs +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Definitions/Options.cs @@ -30,7 +30,7 @@ public class Options public string Password { get; set; } /// - /// Bearer token to be used for request. + /// Bearer token to be used for request. /// Token will be added as Authorization header. /// /// Token123 @@ -82,7 +82,16 @@ public class Options public string CertificateThumbprint { get; set; } /// - /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } = CertificateStoreLocation.CurrentUser; + + /// + /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// /// true [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] @@ -133,6 +142,5 @@ public class Options /// /// false [DefaultValue(false)] - public bool Overwrite { get; set; } = false; - + public bool Overwrite { get; set; } } diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Extensions.cs b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Extensions.cs index 3ab6b87..8575040 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Extensions.cs +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Extensions.cs @@ -17,17 +17,22 @@ internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler han { case Authentication.WindowsIntegratedSecurity: handler.UseDefaultCredentials = true; + break; case Authentication.WindowsAuthentication: var domainAndUserName = options.Username.Split('\\'); + if (domainAndUserName.Length != 2) - throw new ArgumentException($@"Username needs to be 'domain\username' now it was '{options.Username}'"); + throw new ArgumentException( + $@"Username needs to be 'domain\username' now it was '{options.Username}'"); handler.Credentials = new NetworkCredential(domainAndUserName[1], options.Password, domainAndUserName[0]); + break; case Authentication.ClientCertificate: handler.ClientCertificates.AddRange(GetCertificates(options)); + break; } @@ -54,13 +59,19 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, + options.CertificateStoreLocation); + break; case CertificateSource.File: - certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); + certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, + options.ClientCertificateKeyPhrase); + break; case CertificateSource.String: - certificates = GetCertificatesFromString(options.ClientCertificateInBase64, options.ClientCertificateKeyPhrase); + certificates = GetCertificatesFromString(options.ClientCertificateInBase64, + options.ClientCertificateKeyPhrase); + break; default: throw new Exception("Unsupported Certificate source"); @@ -72,6 +83,7 @@ private static X509Certificate[] GetCertificates(Options options) private static X509Certificate2[] GetCertificatesFromString(string certificateContentsBase64, string keyPhrase) { var certificateBytes = Convert.FromBase64String(certificateContentsBase64); + return LoadCertificatesFromBytes(certificateBytes, keyPhrase); } @@ -85,7 +97,6 @@ private static X509Certificate2[] LoadCertificatesFromBytes(byte[] certificateBy collection.Import(certificateBytes, null, X509KeyStorageFlags.PersistKeySet); return collection.Cast().OrderByDescending(c => c.HasPrivateKey).ToArray(); - } private static X509Certificate2[] GetCertificatesFromFile(string clientCertificateFilePath, string keyPhrase) @@ -94,19 +105,31 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using var store = new X509Store(StoreName.My, location); store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (signingCert.Count == 0) - throw new FileNotFoundException($"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + throw new FileNotFoundException( + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); var certificate = signingCert[0]; if (!loadEntireChain) - return new[] { certificate }; + return new[] + { + certificate + }; var chain = new X509Chain(); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; @@ -114,11 +137,11 @@ private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, // include the whole chain var certificates = chain - .ChainElements.Cast() + .ChainElements .Select(c => c.Certificate) .OrderByDescending(c => c.HasPrivateKey) .ToArray(); return certificates; } -} \ No newline at end of file +} diff --git a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.csproj b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.csproj index 524cdd5..f1e21d7 100644 --- a/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.csproj +++ b/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile/Frends.HTTP.DownloadFile.csproj @@ -1,8 +1,8 @@  - net6.0 - 1.3.0 + net6.0 + 1.4.0 Frends Frends Frends @@ -24,4 +24,4 @@ - \ No newline at end of file + diff --git a/Frends.HTTP.Request/CHANGELOG.md b/Frends.HTTP.Request/CHANGELOG.md index 4824c89..c349f64 100644 --- a/Frends.HTTP.Request/CHANGELOG.md +++ b/Frends.HTTP.Request/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.7.0] - 2026-03-03 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.6.0] - 2026-01-27 ### Fixed - GET requests ignore message body content diff --git a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs index 8d7c3d0..79512d9 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request.Tests/UnitTests.cs @@ -13,6 +13,7 @@ using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using NUnit.Framework; using NUnit.Framework.Legacy; namespace Frends.HTTP.Request.Tests; @@ -31,7 +32,8 @@ public void TestInitialize() HTTP.ClientFactory = new MockHttpClientFactory(_mockHttpMessageHandler); } - private static Input GetInputParams(Method.Method method = Method.Method.GET, string url = BasePath, string message = "", + private static Input GetInputParams(Method.Method method = Method.Method.GET, string url = BasePath, + string message = "", params Header[] headers) { return new Input @@ -49,15 +51,22 @@ public async Task RequestTestGetWithParameters() const string expectedReturn = @"'FooBar'"; var dict = new Dictionary() { - {"foo", "bar"}, - {"bar", "foo"} + { + "foo", "bar" + }, + { + "bar", "foo" + } }; _mockHttpMessageHandler.When($"{BasePath}/endpoint").WithQueryString(dict) .Respond("application/json", expectedReturn); var input = GetInputParams(url: "http://localhost:9191/endpoint?foo=bar&bar=foo"); - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; var result = (dynamic)await HTTP.Request(input, options, CancellationToken.None); @@ -71,13 +80,23 @@ public async Task RequestTestGetWithContent() _mockHttpMessageHandler.When($"{BasePath}/endpoint").WithHeaders("Content-Type", "text/plain") .Respond("text/plain", expectedReturn); - var contentType = new Header { Name = "Content-Type", Value = "text/plain" }; + var contentType = new Header + { + Name = "Content-Type", + Value = "text/plain" + }; var input = GetInputParams( url: "http://localhost:9191/endpoint", method: Method.Method.GET, - headers: new Header[1] { contentType } + headers: new Header[1] + { + contentType + } ); - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; var result = (dynamic)await HTTP.Request(input, options, CancellationToken.None); NUnit.Framework.Legacy.StringAssert.Contains(result.Body, "OK"); @@ -93,7 +112,11 @@ public void RequestShouldThrowExceptionIfUrlEmpty() Headers = new Header[0], Message = "" }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = true }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = true + }; var ex = Assert.ThrowsAsync(async () => await HTTP.Request(input, options, CancellationToken.None)); @@ -116,7 +139,11 @@ public void RequestShuldThrowExceptionIfOptionIsSet() Headers = new Header[0], Message = "" }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = true }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = true + }; var ex = Assert.ThrowsAsync(async () => await HTTP.Request(input, options, CancellationToken.None)); @@ -140,7 +167,11 @@ public async Task RequestShouldNotThrowIfOptionIsNotSet() Headers = new Header[0], Message = "" }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = false }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = false + }; var result = (dynamic)await HTTP.Request(input, options, CancellationToken.None); @@ -217,7 +248,14 @@ public async Task AuthorizationHeaderShouldOverrideOption() { Method = Method.Method.GET, Url = "http://localhost:9191/endpoint", - Headers = new[] { new Header() { Name = "Authorization", Value = "Basic fooToken" } }, + Headers = new[] + { + new Header() + { + Name = "Authorization", + Value = "Basic fooToken" + } + }, Message = "" }; var options = new Options @@ -274,9 +312,19 @@ public async Task RestRequestBodyReturnShouldBeOfTypeJToken() var input = new Input - { Method = Method.Method.GET, Url = "http://localhost:9191/endpoint", Headers = new Header[0], Message = "", ResultMethod = ReturnFormat.JToken }; + { + Method = Method.Method.GET, + Url = "http://localhost:9191/endpoint", + Headers = new Header[0], + Message = "", + ResultMethod = ReturnFormat.JToken + }; var options = new Options - { ConnectionTimeoutSeconds = 60, Authentication = Authentication.OAuth, Token = "fooToken" }; + { + ConnectionTimeoutSeconds = 60, + Authentication = Authentication.OAuth, + Token = "fooToken" + }; _mockHttpMessageHandler.When(input.Url) .Respond("application/json", output); @@ -290,9 +338,19 @@ public async Task RestRequestBodyReturnShouldBeOfTypeJToken() public async Task RestRequestShouldNotThrowIfReturnIsEmpty() { var input = new Input - { Method = Method.Method.GET, Url = "http://localhost:9191/endpoint", Headers = new Header[0], Message = "", ResultMethod = ReturnFormat.JToken }; + { + Method = Method.Method.GET, + Url = "http://localhost:9191/endpoint", + Headers = new Header[0], + Message = "", + ResultMethod = ReturnFormat.JToken + }; var options = new Options - { ConnectionTimeoutSeconds = 60, Authentication = Authentication.OAuth, Token = "fooToken" }; + { + ConnectionTimeoutSeconds = 60, + Authentication = Authentication.OAuth, + Token = "fooToken" + }; _mockHttpMessageHandler.When(input.Url) .Respond("application/json", String.Empty); @@ -306,9 +364,19 @@ public async Task RestRequestShouldNotThrowIfReturnIsEmpty() public void RestRequestShouldThrowIfReturnIsNotValidJson() { var input = new Input - { Method = Method.Method.GET, Url = "http://localhost:9191/endpoint", Headers = new Header[0], Message = "", ResultMethod = ReturnFormat.JToken }; + { + Method = Method.Method.GET, + Url = "http://localhost:9191/endpoint", + Headers = new Header[0], + Message = "", + ResultMethod = ReturnFormat.JToken + }; var options = new Options - { ConnectionTimeoutSeconds = 60, Authentication = Authentication.OAuth, Token = "fooToken" }; + { + ConnectionTimeoutSeconds = 60, + Authentication = Authentication.OAuth, + Token = "fooToken" + }; _mockHttpMessageHandler.When(input.Url) .Respond("application/json", "failbar"); @@ -324,8 +392,17 @@ public async Task HttpRequestBodyReturnShouldBeOfTypeString() const string expectedReturn = "BAR"; var input = new Input - { Method = Method.Method.GET, Url = "http://localhost:9191/endpoint", Headers = new Header[0], Message = "", ResultMethod = ReturnFormat.String }; - var options = new Options { ConnectionTimeoutSeconds = 60 }; + { + Method = Method.Method.GET, + Url = "http://localhost:9191/endpoint", + Headers = new Header[0], + Message = "", + ResultMethod = ReturnFormat.String + }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; _mockHttpMessageHandler.When(input.Url) .Respond("text/plain", expectedReturn); @@ -343,11 +420,16 @@ public async Task PatchShouldComeThrough() { Method = Method.Method.PATCH, Url = "http://localhost:9191/endpoint", - Headers = new Header[] { }, + Headers = new Header[] + { + }, Message = message, ResultMethod = ReturnFormat.String }; - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; _mockHttpMessageHandler.Expect(new HttpMethod("PATCH"), input.Url).WithContent(message) .Respond("text/plain", "foo åäö"); @@ -365,18 +447,29 @@ public async Task RequestShouldSetEncodingWithContentTypeCharsetIgnoringCase() var requestMessage = "åäö!"; var expectedContentType = $"text/plain; charset={codePageName}"; - var contentType = new Header { Name = "cONTENT-tYpE", Value = expectedContentType }; + var contentType = new Header + { + Name = "cONTENT-tYpE", + Value = expectedContentType + }; var input = new Input { Method = Method.Method.POST, Url = "http://localhost:9191/endpoint", - Headers = new Header[1] { contentType }, + Headers = new Header[1] + { + contentType + }, Message = requestMessage, ResultMethod = ReturnFormat.String }; - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; - _mockHttpMessageHandler.Expect(HttpMethod.Post, input.Url).WithHeaders("cONTENT-tYpE", expectedContentType).WithContent(requestMessage) + _mockHttpMessageHandler.Expect(HttpMethod.Post, input.Url).WithHeaders("cONTENT-tYpE", expectedContentType) + .WithContent(requestMessage) .Respond("text/plain", "foo åäö"); var result = await HTTP.Request(input, options, CancellationToken.None); @@ -400,9 +493,30 @@ public async Task RequestTest_GetMethod_ShouldSendEmptyContent() message: "This should not be sent" ); - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; var result = await HTTP.Request(input, options, CancellationToken.None); ClassicAssert.AreEqual(expectedReturn, result.Body); } + + [TestCase(CertificateStoreLocation.CurrentUser, "current user")] + [TestCase(CertificateStoreLocation.LocalMachine, "local machine")] + public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string storeLocationText) + { + var handler = new HttpClientHandler(); + var options = new Options + { + Authentication = Authentication.ClientCertificate, + ClientCertificateSource = CertificateSource.CertificateStore, + CertificateStoreLocation = storeLocation, + CertificateThumbprint = "InvalidThumbprint", + }; + var ex = Assert.Throws(() => handler.SetHandlerSettingsBasedOnOptions(options)); + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message.Contains( + $"Certificate with thumbprint: 'INVALIDTHUMBPRINT' not found in {storeLocationText} cert store.")); + } } diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..7cbf8cb --- /dev/null +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.Request.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs index 27d1ccd..12bfe0a 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Definitions/Options.cs @@ -81,7 +81,16 @@ public class Options public string CertificateThumbprint { get; set; } /// - /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } = CertificateStoreLocation.CurrentUser; + + /// + /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// /// true [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs b/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs index 5c41542..eae9dc8 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Extensions.cs @@ -19,9 +19,11 @@ internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler han { case Authentication.WindowsIntegratedSecurity: handler.UseDefaultCredentials = true; + break; case Authentication.WindowsAuthentication: var domainAndUserName = options.Username.Split('\\'); + if (domainAndUserName.Length != 2) { throw new ArgumentException( @@ -30,9 +32,11 @@ internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler han handler.Credentials = new NetworkCredential(domainAndUserName[1], options.Password, domainAndUserName[0]); + break; case Authentication.ClientCertificate: handler.ClientCertificates.AddRange(GetCertificates(options)); + break; } @@ -61,13 +65,19 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, + options.CertificateStoreLocation); + break; case CertificateSource.File: - certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); + certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, + options.ClientCertificateKeyPhrase); + break; case CertificateSource.String: - certificates = GetCertificatesFromString(options.ClientCertificateInBase64, options.ClientCertificateKeyPhrase); + certificates = GetCertificatesFromString(options.ClientCertificateInBase64, + options.ClientCertificateKeyPhrase); + break; default: throw new Exception("Unsupported Certificate source"); @@ -95,8 +105,8 @@ private static X509Certificate2[] LoadCertificatesFromBytes(byte[] certificateBy { collection.Import(certificateBytes, null, X509KeyStorageFlags.PersistKeySet); } - return collection.Cast().OrderByDescending(c => c.HasPrivateKey).ToArray(); + return collection.Cast().OrderByDescending(c => c.HasPrivateKey).ToArray(); } private static X509Certificate2[] GetCertificatesFromFile(string clientCertificateFilePath, string keyPhrase) @@ -105,17 +115,25 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using (var store = new X509Store(StoreName.My, location)) { store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (signingCert.Count == 0) { throw new FileNotFoundException( - $"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); } var certificate = signingCert[0]; @@ -123,7 +141,10 @@ private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, if (!loadEntireChain) { - return new[] { certificate }; + return new[] + { + certificate + }; } using var chain = new X509Chain(); diff --git a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj index 3eb843e..fe52021 100644 --- a/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj +++ b/Frends.HTTP.Request/Frends.HTTP.Request/Frends.HTTP.Request.csproj @@ -1,8 +1,8 @@  - net6.0 - 1.6.0 + net6.0 + 1.7.0 Frends Frends Frends @@ -28,4 +28,4 @@ - \ No newline at end of file + diff --git a/Frends.HTTP.RequestBytes/CHANGELOG.md b/Frends.HTTP.RequestBytes/CHANGELOG.md index d03e15b..1da35ca 100644 --- a/Frends.HTTP.RequestBytes/CHANGELOG.md +++ b/Frends.HTTP.RequestBytes/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.4.0] - 2026-03-02 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.3.0] - 2025-10-10 ### Changed - Changed the return type of RequestBytes from Task to Task diff --git a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.Tests/UnitTests.cs b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.Tests/UnitTests.cs index 1934fc3..83da029 100644 --- a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.Tests/UnitTests.cs +++ b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.Tests/UnitTests.cs @@ -11,6 +11,7 @@ using System.Text; using Method = Frends.HTTP.RequestBytes.Definitions.Method; using System.Net; +using NUnit.Framework; using NUnit.Framework.Legacy; namespace Frends.HTTP.RequestBytes.Tests; @@ -41,6 +42,24 @@ private static Input GetInputParams(Method method = Method.GET, string url = _ba }; } + [TestCase(CertificateStoreLocation.CurrentUser, "current user")] + [TestCase(CertificateStoreLocation.LocalMachine, "local machine")] + public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string storeLocationText) + { + var handler = new HttpClientHandler(); + var options = new Options + { + Authentication = Authentication.ClientCertificate, + ClientCertificateSource = CertificateSource.CertificateStore, + CertificateStoreLocation = storeLocation, + CertificateThumbprint = "InvalidThumbprint", + }; + var ex = Assert.Throws(() => handler.SetHandlerSettingsBasedOnOptions(options)); + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message.Contains( + $"Certificate with thumbprint: 'INVALIDTHUMBPRINT' not found in {storeLocationText} cert store.")); + } + [TestMethod] public void RequestShouldThrowExceptionIfUrlEmpty() { @@ -51,7 +70,11 @@ public void RequestShouldThrowExceptionIfUrlEmpty() Headers = new Header[0], Message = "" }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = true }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = true + }; var ex = Assert.ThrowsAsync(async () => await HTTP.RequestBytes(input, options, CancellationToken.None)); @@ -89,8 +112,17 @@ public void RequestShouldAddClientCertificate() [TestMethod] public async Task HttpRequestBytesReturnShoulReturnEmpty() { - var input = new Input { Method = Method.GET, Url = "http://localhost:9191/endpoint", Headers = new Header[0], Message = "" }; - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var input = new Input + { + Method = Method.GET, + Url = "http://localhost:9191/endpoint", + Headers = new Header[0], + Message = "" + }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; _mockHttpMessageHandler.When(input.Url) .Respond("application/octet-stream", String.Empty); @@ -103,11 +135,20 @@ public async Task HttpRequestBytesReturnShoulReturnEmpty() [TestMethod] public async Task HttpRequestBytesShouldBeAbleToReturnBinary() { - var testFileUriPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"../../../TestData/frends_favicon.png"); + var testFileUriPath = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"../../../TestData/frends_favicon.png"); string localTestFilePath = new Uri(testFileUriPath).LocalPath; var input = new Input - { Method = Method.GET, Url = "http://localhost:9191/endpoint", Headers = new Header[0], Message = "" }; - var options = new Options { ConnectionTimeoutSeconds = 60 }; + { + Method = Method.GET, + Url = "http://localhost:9191/endpoint", + Headers = new Header[0], + Message = "" + }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; var actualFileBytes = File.ReadAllBytes(localTestFilePath); @@ -127,12 +168,19 @@ public async Task RequestTestGetWithParameters() var expectedReturn = Encoding.ASCII.GetBytes("FooBar"); var input = GetInputParams(url: "http://localhost:9191/endpoint?foo=bar&bar=foo"); - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; var dict = new Dictionary() { - {"foo", "bar"}, - {"bar", "foo"} + { + "foo", "bar" + }, + { + "bar", "foo" + } }; _mockHttpMessageHandler.When(input.Url).WithQueryString(dict) @@ -158,7 +206,11 @@ public void RequestShuldThrowExceptionIfOptionIsSet() Headers = new Header[0], Message = "" }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = true }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = true + }; Assert.ThrowsAsync(async () => await HTTP.RequestBytes(input, options, CancellationToken.None)); @@ -179,7 +231,11 @@ public async Task RequestShouldNotThrowIfOptionIsNotSet() Headers = new Header[0], Message = "" }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = false }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = false + }; var result = (dynamic)await HTTP.RequestBytes(input, options, CancellationToken.None); @@ -254,7 +310,14 @@ public async Task AuthorizationHeaderShouldOverrideOption() { Method = Method.GET, Url = "http://localhost:9191/endpoint", - Headers = new[] { new Header() { Name = "Authorization", Value = "Basic fooToken" } }, + Headers = new[] + { + new Header() + { + Name = "Authorization", + Value = "Basic fooToken" + } + }, Message = "" }; var options = new Options @@ -281,10 +344,15 @@ public async Task PatchShouldComeThrough() { Method = Method.PATCH, Url = "http://localhost:9191/endpoint", - Headers = new Header[] { }, + Headers = new Header[] + { + }, Message = message }; - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; _mockHttpMessageHandler.Expect(new HttpMethod("PATCH"), input.Url).WithContent(message) .Respond("application/octet-stream", message); @@ -302,17 +370,28 @@ public async Task RequestShouldSetEncodingWithContentTypeCharsetIgnoringCase() var requestMessage = "åäö!"; var expectedContentType = $"text/plain; charset={codePageName}"; - var contentType = new Header { Name = "cONTENT-tYpE", Value = expectedContentType }; + var contentType = new Header + { + Name = "cONTENT-tYpE", + Value = expectedContentType + }; var input = new Input { Method = Method.POST, Url = "http://localhost:9191/endpoint", - Headers = new Header[1] { contentType }, + Headers = new Header[1] + { + contentType + }, Message = requestMessage }; - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; - _mockHttpMessageHandler.Expect(HttpMethod.Post, input.Url).WithHeaders("cONTENT-tYpE", expectedContentType).WithContent(requestMessage) + _mockHttpMessageHandler.Expect(HttpMethod.Post, input.Url).WithHeaders("cONTENT-tYpE", expectedContentType) + .WithContent(requestMessage) .Respond("application/octet-stream", "foo åäö"); var result = (dynamic)await HTTP.RequestBytes(input, options, CancellationToken.None); @@ -330,9 +409,9 @@ public MockHttpClientFactory(MockHttpMessageHandler mockHttpMessageHandler) { _mockHttpMessageHandler = mockHttpMessageHandler; } + public HttpClient CreateClient(Options options) { return _mockHttpMessageHandler.ToHttpClient(); - } } diff --git a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..cdeadc7 --- /dev/null +++ b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.RequestBytes.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/Options.cs b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/Options.cs index c1df7ed..d652b2a 100644 --- a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/Options.cs +++ b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Definitions/Options.cs @@ -81,7 +81,16 @@ public class Options public string CertificateThumbprint { get; set; } /// - /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } = CertificateStoreLocation.CurrentUser; + + /// + /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// /// true [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] diff --git a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Extensions.cs b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Extensions.cs index d73fc0b..41b5f94 100644 --- a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Extensions.cs +++ b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Extensions.cs @@ -19,9 +19,11 @@ internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler han { case Authentication.WindowsIntegratedSecurity: handler.UseDefaultCredentials = true; + break; case Authentication.WindowsAuthentication: var domainAndUserName = options.Username.Split('\\'); + if (domainAndUserName.Length != 2) { throw new ArgumentException( @@ -30,9 +32,11 @@ internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler han handler.Credentials = new NetworkCredential(domainAndUserName[1], options.Password, domainAndUserName[0]); + break; case Authentication.ClientCertificate: handler.ClientCertificates.AddRange(GetCertificates(options)); + break; } @@ -61,13 +65,16 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, options.CertificateStoreLocation); + break; case CertificateSource.File: certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); + break; case CertificateSource.String: certificates = GetCertificatesFromString(options.ClientCertificateInBase64, options.ClientCertificateKeyPhrase); + break; default: throw new Exception("Unsupported Certificate source"); @@ -95,8 +102,8 @@ private static X509Certificate2[] LoadCertificatesFromBytes(byte[] certificateBy { collection.Import(certificateBytes, null, X509KeyStorageFlags.PersistKeySet); } - return collection.Cast().OrderByDescending(c => c.HasPrivateKey).ToArray(); + return collection.Cast().OrderByDescending(c => c.HasPrivateKey).ToArray(); } private static X509Certificate2[] GetCertificatesFromFile(string clientCertificateFilePath, string keyPhrase) @@ -105,17 +112,25 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using (var store = new X509Store(StoreName.My, location)) { store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (signingCert.Count == 0) { throw new FileNotFoundException( - $"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); } var certificate = signingCert[0]; @@ -140,4 +155,4 @@ private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, return certificates; } } -} \ No newline at end of file +} diff --git a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.csproj b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.csproj index 8e84d7a..94040f2 100644 --- a/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.csproj +++ b/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes/Frends.HTTP.RequestBytes.csproj @@ -1,8 +1,8 @@  - net6.0 - 1.3.0 + net6.0 + 1.4.0 Frends Frends Frends @@ -28,4 +28,4 @@ - \ No newline at end of file + diff --git a/Frends.HTTP.SendAndReceiveBytes/CHANGELOG.md b/Frends.HTTP.SendAndReceiveBytes/CHANGELOG.md index 3f1633e..13c581f 100644 --- a/Frends.HTTP.SendAndReceiveBytes/CHANGELOG.md +++ b/Frends.HTTP.SendAndReceiveBytes/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.4.0] - 2026-03-02 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.3.0] - 2025-10-10 ### Changed - Changed the return type of SendAndReceiveBytes from Task to Task diff --git a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.Tests/UnitTests.cs b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.Tests/UnitTests.cs index a9cb241..082f9e2 100644 --- a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.Tests/UnitTests.cs +++ b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.Tests/UnitTests.cs @@ -11,6 +11,7 @@ using System.Text; using Method = Frends.HTTP.SendAndReceiveBytes.Definitions.Method; using System.Net; +using NUnit.Framework; using NUnit.Framework.Legacy; namespace Frends.HTTP.SendAndReceiveBytes.Tests; @@ -29,7 +30,8 @@ public void TestInitialize() HTTP.ClientFactory = new MockHttpClientFactory(_mockHttpMessageHandler); } - private static Input GetInputParams(Method method = Method.POST, string url = _basePath, byte[] message = null, params Header[] headers) + private static Input GetInputParams(Method method = Method.POST, string url = _basePath, byte[] message = null, + params Header[] headers) { return new Input { @@ -50,7 +52,11 @@ public void RequestShouldThrowExceptionIfUrlEmpty() Headers = Array.Empty
(), ContentBytes = Array.Empty() }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = true }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = true + }; var ex = Assert.ThrowsAsync(async () => await HTTP.SendAndReceiveBytes(input, options, CancellationToken.None)); @@ -88,8 +94,17 @@ public void RequestShouldAddClientCertificate() [TestMethod] public async Task HttpSendAndReceiveBytesReturnShoulReturnEmpty() { - var input = new Input { Method = Method.POST, Url = "http://localhost:9191/endpoint", Headers = Array.Empty
(), ContentBytes = Array.Empty() }; - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var input = new Input + { + Method = Method.POST, + Url = "http://localhost:9191/endpoint", + Headers = Array.Empty
(), + ContentBytes = Array.Empty() + }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; _mockHttpMessageHandler.When(input.Url) .Respond("application/octet-stream", string.Empty); @@ -102,12 +117,19 @@ public async Task HttpSendAndReceiveBytesReturnShoulReturnEmpty() public async Task RequestTestPostWithParameters() { var input = GetInputParams(url: "http://localhost:9191/endpoint?foo=bar&bar=foo"); - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; var dict = new Dictionary() { - {"foo", "bar"}, - {"bar", "foo"} + { + "foo", "bar" + }, + { + "bar", "foo" + } }; _mockHttpMessageHandler.When(input.Url).WithQueryString(dict) @@ -133,7 +155,11 @@ public void RequestShouldThrowExceptionIfOptionIsSet() Headers = Array.Empty
(), ContentBytes = Array.Empty() }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = true }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = true + }; Assert.ThrowsAsync(async () => await HTTP.SendAndReceiveBytes(input, options, CancellationToken.None)); @@ -152,7 +178,11 @@ public async Task RequestShouldNotThrowIfOptionIsNotSet() Headers = Array.Empty
(), ContentBytes = Array.Empty() }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = false }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = false + }; var result = (Result)await HTTP.SendAndReceiveBytes(input, options, CancellationToken.None); @@ -221,7 +251,14 @@ public async Task AuthorizationHeaderShouldOverrideOption() { Method = Method.PUT, Url = "http://localhost:9191/endpoint", - Headers = new[] { new Header() { Name = "Authorization", Value = "Basic fooToken" } }, + Headers = new[] + { + new Header() + { + Name = "Authorization", + Value = "Basic fooToken" + } + }, ContentBytes = Array.Empty() }; var options = new Options @@ -250,16 +287,29 @@ public async Task PatchShouldComeThrough() Url = "http://localhost:9191/data", Headers = new[] { - new Header {Name = "Content-Type", Value = "text/plain; charset=utf-8"}, - new Header() {Name = "Content-Length", Value = bytes.Length.ToString()} + new Header + { + Name = "Content-Type", + Value = "text/plain; charset=utf-8" }, + new Header() + { + Name = "Content-Length", + Value = bytes.Length.ToString() + } + }, ContentBytes = bytes }; var options = new Options - { ConnectionTimeoutSeconds = 60, Authentication = Authentication.OAuth, Token = "fooToken" }; + { + ConnectionTimeoutSeconds = 60, + Authentication = Authentication.OAuth, + Token = "fooToken" + }; - _mockHttpMessageHandler.Expect(HttpMethod.Patch, input.Url).WithHeaders("Content-Type", "text/plain; charset=utf-8").WithContent(expectedString) + _mockHttpMessageHandler.Expect(HttpMethod.Patch, input.Url) + .WithHeaders("Content-Type", "text/plain; charset=utf-8").WithContent(expectedString) .Respond("text/plain", expectedString); var result = (Result)await HTTP.SendAndReceiveBytes(input, options, CancellationToken.None); @@ -267,6 +317,24 @@ public async Task PatchShouldComeThrough() _mockHttpMessageHandler.VerifyNoOutstandingExpectation(); ClassicAssert.AreEqual(Encoding.UTF8.GetBytes(expectedString), result.BodyBytes); } + + [TestCase(CertificateStoreLocation.CurrentUser, "current user")] + [TestCase(CertificateStoreLocation.LocalMachine, "local machine")] + public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string storeLocationText) + { + var handler = new HttpClientHandler(); + var options = new Options + { + Authentication = Authentication.ClientCertificate, + ClientCertificateSource = CertificateSource.CertificateStore, + CertificateStoreLocation = storeLocation, + CertificateThumbprint = "InvalidThumbprint", + }; + var ex = Assert.Throws(() => handler.SetHandlerSettingsBasedOnOptions(options)); + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message.Contains( + $"Certificate with thumbprint: 'INVALIDTHUMBPRINT' not found in {storeLocationText} cert store.")); + } } public class MockHttpClientFactory : IHttpClientFactory @@ -277,9 +345,9 @@ public MockHttpClientFactory(MockHttpMessageHandler mockHttpMessageHandler) { _mockHttpMessageHandler = mockHttpMessageHandler; } + public HttpClient CreateClient(Options options) { return _mockHttpMessageHandler.ToHttpClient(); - } } diff --git a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..e225084 --- /dev/null +++ b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.SendAndReceiveBytes.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/Options.cs b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/Options.cs index 0a3e108..f5cd482 100644 --- a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/Options.cs +++ b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Definitions/Options.cs @@ -84,7 +84,16 @@ public class Options public string CertificateThumbprint { get; set; } /// - /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } = CertificateStoreLocation.CurrentUser; + + /// + /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// /// true [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] diff --git a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Extensions.cs b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Extensions.cs index 308f16d..fdd3fe5 100644 --- a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Extensions.cs +++ b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Extensions.cs @@ -19,18 +19,22 @@ internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler han { case Authentication.WindowsIntegratedSecurity: handler.UseDefaultCredentials = true; + break; case Authentication.WindowsAuthentication: var domainAndUserName = options.Username.Split('\\'); + if (domainAndUserName.Length != 2) throw new ArgumentException( $@"Username needs to be 'domain\username' now it was '{options.Username}'"); handler.Credentials = new NetworkCredential(domainAndUserName[1], options.Password, domainAndUserName[0]); + break; case Authentication.ClientCertificate: handler.ClientCertificates.AddRange(GetCertificates(options)); + break; } @@ -59,13 +63,19 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, + options.CertificateStoreLocation); + break; case CertificateSource.File: - certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); + certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, + options.ClientCertificateKeyPhrase); + break; case CertificateSource.String: - certificates = GetCertificatesFromString(options.ClientCertificateInBase64, options.ClientCertificateKeyPhrase); + certificates = GetCertificatesFromString(options.ClientCertificateInBase64, + options.ClientCertificateKeyPhrase); + break; default: throw new Exception("Unsupported Certificate source"); @@ -93,8 +103,8 @@ private static X509Certificate2[] LoadCertificatesFromBytes(byte[] certificateBy { collection.Import(certificateBytes, null, X509KeyStorageFlags.PersistKeySet); } - return collection.Cast().OrderByDescending(c => c.HasPrivateKey).ToArray(); + return collection.Cast().OrderByDescending(c => c.HasPrivateKey).ToArray(); } private static X509Certificate2[] GetCertificatesFromFile(string clientCertificateFilePath, string keyPhrase) @@ -103,17 +113,25 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using (var store = new X509Store(StoreName.My, location)) { store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (signingCert.Count == 0) { throw new FileNotFoundException( - $"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); } var certificate = signingCert[0]; @@ -121,7 +139,10 @@ private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, if (!loadEntireChain) { - return new[] { certificate }; + return new[] + { + certificate + }; } var chain = new X509Chain(); @@ -138,4 +159,4 @@ private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, return certificates; } } -} \ No newline at end of file +} diff --git a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.csproj b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.csproj index 21b2249..faf664b 100644 --- a/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.csproj +++ b/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes/Frends.HTTP.SendAndReceiveBytes.csproj @@ -1,8 +1,8 @@  - net6.0 - 1.3.0 + net6.0 + 1.4.0 Frends Frends Frends @@ -28,4 +28,4 @@ - \ No newline at end of file + diff --git a/Frends.HTTP.SendBytes/CHANGELOG.md b/Frends.HTTP.SendBytes/CHANGELOG.md index f633c78..15b2e3f 100644 --- a/Frends.HTTP.SendBytes/CHANGELOG.md +++ b/Frends.HTTP.SendBytes/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.5.0] - 2026-03-02 +### Added +- Added CertificateStoreLocation option to allow selection between CurrentUser and LocalMachine certificate stores when using certificate authentication. + ## [1.4.0] - 2025-10-10 ### Changed - Changed the return type of SendBytes from Task to Task diff --git a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.Tests/UnitTests.cs b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.Tests/UnitTests.cs index 7e29851..2955928 100644 --- a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.Tests/UnitTests.cs +++ b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.Tests/UnitTests.cs @@ -11,6 +11,7 @@ using System.Text; using Method = Frends.HTTP.SendBytes.Definitions.Method; using System.Net; +using NUnit.Framework; using NUnit.Framework.Legacy; namespace Frends.HTTP.SendBytes.Tests; @@ -29,7 +30,8 @@ public void TestInitialize() HTTP.ClientFactory = new MockHttpClientFactory(_mockHttpMessageHandler); } - private static Input GetInputParams(Method method = Method.POST, string url = _basePath, byte[] message = null, params Header[] headers) + private static Input GetInputParams(Method method = Method.POST, string url = _basePath, byte[] message = null, + params Header[] headers) { return new Input { @@ -40,6 +42,24 @@ private static Input GetInputParams(Method method = Method.POST, string url = _b }; } + [TestCase(CertificateStoreLocation.CurrentUser, "current user")] + [TestCase(CertificateStoreLocation.LocalMachine, "local machine")] + public void CorrectStoreSearched(CertificateStoreLocation storeLocation, string storeLocationText) + { + var handler = new HttpClientHandler(); + var options = new Options + { + Authentication = Authentication.ClientCertificate, + ClientCertificateSource = CertificateSource.CertificateStore, + CertificateStoreLocation = storeLocation, + CertificateThumbprint = "InvalidThumbprint", + }; + var ex = Assert.Throws(() => handler.SetHandlerSettingsBasedOnOptions(options)); + Assert.That(ex, Is.Not.Null); + Assert.That(ex.Message.Contains( + $"Certificate with thumbprint: 'INVALIDTHUMBPRINT' not found in {storeLocationText} cert store.")); + } + [TestMethod] public void RequestShouldThrowExceptionIfUrlEmpty() { @@ -50,7 +70,11 @@ public void RequestShouldThrowExceptionIfUrlEmpty() Headers = new Header[0], ContentBytes = new byte[0] }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = true }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = true + }; var ex = Assert.ThrowsAsync(async () => await HTTP.SendBytes(input, options, CancellationToken.None)); @@ -88,8 +112,17 @@ public void RequestShouldAddClientCertificate() [TestMethod] public async Task HttpSendBytesReturnShoulReturnEmpty() { - var input = new Input { Method = Method.POST, Url = "http://localhost:9191/endpoint", Headers = new Header[0], ContentBytes = new byte[0] }; - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var input = new Input + { + Method = Method.POST, + Url = "http://localhost:9191/endpoint", + Headers = new Header[0], + ContentBytes = new byte[0] + }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; _mockHttpMessageHandler.When(input.Url) .Respond("application/octet-stream", String.Empty); @@ -102,12 +135,19 @@ public async Task HttpSendBytesReturnShoulReturnEmpty() public async Task RequestTestPostWithParameters() { var input = GetInputParams(url: "http://localhost:9191/endpoint?foo=bar&bar=foo"); - var options = new Options { ConnectionTimeoutSeconds = 60 }; + var options = new Options + { + ConnectionTimeoutSeconds = 60 + }; var dict = new Dictionary() { - {"foo", "bar"}, - {"bar", "foo"} + { + "foo", "bar" + }, + { + "bar", "foo" + } }; _mockHttpMessageHandler.When(input.Url).WithQueryString(dict) @@ -133,7 +173,11 @@ public void RequestShouldThrowExceptionIfOptionIsSet() Headers = new Header[0], ContentBytes = new byte[0] }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = true }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = true + }; Assert.ThrowsAsync(async () => await HTTP.SendBytes(input, options, CancellationToken.None)); @@ -152,7 +196,11 @@ public async Task RequestShouldNotThrowIfOptionIsNotSet() Headers = new Header[0], ContentBytes = new byte[0] }; - var options = new Options { ConnectionTimeoutSeconds = 60, ThrowExceptionOnErrorResponse = false }; + var options = new Options + { + ConnectionTimeoutSeconds = 60, + ThrowExceptionOnErrorResponse = false + }; var result = (dynamic)await HTTP.SendBytes(input, options, CancellationToken.None); @@ -221,7 +269,14 @@ public async Task AuthorizationHeaderShouldOverrideOption() { Method = Method.PUT, Url = "http://localhost:9191/endpoint", - Headers = new[] { new Header() { Name = "Authorization", Value = "Basic fooToken" } }, + Headers = new[] + { + new Header() + { + Name = "Authorization", + Value = "Basic fooToken" + } + }, ContentBytes = new byte[0] }; var options = new Options @@ -250,16 +305,29 @@ public async Task PatchShouldComeThrough() Url = "http://localhost:9191/data", Headers = new[] { - new Header {Name = "Content-Type", Value = "text/plain; charset=utf-8"}, - new Header() {Name = "Content-Length", Value = bytes.Length.ToString()} + new Header + { + Name = "Content-Type", + Value = "text/plain; charset=utf-8" }, + new Header() + { + Name = "Content-Length", + Value = bytes.Length.ToString() + } + }, ContentBytes = bytes }; var options = new Options - { ConnectionTimeoutSeconds = 60, Authentication = Authentication.OAuth, Token = "fooToken" }; + { + ConnectionTimeoutSeconds = 60, + Authentication = Authentication.OAuth, + Token = "fooToken" + }; - _mockHttpMessageHandler.Expect(HttpMethod.Patch, input.Url).WithHeaders("Content-Type", "text/plain; charset=utf-8").WithContent(expectedString) + _mockHttpMessageHandler.Expect(HttpMethod.Patch, input.Url) + .WithHeaders("Content-Type", "text/plain; charset=utf-8").WithContent(expectedString) .Respond("text/plain", "foo åäö"); var result = (Result)await HTTP.SendBytes(input, options, CancellationToken.None); @@ -277,9 +345,9 @@ public MockHttpClientFactory(MockHttpMessageHandler mockHttpMessageHandler) { _mockHttpMessageHandler = mockHttpMessageHandler; } + public HttpClient CreateClient(Options options) { return _mockHttpMessageHandler.ToHttpClient(); - } } diff --git a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/CertificateStoreLocation.cs b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/CertificateStoreLocation.cs new file mode 100644 index 0000000..56069bd --- /dev/null +++ b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/CertificateStoreLocation.cs @@ -0,0 +1,16 @@ +namespace Frends.HTTP.SendBytes.Definitions; + +/// +/// Certificate store location. +/// +public enum CertificateStoreLocation +{ + /// + /// The X.509 certificate store assigned to the current user. + /// + CurrentUser, + /// + /// The X.509 certificate store assigned to the local machine. + /// + LocalMachine +} diff --git a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/Options.cs b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/Options.cs index 42ca4ab..9479810 100644 --- a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/Options.cs +++ b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Definitions/Options.cs @@ -81,7 +81,16 @@ public class Options public string CertificateThumbprint { get; set; } /// - /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source + /// Applicable only when Certificate Source is "CertificateStore". + /// Store location for the certificate. + /// + /// CurrentUser + [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] + [DefaultValue(CertificateStoreLocation.CurrentUser)] + public CertificateStoreLocation CertificateStoreLocation { get; set; } = CertificateStoreLocation.CurrentUser; + + /// + /// Should the entire certificate chain be loaded from the certificate store and included in the request. Only valid when using Certificate Store as the Certificate Source /// /// true [UIHint(nameof(Authentication), "", Authentication.ClientCertificate)] diff --git a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Extensions.cs b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Extensions.cs index 2602daa..93e6276 100644 --- a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Extensions.cs +++ b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Extensions.cs @@ -19,18 +19,22 @@ internal static void SetHandlerSettingsBasedOnOptions(this HttpClientHandler han { case Authentication.WindowsIntegratedSecurity: handler.UseDefaultCredentials = true; + break; case Authentication.WindowsAuthentication: var domainAndUserName = options.Username.Split('\\'); + if (domainAndUserName.Length != 2) throw new ArgumentException( $@"Username needs to be 'domain\username' now it was '{options.Username}'"); handler.Credentials = new NetworkCredential(domainAndUserName[1], options.Password, domainAndUserName[0]); + break; case Authentication.ClientCertificate: handler.ClientCertificates.AddRange(GetCertificates(options)); + break; } @@ -59,13 +63,16 @@ private static X509Certificate[] GetCertificates(Options options) { case CertificateSource.CertificateStore: var thumbprint = options.CertificateThumbprint; - certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate); + certificates = GetCertificatesFromStore(thumbprint, options.LoadEntireChainForCertificate, options.CertificateStoreLocation); + break; case CertificateSource.File: certificates = GetCertificatesFromFile(options.ClientCertificateFilePath, options.ClientCertificateKeyPhrase); + break; case CertificateSource.String: certificates = GetCertificatesFromString(options.ClientCertificateInBase64, options.ClientCertificateKeyPhrase); + break; default: throw new Exception("Unsupported Certificate source"); @@ -93,8 +100,8 @@ private static X509Certificate2[] LoadCertificatesFromBytes(byte[] certificateBy { collection.Import(certificateBytes, null, X509KeyStorageFlags.PersistKeySet); } - return collection.Cast().OrderByDescending(c => c.HasPrivateKey).ToArray(); + return collection.Cast().OrderByDescending(c => c.HasPrivateKey).ToArray(); } private static X509Certificate2[] GetCertificatesFromFile(string clientCertificateFilePath, string keyPhrase) @@ -103,17 +110,25 @@ private static X509Certificate2[] GetCertificatesFromFile(string clientCertifica } private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, - bool loadEntireChain) + bool loadEntireChain, CertificateStoreLocation storeLocation) { thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + var location = storeLocation == CertificateStoreLocation.CurrentUser + ? StoreLocation.CurrentUser + : StoreLocation.LocalMachine; + var locationText = storeLocation == CertificateStoreLocation.CurrentUser + ? "current user" + : "local machine"; + + using (var store = new X509Store(StoreName.My, location)) { store.Open(OpenFlags.ReadOnly); var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (signingCert.Count == 0) { throw new FileNotFoundException( - $"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + $"Certificate with thumbprint: '{thumbprint}' not found in {locationText} cert store."); } var certificate = signingCert[0]; @@ -138,4 +153,4 @@ private static X509Certificate2[] GetCertificatesFromStore(string thumbprint, return certificates; } } -} \ No newline at end of file +} diff --git a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.csproj b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.csproj index e0f3a22..52a6588 100644 --- a/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.csproj +++ b/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes/Frends.HTTP.SendBytes.csproj @@ -1,8 +1,8 @@  - net6.0 - 1.4.0 + net6.0 + 1.5.0 Frends Frends Frends @@ -27,4 +27,4 @@ - \ No newline at end of file +