C# Certificate Authentication with gRPC - c#

I am trying my hand at gRPC in C# and wanted to do authentication integration with aspnet core .Net 6. I am following the tutorial on microsoft for setting up a gRPC application.
The application works with no issues until I attempt to add in the authentication. I am working with a self-signed cert, that I have added to my local user store as well as my trusted root store just in case the cert was rejected due to revocation. I've tried a lot of things to help troubleshoot the issue, but with no luck. I am not sure where to go now and would appreciate any pointers/tips.
Kestrel Web Server gRPC File
using Microsoft.AspNetCore.Authentication.Certificate;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using grpcServerTest.Services;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
namespace grpcServerTest
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Additional configuration is required to successfully run gRPC on macOS.
// For instructions on how to configure Kestrel and gRPC clients on macOS, visit https://go.microsoft.com/fwlink/?linkid=2099682
// Add services to the container.
builder.Services.AddGrpc();
//builder.Services.AddAuthorization();
builder.Services.AddAuthentication(
CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.AllowedCertificateTypes = CertificateTypes.All;
options.ValidateCertificateUse = false;
options.RevocationFlag = System.Security.Cryptography.X509Certificates.X509RevocationFlag.ExcludeRoot;
options.RevocationMode = System.Security.Cryptography.X509Certificates.X509RevocationMode.NoCheck;
options.Events = new CertificateAuthenticationEvents
{
OnChallenge = context =>
{
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
return Task.CompletedTask;
},
OnCertificateValidated = context =>
{
if (true)
{
var claims = new[]
{
new Claim(
ClaimTypes.NameIdentifier,
context.ClientCertificate.Subject,
ClaimValueTypes.String, context.Options.ClaimsIssuer),
new Claim(
ClaimTypes.Name,
context.ClientCertificate.Subject,
ClaimValueTypes.String, context.Options.ClaimsIssuer)
};
context.Principal = new ClaimsPrincipal(
new ClaimsIdentity(claims, context.Scheme.Name));
context.Success();
}
return Task.CompletedTask;
}
};
});
builder.Services.Configure<KestrelServerOptions>(options =>
{
options.ConfigureHttpsDefaults(options =>
{
options.SslProtocols = System.Security.Authentication.SslProtocols.Tls12;
options.CheckCertificateRevocation = false;
//options.ServerCertificate = GetClientCertificate();
options.ClientCertificateValidation = (cert, chain, errors) =>
{
Console.WriteLine("Client Validation Called");
errors = System.Net.Security.SslPolicyErrors.None;
return true;
};
});
});
var app = builder.Build();
app.UseCertificateForwarding();
app.UseAuthentication();
//app.UseAuthorization();
// Configure the HTTP request pipeline.
app.MapGrpcService<GreeterService>();
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
app.Run();
}
}
}
gRPC Client Code
// See https://aka.ms/new-console-template for more information
using Grpc.Core;
using Grpc.Net.Client;
using GrpcTest;
using System.Security.Cryptography.X509Certificates;
internal class Program
{
private static async Task Main(string[] args)
{
Console.WriteLine("Hello, World!");
var x509 = GetClientCertificate();
var handler = new HttpClientHandler();
handler.SslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls11 | System.Security.Authentication.SslProtocols.Tls;
handler.ClientCertificateOptions = ClientCertificateOption.Manual;
handler.ClientCertificates.Add(x509);
handler.UseProxy = false;
Console.ReadKey();
using var channel = GrpcChannel.ForAddress("https://localhost:7283", new GrpcChannelOptions()
{
HttpHandler = handler,
DisposeHttpClient = true
});
var client = new Greeter.GreeterClient(channel);
var reply = await client.SayHelloAsync(
new HelloRequest { Name = "GreeterClient" });
Console.WriteLine("Greeting: " + reply.Message);
Console.WriteLine("Press any key to exit...");
Console.ReadKey();
}
private static X509Certificate2 GetClientCertificate()
{
X509Store userCaStore = new X509Store(StoreName.My, StoreLocation.CurrentUser);
try
{
userCaStore.Open(OpenFlags.ReadOnly);
X509Certificate2Collection certificatesInStore = userCaStore.Certificates;
X509Certificate2Collection findResult = certificatesInStore.Find(X509FindType.FindBySubjectName, "grpctest", true);
X509Certificate2 clientCertificate = null!;
if (findResult.Count == 1)
{
clientCertificate = findResult[0];
}
else
{
throw new Exception("Unable to locate the correct client certificate.");
}
return clientCertificate;
}
catch
{
throw;
}
finally
{
userCaStore.Close();
}
}
}

Is this .NET 6 or 7?
See the parts relevant to gRPC and full chain support in Kestrel at https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-7.0?view=aspnetcore-7.0
With .NET 6 you might need to use something like https://github.com/MarkCiliaVincenti/TlsCertificateLoader

I am a dingus. I was passing the public cert to the HttpHandler in the request instead of a public and private key to actually perform authentication.
I am not sure if this is the appropriate answer or if I have a weird configuration value somewhere that I do not see, but I was able to get certificates to be passed through.
The issue, Kestrel seems to have been blocking the certificate, despite my attempt to bypass it by configuring the default HTTPS connection. Once I manually created my own listener in builder and did not rely on the default configuration, it seem that it worked without an issue.
This is the code I used for my builder, albeit maybe overboard and I do not recommend it for production use without removing my security bypass.
builder.Services.Configure<KestrelServerOptions>(options =>
{
options.Listen(IPAddress.Loopback, 8080, listenOptions =>
{
listenOptions.UseConnectionLogging();
listenOptions.UseHttps(options =>
{
options.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
options.CheckCertificateRevocation = false;
options.AllowAnyClientCertificate();
options.ClientCertificateValidation = (cert, chain, errors) =>
{
Console.WriteLine("Client Validation Called");
errors = System.Net.Security.SslPolicyErrors.None;
return true;
};
});
});
});

Related

.Net 6 API - Certificate Authentication - Not working 403 response received

I am working on an Azure Function that needs to send a certificate to an API that will authenticate the request and return the data accordingly.
I am successfully getting the certificate from Azure Key Vault as a X509Certificate2. This is making a call (will be making several) to my API I am getting a 403 response.
I have followed this configuration https://learn.microsoft.com/en-us/aspnet/core/security/authentication/certauth?view=aspnetcore-6.0 and my code currently looks like this
-- Program.cs
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<KestrelServerOptions>(options =>
{
options.ConfigureHttpsDefaults(options =>
options.ClientCertificateMode = ClientCertificateMode.AllowCertificate);
});
builder.Services.AddAuthentication(
CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = context =>
{
var validationService = context.HttpContext.RequestServices
.GetRequiredService<ICertificateValidationService>();
if (validationService.ValidateCertificate(context.ClientCertificate).Result)
{
var claims = new[]
{
new Claim(
ClaimTypes.NameIdentifier,
context.ClientCertificate.Subject,
ClaimValueTypes.String, context.Options.ClaimsIssuer),
new Claim(
ClaimTypes.Name,
context.ClientCertificate.Subject,
ClaimValueTypes.String, context.Options.ClaimsIssuer)
};
context.Principal = new ClaimsPrincipal(
new ClaimsIdentity(claims, context.Scheme.Name));
context.Success();
}
return Task.CompletedTask;
},
OnAuthenticationFailed = failedContext =>
{
failedContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return Task.CompletedTask;
}
};
});
-- Validation Service
public class CertificateValidationService : ICertificateValidationService
{
private readonly IKeyVaultCertificateService _keyVaultService;
private readonly KeyVaultConfiguration _keyVauyltConfiguration;
public CertificateValidationService(IKeyVaultCertificateService keyVaultService, IOptions<KeyVaultConfiguration> keyVauyltConfiguration)
{
_keyVaultService = keyVaultService;
_keyVauyltConfiguration = keyVauyltConfiguration.Value;
}
public async Task<bool> ValidateCertificate(X509Certificate2 clientCertificate)
{
X509Certificate2 expectedCertificate = await _keyVaultService.GetX509CertificateAsync(_keyVauyltConfiguration.CertificateName);
return clientCertificate.Thumbprint == expectedCertificate.Thumbprint;
}
}
-- Controller
[Authorize(AuthenticationSchemes = CertificateAuthenticationDefaults.AuthenticationScheme)]
public IActionResult GetCount()
{
/// removed
}
When the request is made I am not hitting the method ValidateCertificate and I am just getting a 403 response.
I am having the same when I make a request through Postman and send the certificate in the request there too.
I would be grateful if someone could help me as I am stuck without success right now
--- Edit 1
_client = new HttpClient();
if (_erpConfiguration.UserCertificateAuthentication)
{
X509Certificate2 certificate = _keyVaultCertificateService
.GetX509CertificateAsync(_keyVaultConfiguration.CertificateName).Result;
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(certificate);
_client = new HttpClient(handler);
}
var countHttpResponse = await _client.GetAsync(fullUri);
--- End Edit 1
--- Edit 2
I am getting the certificate from KeyVault like this
public async Task<KeyVaultCertificateWithPolicy> GetCertificateWithPolicyAsync(string certificateName)
{
Response<KeyVaultCertificateWithPolicy>? certificate = await _certificateClient.GetCertificateAsync(certificateName);
return certificate;
}
public async Task<X509Certificate2> GetX509CertificateAsync(string certificateName)
{
KeyVaultCertificateWithPolicy certificate = await GetCertificateWithPolicyAsync(certificateName);
var certContent = certificate.Cer;
return new X509Certificate2(certContent);
}
--- End Edit 2
--- Edit 3
public async Task<X509Certificate2> GetX509CertificateAsync(string certificateName)
{
KeyVaultCertificateWithPolicy certificate = await GetCertificateWithPolicyAsync(certificateName);
// Return a certificate with only the public key if the private key is not exportable.
if (certificate.Policy?.Exportable != true)
{
return new X509Certificate2(certificate.Cer);
}
// Parse the secret ID and version to retrieve the private key.
string[] segments = certificate.SecretId.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3)
{
throw new InvalidOperationException($"Number of segments is incorrect: {segments.Length}, URI: {certificate.SecretId}");
}
string secretName = segments[1];
string secretVersion = segments[2];
KeyVaultSecret secret = await _secretClient.GetSecretAsync(secretName, secretVersion, CancellationToken.None);
// For PEM, you'll need to extract the base64-encoded message body.
if (!"application/x-pkcs12".Equals(secret.Properties.ContentType,
StringComparison.InvariantCultureIgnoreCase))
{
throw new VerificationException("Unable to validate certificate");
}
byte[] pfx = Convert.FromBase64String(secret.Value);
return new X509Certificate2(pfx);
}
--- End Edit 3

Grpc HealthChecks not all serving status

I would like to check healthcheck grpc server like this link
In my GrpcServer project, in startup class I've :
//HealthCheck
services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy());
services.AddGrpcHealthChecks(o => {
o.Services.MapService("myservice", _ => true);
});
And
app.UseEndpoints(endpoints => {
endpoints.MapGrpcService<MyGrpcService>();
endpoints.MapGrpcHealthChecksService();
endpoints.MapHealthChecks("/health", new HealthCheckOptions() {
Predicate = _ => true,
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
});
On client side, I use Grpc.HealthCheck package :
private static async Task Main()
{
var httpClientHandler = new HttpClientHandler();
httpClientHandler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
var httpClient = new HttpClient(httpClientHandler);
var channel = GrpcChannel.ForAddress("https://localhost:5002", new GrpcChannelOptions { HttpClient = httpClient });
var client = new Health.HealthClient(channel);
var health = await client.CheckAsync(new HealthCheckRequest { Service = "myservice" });
Console.WriteLine($"Health Status: {health.Status}");
Console.WriteLine("Press a key to exit");
Console.ReadKey();
}
Like this is working fine, but if I change service name in HealthCheckRequest, I've got:
The request was aborted. The response ended prematurely, with at least
9 additional bytes expected.
Shouldn't I have a health.Status = Unknown ? Why I've this error ?
Thanks for help

Grpc token expiration handling

I'm using grpc code-first for my blazor-wasm app and I can't understand how I should handle token expiration.
As I understand it, I need an client side interceptor that will check the expiration time and make a request to the server to update.
public static WebAssemblyHostBuilder AddGrpc(this WebAssemblyHostBuilder builder)
{
builder.Services
.AddTransient(sp => sp.GetRequiredService<IHttpClientFactory>()
.CreateClient(BaseClient));
builder.Services.AddScoped(services =>
{
var baseAddressMessageHandler = services.GetRequiredService<AuthenticationHeaderHandler>(); // <= adds the authorization header
baseAddressMessageHandler.InnerHandler = new HttpClientHandler(); // <= I tried adding a custom handler, but it didn't work either
var grpcWebHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, baseAddressMessageHandler);
var channel = GrpcChannel.ForAddress(builder.HostEnvironment.BaseAddress, new GrpcChannelOptions { HttpHandler = grpcWebHandler });
return channel;
});
return builder;
}
How to solve this problem correctly?
Solution:
builder.Services.AddScoped(services =>
{
var authManager = services.GetRequiredService<IAuthenticationManager>();
var navManager = services.GetRequiredService<NavigationManager>();
var credentials = CallCredentials.FromInterceptor(async (context, metadata) =>
{
try
{
await authManager.TryRefreshToken();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
navManager.NavigateTo("/login");
}
});
var baseAddressMessageHandler = services.GetRequiredService<AuthenticationHeaderHandler>();
baseAddressMessageHandler.InnerHandler = new HttpClientHandler();
var grpcWebHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, baseAddressMessageHandler);
var channel = GrpcChannel.ForAddress(builder.HostEnvironment.BaseAddress, new GrpcChannelOptions
{
HttpHandler = grpcWebHandler,
Credentials = ChannelCredentials.Create(new SslCredentials(), credentials)
});
return channel;
});
In CallCredentials.FromInterceptor we can check token and update it.

remote certificate is invalid according to the validation procedure (Identity server Hosted in azure)

I tried a lot and now putting it here .. so I have an app service in which API Identity server and UI(Blazor) is hosted in different folders inside the same app service now i generated the rsa signing cert from
[https://damienbod.com/2020/02/10/create-certificates-for-identityserver4-signing-using-net-core/](this blog post)
now everything works fine on development even when i set hosted identity server as provider and localhost(UI and Web API) even then its working
but when i try to access the hosted API its throwing error (in log) and i am getting 401
please any helps would be appreciated
also
my identity server startup looks like this
public void ConfigureServices(IServiceCollection services)
{
var settings = config.GetSection("AppSettings").Get<AppSettings>();
X509Certificate2 rsaCertificate = null;
if (env.IsDevelopment())
{
rsaCertificate = new X509Certificate2(
Path.Combine(env.WebRootPath, "cert/rsaCert.pfx"), "password");
}
else
{
using (X509Store certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser))
{
certStore.Open(OpenFlags.ReadOnly);
X509Certificate2Collection certCollection = certStore.Certificates.Find(
X509FindType.FindByThumbprint,
settings.CertificateDetails.CertificateThumbPrint,
false);
// Get the first cert with the thumbprint
if (certCollection.Count > 0)
{
rsaCertificate = certCollection[0];
}
}
}
var connectionString = config.GetConnectionString("DefaultConnection");
services.AddOidcStateDataFormatterCache();
services.AddDbContext<SeatingDBContext>(x =>
{
x.UseSqlServer(connectionString);
});
services.AddIdentity<ApplicationUser, ApplicationRole>(x =>
{
x.Password.RequiredLength = 4;
x.Password.RequireDigit = false;
x.Password.RequireNonAlphanumeric = false;
x.Password.RequireUppercase = false;
}).AddEntityFrameworkStores<SeatingDBContext>().
AddDefaultTokenProviders();
services.ConfigureApplicationCookie(x =>
{
x.Cookie.Name = "IdentityServer.Cookie";
x.LoginPath = "/Auth/Login";
});
var assembly = typeof(Startup).Assembly.GetName().Name;
services.AddIdentityServer(x =>
{
x.Events.RaiseErrorEvents = true;
x.Events.RaiseFailureEvents = true;
x.Events.RaiseSuccessEvents = true;
x.Events.RaiseInformationEvents = true;
}).AddAspNetIdentity<ApplicationUser>()
.AddInMemoryIdentityResources(Configuration.GetIdentityResources())
.AddInMemoryApiScopes(Configuration.GetApiScopes())
.AddInMemoryApiResources(Configuration.GetApis())
.AddInMemoryClients(Configuration.GetClients(settings.ClientApps))
.AddSigningCredential(rsaCertificate);
// .AddValidationKey(rsaCertificate);
services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>));
services.AddTransient<IUserSyncService, UserSyncService>();
services.AddControllersWithViews();
}
and my web API looks like this
public void ConfigureServices(IServiceCollection services)
{
var connectionString = Configuration.GetConnectionString("DefaultConnection");
var settings = Configuration.GetSection("AppSettings").Get<AppSettings>();
services.AddControllers();
services.AddDbContext<SeatingDBContext>(x => x.UseSqlServer(connectionString));
services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>));
services.AddAutoMapper(typeof(Startup));
services.AddScoped<IEmailService, EmailService>();
services.AddSingleton<IEmailConfiguration>(settings.EmailConfiguration);
services.AddScoped<MailSender>();
services.AddControllers();
services.AddIdentityForWebApi<ApplicationUser, ApplicationRole>(x =>
{
x.Password.RequiredLength = 4;
x.Password.RequireDigit = false;
x.Password.RequireNonAlphanumeric = false;
x.Password.RequireUppercase = false;
}).AddEntityFrameworkStores<SeatingDBContext>();
services.AddAuthentication(defaultScheme:"Bearer")
.AddIdentityServerAuthentication("Bearer", config =>
{
config.Authority = settings.ODICSettings.Authority;
config.ApiName = settings.ODICSettings.Audience;
});
services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser();
foreach (string scope in settings.ODICSettings.scope)
policy.RequireClaim("scope", scope);
});
});
}
You can not use the signing certificate as a HTTPS web certificate. The signing cert is only used when IdentityServer signs the JWT tokens.
You need to get a real certificate from a trusted provider (like Lets Encrypt) and install it separately as a TLS/HTTPS certificate.
Signing certs and TLS/HTTPS certificates are separate things that both needs to be configured properly.

Cookies are not refreshed in .NET Core 3.1

I have an MVC .NET Core 3.1 application that uses Open ID connect for authentication and stores identity & tokens in cookies. The tokens need to be refreshed as they are used in some API requests our application does. I subscribe to ValidatePrincipal event and refresh the tokens there. The request goes OK, but cookies are not updated for some reason.
Startup.cs:
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
options.OnAppendCookie = (e) =>
{
e.CookieOptions.Domain = <some domain>
};
});
...
.AddCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.Cookie.Domain = <some domain>;
options.Cookie.IsEssential = true;
options.Cookie.Name = <some name>
options.EventsType = typeof(CookieAuthEvents);
})
CookieAuthEvents.cs (constructor, member declarations and logging are omitted):
public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
{
if (context.Principal.Identity.IsAuthenticated)
{
var now = DateTime.UtcNow;
var tokenExpiration = context.Properties.GetTokenExpiration();
if (now > tokenExpiration)
{
await UpdateCookies(context);
}
}
}
private async Task UpdateCookies(CookieValidatePrincipalContext context)
{
var refreshToken = context.Properties.GetTokenValue(OpenIdConnectGrantTypes.RefreshToken);
if (String.IsNullOrEmpty(refreshToken))
{
return;
}
var response = await GetTokenClient().RequestRefreshTokenAsync(refreshToken);
if (!response.IsError)
{
WriteCookies(context, response);
}
else
{
context.RejectPrincipal();
}
}
private void WriteCookies(CookieValidatePrincipalContext context, TokenResponse response)
{
var tokens = new List<AuthenticationToken>
{
new AuthenticationToken
{
Name = OpenIdConnectParameterNames.IdToken,
Value = response.IdentityToken
},
new AuthenticationToken
{
Name = OpenIdConnectParameterNames.AccessToken,
Value = response.AccessToken
},
new AuthenticationToken
{
Name = OpenIdConnectParameterNames.RefreshToken,
Value = response.RefreshToken
},
new AuthenticationToken
{
Name = OpenIdConnectParameterNames.TokenType,
Value = response.TokenType
}
};
var expiresAt = DateTime.UtcNow.AddSeconds(response.ExpiresIn);
tokens.Add(new AuthenticationToken
{
Name = "expires_at",
Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
});
var newPrincipal = GetNewPrincipal(context);
context.ReplacePrincipal(newPrincipal);
context.Properties.StoreTokens(tokens);
context.ShouldRenew = true;
}
private static ClaimsPrincipal GetNewPrincipal(CookieValidatePrincipalContext context)
{
var claims = context.Principal.Claims.Select(c => new Claim(c.Type, c.Value)).ToList();
var authTimeClaim = claims.FirstOrDefault(claim => claim.Type.Same("auth_time"));
if (authTimeClaim != null)
{
claims.Remove(authTimeClaim);
}
claims.Add(new Claim("auth_time", DateTime.UtcNow.UnixTimestamp().ToString()));
return new ClaimsPrincipal(new ClaimsIdentity(claims, context.Principal.Identity.AuthenticationType));
}
The main problem is this works perfectly fine locally. All the calls are fine, cookies are refreshed correctly. But when the app is run from dev machine (we host it in Azure App Service) RequestRefreshTokenAsync call is successful, but the cookies are not updated therefore all the next calls are made with an old tokens leading to 400 invalid_grant error. So basically what I see in logs is:
RequestRefreshTokenAsync successful. The old refresh_token is used to get a new one
ValidatePrincipal successful. Here the cookies should be rewritten (including a new refresh_token)
Next request - RequestRefreshTokenAsync failed. The old refresh_token is used (even though it's invalid).
I tried playing with cookies configurations and placing semaphores and locks inside ValidatePrincipal method but none of it worked.
Does anyone have an idea what can cause that?

Categories

Resources