Created
February 20, 2026 20:34
-
-
Save swaters86/ab57f79cf4c113d61478b56db9f4fcdf to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // MtlsEnrollmentClient.Net10.cs | |
| // .NET 10 Worker Service helper for enrollment + mTLS API calls | |
| // | |
| // Implements: | |
| // - EnsureClientCertificateInstalledAsync(enrollmentCode) | |
| // - CreateMtlsHttpClient() | |
| // - TryRenewCertificateAsync() | |
| // | |
| // Notes: | |
| // - Looks up cert in LocalMachine\My using a subject marker (e.g. "OU=tenant_demo_001"). | |
| // - For production, store and load by thumbprint (more precise) after first enrollment. | |
| // - Do NOT disable server certificate validation in production. | |
| using System.Net.Http; | |
| using System.Security.Authentication; | |
| using System.Security.Cryptography.X509Certificates; | |
| using System.Text; | |
| using System.Text.Json; | |
| public sealed class MtlsEnrollmentClient | |
| { | |
| private readonly Uri _baseApi; | |
| private readonly string _certSubjectContains; | |
| private readonly TimeSpan _timeout; | |
| private readonly JsonSerializerOptions _jsonOptions; | |
| public string? CurrentThumbprint { get; private set; } | |
| /// <param name="baseApiUrl">Base API URL, e.g. https://api.yourdomain.com/</param> | |
| /// <param name="certSubjectContains">Stable subject marker to find cert, e.g. "OU=tenant_demo_001"</param> | |
| /// <param name="timeout">Optional HTTP timeout (default 30s)</param> | |
| public MtlsEnrollmentClient(string baseApiUrl, string certSubjectContains, TimeSpan? timeout = null) | |
| { | |
| if (string.IsNullOrWhiteSpace(baseApiUrl)) | |
| throw new ArgumentNullException(nameof(baseApiUrl)); | |
| if (string.IsNullOrWhiteSpace(certSubjectContains)) | |
| throw new ArgumentNullException(nameof(certSubjectContains)); | |
| _baseApi = new Uri(baseApiUrl.TrimEnd('/') + "/"); | |
| _certSubjectContains = certSubjectContains; | |
| _timeout = timeout ?? TimeSpan.FromSeconds(30); | |
| _jsonOptions = new JsonSerializerOptions | |
| { | |
| PropertyNameCaseInsensitive = true | |
| }; | |
| } | |
| /// <summary> | |
| /// Ensures a client certificate exists in LocalMachine\My. | |
| /// If not found, uses enrollment code to call /enroll and installs returned PFX. | |
| /// </summary> | |
| public async Task EnsureClientCertificateInstalledAsync(string? enrollmentCode, CancellationToken ct = default) | |
| { | |
| var existing = FindExistingClientCert(); | |
| if (existing is not null) | |
| { | |
| CurrentThumbprint = existing.Thumbprint; | |
| return; | |
| } | |
| if (string.IsNullOrWhiteSpace(enrollmentCode)) | |
| throw new InvalidOperationException("No client certificate installed and enrollmentCode was not provided."); | |
| var request = new | |
| { | |
| enrollmentCode = enrollmentCode.Trim(), | |
| machineName = Environment.MachineName | |
| }; | |
| var enrollResponse = await PostJsonAsync<EnrollResponse>( | |
| new Uri(_baseApi, "enroll"), | |
| request, | |
| handler: CreateHandlerNoClientCert(), | |
| ct: ct); | |
| if (enrollResponse is null || string.IsNullOrWhiteSpace(enrollResponse.PfxBase64)) | |
| throw new InvalidOperationException("Enrollment failed: empty or invalid response."); | |
| byte[] pfxBytes = Convert.FromBase64String(enrollResponse.PfxBase64); | |
| InstallPfxToLocalMachineMy(pfxBytes, enrollResponse.PfxPassword ?? string.Empty); | |
| var installed = FindExistingClientCert(); | |
| if (installed is null) | |
| throw new InvalidOperationException(@"Enrollment succeeded but certificate was not found in LocalMachine\My."); | |
| CurrentThumbprint = installed.Thumbprint; | |
| } | |
| /// <summary> | |
| /// Creates an HttpClient that presents the installed client certificate (mTLS). | |
| /// Caller owns disposal of the returned HttpClient. | |
| /// </summary> | |
| public HttpClient CreateMtlsHttpClient() | |
| { | |
| var cert = FindExistingClientCert() | |
| ?? throw new InvalidOperationException("Client certificate not installed. Call EnsureClientCertificateInstalledAsync first."); | |
| CurrentThumbprint = cert.Thumbprint; | |
| var handler = new HttpClientHandler | |
| { | |
| SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, | |
| ClientCertificateOptions = ClientCertificateOption.Manual | |
| }; | |
| handler.ClientCertificates.Add(cert); | |
| // DO NOT do this in production: | |
| // handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; | |
| var client = new HttpClient(handler) | |
| { | |
| BaseAddress = _baseApi, | |
| Timeout = _timeout | |
| }; | |
| client.DefaultRequestHeaders.UserAgent.ParseAdd("YourWorkerService/1.0"); | |
| return client; | |
| } | |
| /// <summary> | |
| /// Renews the client certificate via POST /cert/renew when close to expiry. | |
| /// Returns true if cert is valid/renewed, false if renewal failed. | |
| /// </summary> | |
| /// <param name="renewWhenWithinDays">Renew if cert expires within this many days (default 14)</param> | |
| public async Task<bool> TryRenewCertificateAsync(int renewWhenWithinDays = 14, CancellationToken ct = default) | |
| { | |
| var cert = FindExistingClientCert(); | |
| if (cert is null) | |
| return false; | |
| if (cert.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddDays(renewWhenWithinDays)) | |
| { | |
| CurrentThumbprint = cert.Thumbprint; | |
| return true; // not due yet | |
| } | |
| using var http = CreateMtlsHttpClient(); | |
| using var content = new StringContent("{}", Encoding.UTF8, "application/json"); | |
| using var response = await http.PostAsync("cert/renew", content, ct); | |
| if (!response.IsSuccessStatusCode) | |
| return false; | |
| var responseText = await response.Content.ReadAsStringAsync(ct); | |
| var renewResponse = JsonSerializer.Deserialize<EnrollResponse>(responseText, _jsonOptions); | |
| if (renewResponse is null || string.IsNullOrWhiteSpace(renewResponse.PfxBase64)) | |
| return false; | |
| byte[] pfxBytes = Convert.FromBase64String(renewResponse.PfxBase64); | |
| InstallPfxToLocalMachineMy(pfxBytes, renewResponse.PfxPassword ?? string.Empty); | |
| var newCert = FindExistingClientCert(); | |
| if (newCert is not null) | |
| CurrentThumbprint = newCert.Thumbprint; | |
| return true; | |
| } | |
| // ---------------- Internals ---------------- | |
| private HttpClientHandler CreateHandlerNoClientCert() | |
| { | |
| return new HttpClientHandler | |
| { | |
| SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 | |
| }; | |
| } | |
| /// <summary> | |
| /// Finds the newest valid certificate in LocalMachine\My matching the configured subject marker. | |
| /// </summary> | |
| private X509Certificate2? FindExistingClientCert() | |
| { | |
| using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine); | |
| store.Open(OpenFlags.ReadOnly); | |
| return store.Certificates | |
| .OfType<X509Certificate2>() | |
| .Where(c => | |
| c.HasPrivateKey && | |
| !string.IsNullOrWhiteSpace(c.Subject) && | |
| c.Subject.Contains(_certSubjectContains, StringComparison.OrdinalIgnoreCase) && | |
| c.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddMinutes(5)) | |
| .OrderByDescending(c => c.NotAfter) | |
| .FirstOrDefault(); | |
| } | |
| /// <summary> | |
| /// Installs the returned PFX into LocalMachine\My. Private key persists on machine. | |
| /// </summary> | |
| private static void InstallPfxToLocalMachineMy(byte[] pfxBytes, string password) | |
| { | |
| // NOTE: | |
| // - MachineKeySet: store private key in machine context | |
| // - PersistKeySet: key remains after X509Certificate2 object disposal | |
| // - EphemeralKeySet is NOT wanted here because we need the cert for future calls | |
| // | |
| // If the API issues a cert with an exportable private key, Windows may still permit export. | |
| // For stronger controls, use CSR-based enrollment so the private key is generated locally. | |
| using var cert = new X509Certificate2( | |
| pfxBytes, | |
| password, | |
| X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet); | |
| using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine); | |
| store.Open(OpenFlags.ReadWrite); | |
| store.Add(cert); | |
| } | |
| private async Task<T?> PostJsonAsync<T>(Uri url, object body, HttpClientHandler handler, CancellationToken ct) where T : class | |
| { | |
| using var client = new HttpClient(handler) { Timeout = _timeout }; | |
| string json = JsonSerializer.Serialize(body); | |
| using var content = new StringContent(json, Encoding.UTF8, "application/json"); | |
| using var response = await client.PostAsync(url, content, ct); | |
| string responseText = await response.Content.ReadAsStringAsync(ct); | |
| if (!response.IsSuccessStatusCode) | |
| throw new InvalidOperationException($"HTTP {(int)response.StatusCode} {response.ReasonPhrase} :: {responseText}"); | |
| return JsonSerializer.Deserialize<T>(responseText, _jsonOptions); | |
| } | |
| // ---------------- DTOs (match API JSON camelCase) ---------------- | |
| private sealed class EnrollResponse | |
| { | |
| public string? TenantId { get; set; } | |
| public string? Thumbprint { get; set; } | |
| public DateTime? NotAfterUtc { get; set; } | |
| public string? PfxBase64 { get; set; } | |
| public string? PfxPassword { get; set; } | |
| } | |
| } | |
| /* | |
| ======================================== | |
| EXAMPLE USAGE IN A .NET 10 WORKER SERVICE | |
| ======================================== | |
| 1) appsettings.json (example) | |
| { | |
| "ApiSettings": { | |
| "BaseUrl": "https://api.yourdomain.com/", | |
| "EnrollmentCode": "ABC-123-ENROLL", | |
| "CertSubjectContains": "OU=tenant_demo_001" | |
| } | |
| } | |
| 2) Worker.cs (BackgroundService) usage example: | |
| using Microsoft.Extensions.Configuration; | |
| using Microsoft.Extensions.Hosting; | |
| using Microsoft.Extensions.Logging; | |
| public sealed class Worker : BackgroundService | |
| { | |
| private readonly ILogger<Worker> _logger; | |
| private readonly IConfiguration _config; | |
| private MtlsEnrollmentClient? _mtls; | |
| public Worker(ILogger<Worker> logger, IConfiguration config) | |
| { | |
| _logger = logger; | |
| _config = config; | |
| } | |
| protected override async Task ExecuteAsync(CancellationToken stoppingToken) | |
| { | |
| string baseUrl = _config["ApiSettings:BaseUrl"]!; | |
| string? enrollmentCode = _config["ApiSettings:EnrollmentCode"]; // can be removed after first install | |
| string subjectMarker = _config["ApiSettings:CertSubjectContains"]!; | |
| _mtls = new MtlsEnrollmentClient(baseUrl, subjectMarker); | |
| // Ensure cert exists (first run enrolls; later runs reuse installed cert) | |
| await _mtls.EnsureClientCertificateInstalledAsync(enrollmentCode, stoppingToken); | |
| _logger.LogInformation("mTLS cert ready. Thumbprint: {Thumbprint}", _mtls.CurrentThumbprint); | |
| while (!stoppingToken.IsCancellationRequested) | |
| { | |
| try | |
| { | |
| // Periodic renewal check (no-op if not near expiry) | |
| bool renewOk = await _mtls.TryRenewCertificateAsync(ct: stoppingToken); | |
| if (!renewOk) | |
| { | |
| _logger.LogWarning("Certificate renewal check failed."); | |
| } | |
| // Example protected API call | |
| using var http = _mtls.CreateMtlsHttpClient(); | |
| using var resp = await http.GetAsync("phi/ping", stoppingToken); | |
| string body = await resp.Content.ReadAsStringAsync(stoppingToken); | |
| _logger.LogInformation("GET /phi/ping => {Status} {Body}", (int)resp.StatusCode, body); | |
| } | |
| catch (Exception ex) | |
| { | |
| _logger.LogError(ex, "Error during mTLS API call."); | |
| } | |
| await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); | |
| } | |
| } | |
| } | |
| 3) Program.cs (Worker) typical registration: | |
| using Microsoft.Extensions.DependencyInjection; | |
| using Microsoft.Extensions.Hosting; | |
| Host.CreateDefaultBuilder(args) | |
| .UseWindowsService() // optional if running as Windows Service | |
| .ConfigureServices(services => | |
| { | |
| services.AddHostedService<Worker>(); | |
| }) | |
| .Build() | |
| .Run(); | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment