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
Related
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;
};
});
});
});
This exception is thrown when I need to call the Registry of my project and get all services displayed in client.It worked yesterday but dont know why its not working now.Can anyone help with me with this?
This is my method to get all services to client from the registry api
private void btnGetAll_Click(object sender, RoutedEventArgs e)
{
Services services;
User user = User.Instance;
//Api Call is made.
string url = "http://localhost:14470/api/AllServies/ " + Rtoken;
var clinet = new RestClient(url);
var request = new RestRequest();
var response = clinet.Post(request);
List<Services> data = new List<Services>();
data = JsonConvert.DeserializeObject<List<Services>>(response.Content.ToString());
List<Services> gridData = new List<Services>();
foreach (Services line in data)
{
//services = JsonConvert.DeserializeObject<List<Services>>(line);
/*if (services.Status.Equals("Denied"))
{
logout();
break;
}*/
gridData.Add(line);
}
// Services test = serviceInfo.SelectedItem as Services;
serviceInfo.ItemsSource = gridData;
}
This is the all service controller of my registry api
// POST: api/AllServices
[Route("api/AllServies/{token}")]
[HttpPost]
public IHttpActionResult Post([FromBody] int token)
{
//AuthInterface foob = auth.initServerConnection();
// Ilist return for returning list with multple types
//token validation
string validate = foob.Validate(token);
if (validate.Equals("Not Validated"))
{
//if valid token
// path to the text file
string path = "C:\\Users\\ravin\\Desktop\\assignment_1_DC\\Services.txt";
List<string> services = File.ReadAllLines(path).ToList();
List<Rdetails> regservice = new List<Rdetails>();
//checking for every line
foreach (var service in services)
{
//deserlizing text and assigning values to RegistryDetail type object
Rdetails rDetail = JsonConvert.DeserializeObject<Rdetails>(service);
// add details to the created Registry details type list
regservice.Add(rDetail);
}
return Ok(regservice);
}
else
{
// if token is unvalid
Rresponse reg = new Rresponse();
reg.RegStatus = "Denied";
reg.RegReason = "Authentication Error";
List<Rresponse> output = new List<Rresponse>();
output.Add(reg);
return Ok(output);
}
}
}
}
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.
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?
I am setting up a multi tenant application and I am having issues creating a GraphServiceClient.
I have to following AuthorizationCodeReceived:
AuthorizationCodeReceived = async context =>
{
var tenantId =
context.AuthenticationTicket.Identity.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value;
var authenticationContext = new AuthenticationContext("https://login.microsoftonline.com/"+ tenantId);
await authenticationContext.AcquireTokenByAuthorizationCodeAsync(
context.Code,
new Uri("http://localhost:21925"),
new ClientCredential(ClientId, ClientSecret),
"https://graph.microsoft.com");
}
This works perfectly to authenticate the user. I am using fiddler, and I see that a new bearer token was given by login.microsoftonline.com/{tenantid}/oauth2/token
When creating a new Graph Service Client I use the following factory method:
public IGraphServiceClient CreateGraphServiceClient()
{
var client = new GraphServiceClient(
new DelegateAuthenticationProvider(
async requestMessage =>
{
string token;
var currentUserId = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
var currentUserHomeTenantId = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value;
var authenticationContext = new AuthenticationContext("https://login.microsoftonline.com/" + currentUserHomeTenantId + "/");
var clientCredential = new ClientCredential(_configuration.ClientId, _configuration.ClientSecret);
try
{
var authenticationResult = await authenticationContext.AcquireTokenSilentAsync(
GraphResourceId,
clientCredential,
new UserIdentifier(currentUserId, UserIdentifierType.UniqueId));
token = authenticationResult.AccessToken;
}
catch (AdalSilentTokenAcquisitionException e)
{
var result = await authenticationContext.AcquireTokenAsync(GraphResourceId, clientCredential);
token = result.AccessToken;
}
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", token);
}));
return client;
}
This method always throws an AdalSilentAcquisitionException and the AcquireTokenAsync retrieves a new token.
With this token, I am not able to request 'Me' on the graph.
I get the following exception: message=Resource 'some guid' does not exist or one of its queried reference-property objects are not present.
However, if I am debugging and I change the token before it is passed to the header, with the value of the one I got previously right after login in (received from login.microsoftonline.com/{tenantid}/oauth2/token ) then the API call works.
Does anyone know what I am doing wrong? He can I get the acquiretokensilently working?
Update: I have updated the code samples. I have removed the custom cache, and now everything seems to work.
How can I make a custom cache based on the http sessions, making sure the AcquireTokenSilently works.
Preview of not working token cache:
public class WebTokenCache : TokenCache
{
private readonly HttpContext _httpContext;
private readonly string _cacheKey;
public WebTokenCache()
{
_httpContext = HttpContext.Current;
var claimsPrincipal = (ClaimsPrincipal) HttpContext.Current.User;
_cacheKey = BuildCacheKey(claimsPrincipal);
AfterAccess = AfterAccessNotification;
LoadFromCache();
}
private string BuildCacheKey(ClaimsPrincipal claimsPrincipal)
{
var clientId = claimsPrincipal.FindFirst("aud").Value;
return $"{claimsPrincipal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value}_TokenCache";
}
private void LoadFromCache()
{
var token = _httpContext.Cache[_cacheKey];
if (token == null) return;
Deserialize((byte[]) token);
}
private void AfterAccessNotification(TokenCacheNotificationArgs args)
{
if (!HasStateChanged) return;
if (Count > 0)
{
_httpContext.Cache[_cacheKey] = Serialize();
}
else
{
_httpContext.Cache.Remove(_cacheKey);
}
HasStateChanged = false;
}
}
I am trying use the code above and it works well form me.
Please ensure that the GraphResourceId is https://graph.microsoft.com(This resource is requested first time in your startUp class) since the method AcquireTokenSilentAsync will try to retrieve the token from cache based on the resrouce.