ARTICLE AD BOX
We are building an application where there are several components that must all work together that are not all entirely in my control.
First, a frontend written in Blazor Server that does some rendering of 3D assets. This is done via WebGL and some similar technologies. Where the server does some caching but mostly just allows us to do some multi-threading that we can't accomplish using Blazor WASM. Second, a backend ASP.NET Core Web API that provides a control plane for our application-specific services and handles our out-of-process processing via some microservices. This API is both a REST API and a gRPC API where the gRPC is mostly just a proxy to other services that require gRPC, but now with added authentication. Third, our authentication service which is a keycloak instance running in cluster with the other services.Currently, our application requires the use of Blazor Server as WASM doesn't support the needed multi-threading parts that we currently need. We are looking to move out the parts that are multi-threaded behind the API, but that will take time and the system isn't currently designed to be able to do this, but it can talk to elements of the Blazor server (just not an external server it is an odd predicamentI know).
Problem
Most of this is working as intended; however, I need to be able to make the gRPC calls in a way that is authenticated to the API.
Currently, I am getting errors related to the token not being available because the HttpContext no longer exists by the time the gRPC call needs to occur. I have solved this on the REST api side of things by using a DelegatingHandler on the HttpClient that are used to make the calls. However, when using the GrpcWebHandler and HttpClientHandler it does not appear that there is a way to do the same with a DelegatingHandler so we have created a wrapper around the GrpcChannel to allow for the token to be passed in via an interceptor. This is what does not appear to work.
After the user has logged in and the token recieved we make a call to the API. Which will fail with a http 401 error as the request does not contain the token.
Specifically:
Grpc.Core.RpcException: Status(StatusCode="Unauthenticated", Detail="Bad gRPC response. HTTP status code: 401")
at Service.Grpc.GrpcClient1.ReadResponse[TResponse](Func1 executeCall, Int32 retryBackoffMs) in /Service.Grpc/Clients/GrpcClient.cs:line 499
The interceptor channel looks like:
internal class AuthenticatedChannel( GrpcChannel innerChannel, IHttpContextAccessor httpContextAccessor, string? initialToken, ILogger logger) : ChannelBase(innerChannel.Target) { public override CallInvoker CreateCallInvoker() { var callInvoker = innerChannel.CreateCallInvoker(); return callInvoker.Intercept(new WebAuthenticationInterceptor(httpContextAccessor, initialToken, logger)); } } internal class WebAuthenticationInterceptor( IHttpContextAccessor httpContextAccessor, string? fallbackToken) : Interceptor { private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); private async Task<string?> GetAccessTokenAsync() { if (_httpContextAccessor.HttpContext is null) return fallbackToken; var token = await _httpContextAccessor.HttpContext.GetTokenAsync("access_token"); return !string.IsNullOrWhiteSpace(token) ? token : fallbackToken; } private Metadata AddAuthorizationHeader(Metadata? headers, string? accessToken) { var metadata = headers ?? new Metadata(); if (!string.IsNullOrWhiteSpace(accessToken)) { metadata.Add("Authorization", $"Bearer {accessToken}"); } else { _logger.LogWarning("No access token available for gRPC call");enter code here } return metadata; } //... other methods omitted for brevity }This channel is created by a factory that handles channel creation similar to the following:
public class ChannelFactory : IChannelFactory { public ChannelFactory( IConfiguration configuration, IHttpContextAccessor httpContextAccessor, ILogger<WebGrpcChannelFactory> logger) { _configuration = configuration; _httpContextAccessor = httpContextAccessor; _logger = logger; if (httpContextAccessor.HttpContext is not null) { _circuitToken = httpContextAccessor.HttpContext.GetTokenAsync("access_token") .GetAwaiter().GetResult(); } if (string.IsNullOrWhiteSpace(_circuitToken)) { _logger.LogWarning("No access token captured"); } } public async Task<IGrpcChannel> CreateChannel( string target, bool useExistingChannel, CancellationToken cancellationToken) { string? token = null; if (_httpContextAccessor.HttpContext is not null) { token = await _httpContextAccessor.HttpContext.GetTokenAsync("access_token"); if (string.IsNullOrWhiteSpace(token)) { _logger.LogWarning("Failed to acquire access token from HttpContext"); } } token ??= _circuitToken; if (useExistingChannel && _channels.TryGetValue(target, out var existingChannel)) { return new WebGrpcChannelWrapper(existingChannel, _httpContextAccessor, token, _logger); } var httpHandler = new GrpcWebHandler(new HttpClientHandler()); var channelOptions = new GrpcChannelOptions { HttpHandler = httpHandler, MaxReceiveMessageSize = null, MaxSendMessageSize = null }; var channel = GrpcChannel.ForAddress(_apiBaseUrl, channelOptions); if (useExistingChannel) { _channels[target] = channel; } return new WebGrpcChannelWrapper(channel, _httpContextAccessor, token, _logger); } }Here is what I am doing to solve this for the HttpClient with a DelegatingHandler, this does work:
public class ApiTokenHandler : DelegatingHandler { private readonly IHttpContextAccessor _contextAccessor; private readonly ILogger<ApiTokenHandler> _logger; public ApiTokenHandler( IHttpContextAccessor contextAccessor, ILogger<ApiTokenHandler> logger) { _contextAccessor = contextAccessor; _logger = logger; } protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { string? token = null; if (_contextAccessor.HttpContext is not null) { token = await _contextAccessor.HttpContext.GetTokenAsync("access_token"); } if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } return await base.SendAsync(request, cancellationToken); } }Question
Is there a way that I should be handling this kind of authentication for a Blazor Server application? I have gone through the documentation (here)[https://learn.microsoft.com/en-us/aspnet/core/blazor/security/?view=aspnetcore-10.0&tabs=visual-studio] and it seems that Blazor Server doesn't really have any solid mechanisms for dealing with authentication when on the client side or if the request is not being done client-side then during the later points in time of a circuit's session. I feel like I'm trying to force something that shouldn't be, but I also don't have a ton of options for changing it at the moment due to business requirements.
