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