Created
February 20, 2026 19:40
-
-
Save swaters86/adf07dd2808a8819893af8f1e6ade6a0 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
| // Program.cs - .NET 10 Minimal API (Aspire-hosted) | |
| // Implements: | |
| // - POST /enroll (enrollment code -> returns client cert PFX + password) | |
| // - POST /cert/renew (mTLS -> rotates/renews cert) | |
| // - GET /phi/ping (mTLS protected example) | |
| // Notes: | |
| // - This assumes TLS terminates at this API (Kestrel) so HttpContext.Connection.ClientCertificate is present. | |
| // - Replace the in-memory stores with DB/Dapper for real usage (tenant mapping, enrollment codes, revocation). | |
| // - For production: run your own internal CA OR a managed PKI; add revocation checking & chain validation. | |
| using System.Collections.Concurrent; | |
| using System.Security.Claims; | |
| using System.Security.Cryptography; | |
| using System.Security.Cryptography.X509Certificates; | |
| using Microsoft.AspNetCore.Authentication; | |
| using Microsoft.AspNetCore.Authorization; | |
| using Microsoft.AspNetCore.Server.Kestrel.Https; | |
| using Microsoft.Extensions.Options; | |
| var builder = WebApplication.CreateBuilder(args); | |
| // -------------------- Kestrel mTLS: require client certificates -------------------- | |
| builder.WebHost.ConfigureKestrel(options => | |
| { | |
| options.ConfigureHttpsDefaults(https => | |
| { | |
| https.ClientCertificateMode = ClientCertificateMode.RequireCertificate; | |
| // Revocation checking requires chain trust & network access to CRL/OCSP. | |
| // You can set this to true when your PKI supports it reliably. | |
| https.CheckCertificateRevocation = false; | |
| // Optional: limit TLS versions/ciphers at the hosting layer as well. | |
| // https.SslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13; | |
| }); | |
| }); | |
| // -------------------- AuthN/AuthZ using client certificate -------------------- | |
| // We implement an authentication handler that: | |
| // 1) reads HttpContext.Connection.ClientCertificate | |
| // 2) verifies it's known (thumbprint mapped to tenant) | |
| // 3) creates ClaimsPrincipal with tenant_id | |
| builder.Services.AddAuthentication("mTLS") | |
| .AddScheme<AuthenticationSchemeOptions, MtlsAuthHandler>("mTLS", _ => { }); | |
| builder.Services.AddAuthorization(options => | |
| { | |
| options.DefaultPolicy = new AuthorizationPolicyBuilder() | |
| .AddAuthenticationSchemes("mTLS") | |
| .RequireAuthenticatedUser() | |
| .Build(); | |
| }); | |
| // -------------------- In-memory stores (replace with DB) -------------------- | |
| // Enrollment codes: code -> tenantId | |
| // Thumbprints: thumbprint -> tenantId | |
| // Revoked: thumbprint -> revokedAt | |
| var enrollmentCodes = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase); | |
| var certThumbprintsToTenant = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase); | |
| var revokedThumbprints = new ConcurrentDictionary<string, DateTimeOffset>(StringComparer.OrdinalIgnoreCase); | |
| // Seed an example enrollment code (replace with DB-generated short-lived codes) | |
| enrollmentCodes["ABC-123-ENROLL"] = "tenant_demo_001"; | |
| var app = builder.Build(); | |
| app.UseHttpsRedirection(); | |
| app.UseAuthentication(); | |
| app.UseAuthorization(); | |
| // -------------------- Enrollment endpoint -------------------- | |
| // Customer installs MSI, enters code. Service calls this once to obtain a client cert. | |
| // Response: base64(PFX) + password, plus thumbprint and expiry. | |
| // | |
| // Security hardening suggestions: | |
| // - Make enrollment codes one-time + short-lived | |
| // - Rate-limit this endpoint | |
| // - Log enrollments (tenant, machine name, time, IP) | |
| // - Consider requiring additional installer secret or allowlist by tenant | |
| app.MapPost("/enroll", (EnrollRequest req) => | |
| { | |
| if (string.IsNullOrWhiteSpace(req.EnrollmentCode)) | |
| return Results.BadRequest("EnrollmentCode is required."); | |
| if (!enrollmentCodes.TryGetValue(req.EnrollmentCode.Trim(), out var tenantId)) | |
| return Results.Unauthorized(); | |
| // OPTIONAL: one-time use. If you want multi-activations, remove this line. | |
| enrollmentCodes.TryRemove(req.EnrollmentCode.Trim(), out _); | |
| // Generate a short random password for the PFX. | |
| // The PFX is transmitted over TLS; still treat it as sensitive. | |
| var pfxPassword = Convert.ToBase64String(RandomNumberGenerator.GetBytes(24)); | |
| // Create a client certificate (self-signed for demo). | |
| // In production, issue from a real CA so chain validation is meaningful. | |
| // Subject includes tenant & device info for audit. | |
| var (pfxBytes, cert) = CertificateIssuer.CreateClientPfx( | |
| subjectCommonName: $"client-{tenantId}-{Sanitize(req.MachineName)}", | |
| tenantId: tenantId, | |
| notAfterDays: 90, | |
| pfxPassword: pfxPassword | |
| ); | |
| // Save mapping: thumbprint -> tenantId | |
| certThumbprintsToTenant[cert.Thumbprint!] = tenantId; | |
| var resp = new EnrollResponse( | |
| TenantId: tenantId, | |
| Thumbprint: cert.Thumbprint!, | |
| NotAfterUtc: cert.NotAfter.ToUniversalTime(), | |
| PfxBase64: Convert.ToBase64String(pfxBytes), | |
| PfxPassword: pfxPassword | |
| ); | |
| return Results.Ok(resp); | |
| }) | |
| .AllowAnonymous(); | |
| // -------------------- Renewal endpoint -------------------- | |
| // Requires existing mTLS cert. Issues a new cert and (optionally) revokes old one. | |
| app.MapPost("/cert/renew", [Authorize] (HttpContext ctx) => | |
| { | |
| var oldCert = ctx.Connection.ClientCertificate; | |
| if (oldCert == null) | |
| return Results.Unauthorized(); | |
| var oldThumb = oldCert.Thumbprint ?? ""; | |
| if (revokedThumbprints.ContainsKey(oldThumb)) | |
| return Results.Unauthorized(); | |
| if (!certThumbprintsToTenant.TryGetValue(oldThumb, out var tenantId)) | |
| return Results.Unauthorized(); | |
| // Issue new cert | |
| var pfxPassword = Convert.ToBase64String(RandomNumberGenerator.GetBytes(24)); | |
| var (pfxBytes, newCert) = CertificateIssuer.CreateClientPfx( | |
| subjectCommonName: $"client-{tenantId}-{Sanitize(Environment.MachineName)}", | |
| tenantId: tenantId, | |
| notAfterDays: 90, | |
| pfxPassword: pfxPassword | |
| ); | |
| // Map new thumbprint -> tenant | |
| certThumbprintsToTenant[newCert.Thumbprint!] = tenantId; | |
| // Revoke old cert (optional but recommended on rotation) | |
| revokedThumbprints[oldThumb] = DateTimeOffset.UtcNow; | |
| return Results.Ok(new EnrollResponse( | |
| TenantId: tenantId, | |
| Thumbprint: newCert.Thumbprint!, | |
| NotAfterUtc: newCert.NotAfter.ToUniversalTime(), | |
| PfxBase64: Convert.ToBase64String(pfxBytes), | |
| PfxPassword: pfxPassword | |
| )); | |
| }); | |
| // -------------------- Example PHI endpoint -------------------- | |
| app.MapGet("/phi/ping", [Authorize] (ClaimsPrincipal user) => | |
| { | |
| // Enforce tenant isolation via claim from mTLS auth handler. | |
| var tenantId = user.FindFirstValue("tenant_id") ?? "unknown"; | |
| return Results.Ok(new | |
| { | |
| ok = true, | |
| tenant = tenantId, | |
| utc = DateTimeOffset.UtcNow | |
| }); | |
| }); | |
| app.Run(); | |
| // -------------------- DTOs -------------------- | |
| record EnrollRequest(string EnrollmentCode, string? MachineName); | |
| record EnrollResponse(string TenantId, string Thumbprint, DateTime NotAfterUtc, string PfxBase64, string PfxPassword); | |
| // -------------------- Auth handler -------------------- | |
| sealed class MtlsAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions> | |
| { | |
| private readonly ConcurrentDictionary<string, string> _thumbToTenant; | |
| private readonly ConcurrentDictionary<string, DateTimeOffset> _revoked; | |
| public MtlsAuthHandler( | |
| IOptionsMonitor<AuthenticationSchemeOptions> options, | |
| ILoggerFactory logger, | |
| System.Text.Encodings.Web.UrlEncoder encoder, | |
| ISystemClock clock, | |
| ConcurrentDictionary<string, string> thumbToTenant, | |
| ConcurrentDictionary<string, DateTimeOffset> revoked) | |
| : base(options, logger, encoder, clock) | |
| { | |
| _thumbToTenant = thumbToTenant; | |
| _revoked = revoked; | |
| } | |
| protected override Task<AuthenticateResult> HandleAuthenticateAsync() | |
| { | |
| var cert = Context.Connection.ClientCertificate; | |
| if (cert == null) | |
| return Task.FromResult(AuthenticateResult.Fail("Client certificate required.")); | |
| if (string.IsNullOrWhiteSpace(cert.Thumbprint)) | |
| return Task.FromResult(AuthenticateResult.Fail("Invalid client certificate.")); | |
| var thumb = cert.Thumbprint; | |
| if (_revoked.ContainsKey(thumb)) | |
| return Task.FromResult(AuthenticateResult.Fail("Certificate revoked.")); | |
| if (!_thumbToTenant.TryGetValue(thumb, out var tenantId)) | |
| return Task.FromResult(AuthenticateResult.Fail("Unknown certificate.")); | |
| // Optional: add chain validation here if issued by a trusted CA. | |
| // Keep in mind self-signed demo certs will not pass a strict chain check. | |
| var claims = new[] | |
| { | |
| new Claim("tenant_id", tenantId), | |
| new Claim("cert_thumbprint", thumb), | |
| }; | |
| var identity = new ClaimsIdentity(claims, Scheme.Name); | |
| var principal = new ClaimsPrincipal(identity); | |
| var ticket = new AuthenticationTicket(principal, Scheme.Name); | |
| return Task.FromResult(AuthenticateResult.Success(ticket)); | |
| } | |
| } | |
| // Register in-memory stores into DI so MtlsAuthHandler can receive them. | |
| builder.Services.AddSingleton(enrollmentCodes); | |
| builder.Services.AddSingleton(certThumbprintsToTenant); | |
| builder.Services.AddSingleton(revokedThumbprints); | |
| // -------------------- Certificate issuer helper -------------------- | |
| static class CertificateIssuer | |
| { | |
| public static (byte[] pfxBytes, X509Certificate2 certPublic) CreateClientPfx( | |
| string subjectCommonName, | |
| string tenantId, | |
| int notAfterDays, | |
| string pfxPassword) | |
| { | |
| using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); | |
| var subject = new X500DistinguishedName($"CN={subjectCommonName}, O=YourCompany, OU={tenantId}"); | |
| var req = new CertificateRequest(subject, ecdsa, HashAlgorithmName.SHA256); | |
| // Client Authentication EKU | |
| req.CertificateExtensions.Add( | |
| new X509EnhancedKeyUsageExtension( | |
| new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") }, // ClientAuth | |
| critical: true)); | |
| // Basic constraints: not a CA | |
| req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); | |
| req.CertificateExtensions.Add(new X509KeyUsageExtension( | |
| X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); | |
| req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); | |
| var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5); | |
| var notAfter = DateTimeOffset.UtcNow.AddDays(notAfterDays); | |
| using var cert = req.CreateSelfSigned(notBefore, notAfter); | |
| // Export as PFX including private key | |
| var pfxBytes = cert.Export(X509ContentType.Pfx, pfxPassword); | |
| // Return a public view (no private key) for mapping/logging | |
| var certPublic = new X509Certificate2(cert.Export(X509ContentType.Cert)); | |
| return (pfxBytes, certPublic); | |
| } | |
| } | |
| // -------------------- Helpers -------------------- | |
| static string Sanitize(string? s) | |
| { | |
| if (string.IsNullOrWhiteSpace(s)) return "unknown"; | |
| var chars = s.Where(ch => char.IsLetterOrDigit(ch) || ch == '-' || ch == '_').ToArray(); | |
| return new string(chars).Trim(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment