Skip to content

Conversation

@leknesh
Copy link
Contributor

@leknesh leknesh commented Nov 26, 2025

Added functionality for retrieving private key from PEM file in certificate store, and related tests. Integration tests require access to cert store.

I was a bit unsure how to hook up the functionality when it comes to DI, ended up with adding optional extension method that must be called when pem is relevant. Not sure if this is the best way, there's probably more elegant ways of connecting the dots here.

Sample code will be added in a separate PR

RelatesTo #313

@leknesh leknesh self-assigned this Nov 26, 2025
@leknesh leknesh changed the title Feature/313 client assertion pem WIP: 313 client assertion pem Dec 1, 2025
…' into feature/313-client-assertion-PEM

# Conflicts:
#	src/Fhi.Authentication.Extensions/ClientCredentials/ClientAssertionOptions.cs
#	src/Fhi.Authentication.Extensions/ClientCredentials/ClientCredentialsAssertionService.cs
#	src/Fhi.Authentication.Extensions/Tokens/ICertificateKeyHandler.cs
@leknesh leknesh changed the title WIP: 313 client assertion pem 313 client assertion pem Dec 5, 2025
Copy link
Contributor

@Kattemat Kattemat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synes det ser bra ut jeg :)

@leknesh
Copy link
Contributor Author

leknesh commented Dec 5, 2025

Gikk over til et factory pattern i denne committen: c26d5a0

# Conflicts:
#	tests/Fhi.Authentication.Extensions.UnitTests/Tokens/ClientAssertionTokenHandlerTests.cs
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@leknesh
Copy link
Contributor Author

leknesh commented Dec 17, 2025

New attempt at making a nice, expandable architecture.
First version
Second version
Current/third version

If none of these are acceptable I suggest someone rewrites this to their liking so I can learn from a final version, I won't be making yet another attempt at this point.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 22 comments.

Comments suppressed due to low confidence (1)

tests/Fhi.Auth.IntegrationTests/Setup/TestCertificateBuilder.cs:46

  • This variable is manually disposed in a finally block - consider a C# using statement as a preferable resource management technique.
    This variable is manually disposed in a finally block - consider a C# using statement as a preferable resource management technique.
            var rsa = RSA.Create(_keySize);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 27 comments.

Comments suppressed due to low confidence (2)

tests/Fhi.Auth.IntegrationTests/Setup/TestCertificateBuilder.cs:57

  • This variable is manually disposed in a finally block - consider a C# using statement as a preferable resource management technique.
                var persistable = new X509Certificate2(pfx, (string?)null, X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable);

tests/Fhi.Auth.IntegrationTests/Setup/TestCertificateBuilder.cs:63

  • This variable is manually disposed in a finally block - consider a C# using statement as a preferable resource management technique.
            var publicOnly = X509Certificate2.CreateFromPem("-----BEGIN CERTIFICATE-----\n" + Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks) + "\n-----END CERTIFICATE-----\n");

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

{
[TestFixture]
[Explicit ("Requires Windows and Local Machine Certificate Store access")]
public class CertificateKeyHandlerTests
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test class is named "CertificateKeyHandlerTests" but the field and setup reference "IPrivateKeyHandler". For consistency with the interface/class being tested, consider renaming to "PrivateKeyHandlerTests".

Suggested change
public class CertificateKeyHandlerTests
public class PrivateKeyHandlerTests

Copilot uses AI. Check for mistakes.

using (Assert.EnterMultipleScope())
{
Assert.That(jwkString, Is.Not.Null.Or.Empty);
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion uses "Is.Not.Null.Or.Empty" which is not valid NUnit syntax. It should be "Is.Not.Null.And.Not.Empty" to properly combine the null check and empty string check.

Suggested change
Assert.That(jwkString, Is.Not.Null.Or.Empty);
Assert.That(jwkString, Is.Not.Null.And.Not.Empty);

Copilot uses AI. Check for mistakes.
Comment on lines 28 to 54
private bool Load(X509Certificate2 certificate)
{
ArgumentNullException.ThrowIfNull(certificate);

// Default: only load certificates with private keys that are not expired
if (!certificate.HasPrivateKey)
{
Logger.LogDebug("Skipping certificate {Thumbprint} - no private key", certificate.Thumbprint);
return false;
}

if (certificate.NotAfter < DateTime.Now)
{
Logger.LogWarning("Skipping expired certificate {Thumbprint}, expired on {ExpiryDate}",
certificate.Thumbprint, certificate.NotAfter);
return false;
}

if (certificate.NotBefore > DateTime.Now)
{
Logger.LogWarning("Skipping certificate {Thumbprint}, not valid until {ValidFrom}",
certificate.Thumbprint, certificate.NotBefore);
return false;
}

return true;
}
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Load method is private but follows a naming convention that suggests it should be virtual and overridable (similar to the pattern mentioned in the class documentation about Azure's KeyVaultSecretManager). Consider making this method virtual and protected to allow derived classes to customize certificate filtering logic.

Copilot uses AI. Check for mistakes.

if (!string.IsNullOrEmpty(certificateThumbprint))
{
services.AddSingleton<CertificateSecretManager>();
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CertificateSecretManager is registered as a singleton without checking if it's actually needed. When using FileSecretStore (lines 155-161), the CertificateSecretManager is still registered but never used. Consider moving the CertificateSecretManager registration inside the certificate-specific block (line 140) to avoid unnecessary service registrations.

Copilot uses AI. Check for mistakes.
var app = builder.Build();

var app = builder.Build();
await app.StartAsync();
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app.StartAsync() is called but the subsequent code to actually use the service (commented out lines for getting health records) is missing. This makes the sample incomplete and non-functional. Either restore the functional code or add a comment explaining that this is intentionally incomplete pending the separate PR mentioned in the description.

Suggested change
await app.StartAsync();
await app.StartAsync();
// The host is now started and HealthRecordService can be resolved from the DI container.
// This sample intentionally stops after startup to focus on configuration of client
// credentials authentication. Example code that calls the protected API using
// HealthRecordService is provided separately.

Copilot uses AI. Check for mistakes.
Comment on lines 150 to 153
sp.GetRequiredService<IPrivateKeyHandler>(),
sp.GetRequiredService<ILogger<CertificateSecretStore>>(),
sp.GetRequiredService<CertificateSecretManager>());
});
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AddCertificateStoreKeyHandler extension method is not called in the ConfigureWithSecretStore method, but IPrivateKeyHandler is retrieved from the service provider. This will cause a runtime exception when GetRequiredService<IPrivateKeyHandler> is called because IPrivateKeyHandler was never registered. Add services.AddCertificateStoreKeyHandler() before the ISecretStore registration.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +192
if (string.IsNullOrEmpty(optionName))
throw new ArgumentException("Option name cannot be null or empty", nameof(optionName));

Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This validation throws an ArgumentException for an empty optionName, but this check is inconsistent with the other overloads which don't perform this validation. Either add this validation to all overloads for consistency, or remove it from this one and let the framework handle the validation.

Suggested change
if (string.IsNullOrEmpty(optionName))
throw new ArgumentException("Option name cannot be null or empty", nameof(optionName));

Copilot uses AI. Check for mistakes.
Comment on lines +143 to +145
services.TryAddTransient<ICertificateProvider>(_ =>
new StoreCertificateProvider(certificate.StoreLocation));
services.TryAddTransient<IPrivateKeyHandler, PrivateKeyHandler>();
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code uses TryAddTransient for ICertificateProvider and IPrivateKeyHandler, which means if they're already registered, these registrations will be ignored. However, a specific StoreLocation is being configured based on certificate.StoreLocation. If a different registration already exists with a different StoreLocation, this could lead to unexpected behavior. Consider either using AddTransient (which would throw on duplicate) or documenting this behavior clearly.

Suggested change
services.TryAddTransient<ICertificateProvider>(_ =>
new StoreCertificateProvider(certificate.StoreLocation));
services.TryAddTransient<IPrivateKeyHandler, PrivateKeyHandler>();
services.AddTransient<ICertificateProvider>(_ =>
new StoreCertificateProvider(certificate.StoreLocation));
services.AddTransient<IPrivateKeyHandler, PrivateKeyHandler>();

Copilot uses AI. Check for mistakes.
this IServiceCollection services,
CertificateStoreLocation storeLocation = CertificateStoreLocation.CurrentUser)
{
services.AddTransient<ICertificateProvider>(_ => new StoreCertificateProvider(storeLocation));
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AddCertificateStoreKeyHandler method registers both ICertificateProvider and IPrivateKeyHandler as Transient, which means a new StoreCertificateProvider instance is created for each request. Since StoreCertificateProvider is stateless and accesses the certificate store each time, consider using Singleton lifetime instead to reduce allocations and improve performance.

Suggested change
services.AddTransient<ICertificateProvider>(_ => new StoreCertificateProvider(storeLocation));
services.AddSingleton<ICertificateProvider>(_ => new StoreCertificateProvider(storeLocation));

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,171 @@
using System.Security.Cryptography;
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file name "CertificateKeyHandler.UnitTests.cs" doesn't follow the conventional test file naming pattern. It should be "PrivateKeyHandlerTests.cs" or "PrivateKeyHandler.UnitTests.cs" to match the class being tested (PrivateKeyHandler) and be consistent with other test files in the project.

Copilot uses AI. Check for mistakes.
{
if (certificate is null) throw new ArgumentNullException(nameof(certificate));

if (certificate.NotAfter < DateTime.Now)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably use DateTime.UtcNow to avoid time zone issues

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants