Skip to content

Instantly share code, notes, and snippets.

@swaters86
Created February 20, 2026 19:40
Show Gist options
  • Select an option

  • Save swaters86/adf07dd2808a8819893af8f1e6ade6a0 to your computer and use it in GitHub Desktop.

Select an option

Save swaters86/adf07dd2808a8819893af8f1e6ade6a0 to your computer and use it in GitHub Desktop.
// 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