I solved this based on the new project templates for Hosted Blazor WebAssembly projects by Microsoft in .NET Core 3.2. I copied code from BaseAddressAuthorizationMessageHandler but commented out the exception thrown when the token is unavailable and added it to the HttpClient in Program.cs:
Program.cs:
builder.Services.AddHttpClient("SampleProject.ServerAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
.AddHttpMessageHandler<GrpcWebHandler>()
.AddHttpMessageHandler<GrpcAuthorizationMessageHandler>();
builder.Services.AddSingleton(services =>
{
// Create a gRPC-Web channel pointing to the backend server
var httpClient = services.GetRequiredService<HttpClient>();
var baseUri = services.GetRequiredService<NavigationManager>().BaseUri;
var channel = GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions { HttpClient = httpClient });
// Now we can instantiate gRPC clients for this channel
return new Products.ProductsClient(channel);
});
GrpcAuthorizationMessageHandler.cs (source):
public class GrpcAuthorizationMessageHandler : DelegatingHandler
{
private readonly IAccessTokenProvider _provider;
private readonly NavigationManager _navigation;
private AccessToken _lastToken;
private AuthenticationHeaderValue _cachedHeader;
private Uri[] _authorizedUris;
private AccessTokenRequestOptions _tokenOptions;
public GrpcAuthorizationMessageHandler(
IAccessTokenProvider provider,
NavigationManager navigation)
{
_provider = provider;
_navigation = navigation;
ConfigureHandler(new[] { _navigation.BaseUri });
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var now = DateTimeOffset.Now;
if (_authorizedUris == null)
{
throw new InvalidOperationException($"The '{nameof(AuthorizationMessageHandler)}' is not configured. " +
$"Call '{nameof(AuthorizationMessageHandler.ConfigureHandler)}' and provide a list of endpoint urls to attach the token to.");
}
if (_authorizedUris.Any(uri => uri.IsBaseOf(request.RequestUri)))
{
if (_lastToken == null || now >= _lastToken.Expires.AddMinutes(-5))
{
var tokenResult = _tokenOptions != null ?
await _provider.RequestAccessToken(_tokenOptions) :
await _provider.RequestAccessToken();
if (tokenResult.TryGetToken(out var token))
{
_lastToken = token;
_cachedHeader = new AuthenticationHeaderValue("Bearer", _lastToken.Value);
}
// this exception was commented out to be used with the GrpcWebHandler
// else
// {
// throw new AccessTokenNotAvailableException(_navigation, tokenResult, _tokenOptions?.Scopes);
// }
}
// We don't try to handle 401s and retry the request with a new token automatically since that would mean we need to copy the request
// headers and buffer the body and we expect that the user instead handles the 401s. (Also, we can't really handle all 401s as we might
// not be able to provision a token without user interaction).
request.Headers.Authorization = _cachedHeader;
}
return await base.SendAsync(request, cancellationToken);
}
public GrpcAuthorizationMessageHandler ConfigureHandler(
IEnumerable<string> authorizedUrls,
IEnumerable<string> scopes = null,
string returnUrl = null)
{
if (_authorizedUris != null)
{
throw new InvalidOperationException("Handler already configured.");
}
if (authorizedUrls == null)
{
throw new ArgumentNullException(nameof(authorizedUrls));
}
var uris = authorizedUrls.Select(uri => new Uri(uri, UriKind.Absolute)).ToArray();
if (uris.Length == 0)
{
throw new ArgumentException("At least one URL must be configured.", nameof(authorizedUrls));
}
_authorizedUris = uris;
var scopesList = scopes?.ToArray();
if (scopesList != null || returnUrl != null)
{
_tokenOptions = new AccessTokenRequestOptions
{
Scopes = scopesList,
ReturnUrl = returnUrl
};
}
return this;
}
}
Here is the rationale behind it.
According to this blog post by Steve Sanderson, you only need to add the GrpcWebHandler to the HttpClient to be able to use GrpcWeb. However, if you try to use the BaseAddressAuthorizationMessageHandler with the GrpcWebHandler you will get an RpcException with StatusCode=Internal thrown when the user is unauthenticated.
After looking into the code, I found that the cause of the exception is that the authorization handler throws an exception when the token is not available, and the GrpcWebHandler catches it as an internal exception. If you add a custom message handler that does not throw that exception, like the one above, the GrpcWebHandler will throw the correct RcpException with StatusCode=Unauthenticated, which you can then handle accordingly, for example by redirecting to the login page.
This is a sample of how you can then use your GrpcClient in a razor page without needing to add additional authorization code:
@inject CustomClient grpcClient
@inject NavigationManager navManager
@code {
public async Task MakeRequest() {
var request = new Request();
try
{
var reply = await grpcClient.MakeRequestAsync(request);
}
catch (Grpc.Core.RpcException ex) when (ex.StatusCode == StatusCode.Unauthenticated)
{
NavigationManager.NavigateTo($"/authentication/login/?returnUrl={NavigationManager.BaseUri}your-page");
}
}
}