using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using SharepointToolbox.Web.Infrastructure.Auth;
namespace SharepointToolbox.Web.Services.Auth;
///
/// Creates a 2048-bit RSA self-signed certificate valid for two years, persists its private
/// key (PFX) through , and returns the public certificate so
/// the caller can attach it to the Entra app registration as a sign-in credential.
///
public class CertProvisioningService : ICertProvisioningService
{
private readonly IAppOnlyCertStore _certStore;
public CertProvisioningService(IAppOnlyCertStore certStore) { _certStore = certStore; }
public async Task GenerateAndStoreAsync(
string profileId, string subjectName, CancellationToken ct = default)
{
// X.509 validity is stored at whole-second precision (ASN.1 has no sub-second field).
// Truncate here so the keyCredential start/endDateTime we send to Graph match the
// certificate's embedded validity exactly — otherwise the JSON endDateTime carries
// a fractional second that lands *after* the cert's NotAfter and Graph rejects it
// with KeyCredentialsInvalidEndDate.
var notBefore = TruncateToSecond(DateTimeOffset.UtcNow.AddMinutes(-5));
var notAfter = notBefore.AddYears(2);
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(
$"CN={Sanitize(subjectName)}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
using var cert = req.CreateSelfSigned(notBefore, notAfter);
// Transient password only protects the in-memory PFX handoff to the store, which
// re-exports it password-less and encrypts at rest with Data Protection.
var transientPwd = Convert.ToBase64String(RandomNumberGenerator.GetBytes(24));
var pfxBytes = cert.Export(X509ContentType.Pkcs12, transientPwd);
var thumbprint = await _certStore.SaveAsync(profileId, pfxBytes, transientPwd, ct);
var publicBase64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert));
return new CertProvisioningResult(thumbprint, publicBase64, notBefore, notAfter);
}
private static DateTimeOffset TruncateToSecond(DateTimeOffset value) =>
new(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond), value.Offset);
// CN cannot contain characters that break the X.500 distinguished name.
private static string Sanitize(string name)
{
var cleaned = name.Replace(",", " ").Replace("=", " ").Replace("\"", " ").Trim();
return string.IsNullOrEmpty(cleaned) ? "SP Toolbox" : cleaned;
}
}