Reputation: 391
I am testing gRPC-Web in Blazor Webassembly with authentication and hit a little bit of a block about how to get a clean access to my gRPC channel.
Without authentication there is a pretty simple and clean way, as detailed in the Blazor sample for grpc-dotnet https://github.com/grpc/grpc-dotnet/tree/master/examples/Blazor.
Provision of the Channel:
builder.Services.AddSingleton(services =>
{
// Get the service address from appsettings.json
var config = services.GetRequiredService<IConfiguration>();
var backendUrl = config["BackendUrl"];
var httpClient = new HttpClient(new GrpcWebHandler(GrpcWebMode.GrpcWebText, new HttpClientHandler()));
var channel = GrpcChannel.ForAddress(backendUrl, new GrpcChannelOptions { HttpClient = httpClient });
return channel;
});
Usage in the Razor Files
@inject GrpcChannel Channel
Adding authentication directly in the razor file and creating the channel there isn't that complicated either
@inject IAccessTokenProvider AuthenticationService
...
@code {
...
var httpClient = new HttpClient(new GrpcWebHandler(GrpcWebMode.GrpcWebText, new HttpClientHandler()));
var tokenResult = await AuthenticationService.RequestAccessToken();
if (tokenResult.TryGetToken(out var token))
{
var _token = token.Value;
var credentials = CallCredentials.FromInterceptor((context, metadata) =>
{
if (!string.IsNullOrEmpty(_token))
{
metadata.Add("Authorization", $"Bearer {_token}");
}
return Task.CompletedTask;
});
//SslCredentials is used here because this channel is using TLS.
//Channels that aren't using TLS should use ChannelCredentials.Insecure instead.
var channel = GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions
{
Credentials = ChannelCredentials.Create(new SslCredentials(), credentials)
});
But this moves a lot of the required logic into the razor file. Is there a way to combine these and provide an authenticated grpc channel via injection?
Upvotes: 3
Views: 4314
Reputation: 854
I had this exact issue while deploying a separate API/Identity/gRPC Server and Blazor WASM/gRPC Client both with different host names. The requests being sent to the gRPC server were not including the authorization
header and thus a gRPC 401/Unauthenticated
even though the user was successfully authenticated.
If you are using IdentityServer4 (or any authentication really) and it is hosted from a different endpoint (URI) than the Blazor WASM app, you will need a custom implementation of AuthorizationMessageHandler
. First, set authorizedUrls
within ConfigureHandler()
to include your backend server(s), then update your Program.cs
file and replace or add the newly created message handler to the gRPC and Http clients.
It is very simple, create the custom class implementation:
public class CorsAuthorizationMessageHandler : AuthorizationMessageHandler
{
public CorsAuthorizationMessageHandler(IAccessTokenProvider provider,
NavigationManager navigation) : base(provider, navigation)
{
ConfigureHandler(authorizedUrls: new[] { "https://api.myapp.com" });
}
}
Then update Progam.cs and add the following scoped service:
builder.Services.AddScoped<CorsAuthorizationMessageHandler>();
Next update any secured HttpClients
:
builder.Services.AddHttpClient(
"Private.ServerAPI",
client => client.BaseAddress = new Uri("https://api.myapp.com")
).AddHttpMessageHandler<CorsAuthorizationMessageHandler>();
Finally for gRPC
clients:
builder.Services.AddScoped(sp =>
{
var messageHandler = sp.GetRequiredService<CorsAuthorizationMessageHandler>();
messageHandler.InnerHandler = new HttpClientHandler();
var grpcWebHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, messageHandler);
var channel = GrpcChannel.ForAddress("https://api.myapp.com",
new GrpcChannelOptions { HttpHandler = grpcWebHandler });
return new MygRPCService.MygRPCServiceClient(channel);
});
That's it! Let me know if you have questions about this configuration.
Upvotes: 1
Reputation: 9830
For my solution I extracted the code to get and cache the token in a separate class: GrpcBearerTokenProvider.cs
public class GrpcBearerTokenProvider
{
private readonly IAccessTokenProvider _provider;
private readonly NavigationManager _navigation;
private AccessToken _lastToken;
private string _cachedToken;
public GrpcBearerTokenProvider(IAccessTokenProvider provider, NavigationManager navigation)
{
_provider = provider;
_navigation = navigation;
}
public async Task<string> GetTokenAsync(params string[] scopes)
{
var now = DateTimeOffset.Now;
if (_lastToken == null || now >= _lastToken.Expires.AddMinutes(-5))
{
var tokenResult = scopes?.Length > 0 ?
await _provider.RequestAccessToken(new AccessTokenRequestOptions { Scopes = scopes }) :
await _provider.RequestAccessToken();
if (tokenResult.TryGetToken(out var token))
{
_lastToken = token;
_cachedToken = _lastToken.Value;
}
else
{
throw new AccessTokenNotAvailableException(_navigation, tokenResult, scopes);
}
}
return _cachedToken;
}
}
Which can be used in the partial page code-behind like:
[Inject]
public GrpcChannel Channel { get; set; }
[Inject]
public GrpcBearerTokenProvider GrpcBearerTokenProvider { get; set; }
private async Task IncrementCount()
{
var cts = new CancellationTokenSource();
string token = "";
try
{
token = await GrpcBearerTokenProvider.GetTokenAsync(Program.Scope);
}
catch (AccessTokenNotAvailableException a)
{
a.Redirect();
}
var headers = new Metadata
{
{ "Authorization", $"Bearer {token}" }
};
var client = new Count.Counter.CounterClient(Channel);
var call = client.StartCounter(new CounterRequest { Start = currentCount }, headers, cancellationToken: cts.Token);
}
Full example projects can be found here:
Upvotes: 1
Reputation: 21
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");
}
}
}
Upvotes: 2
Reputation: 745
I tried to do something similar in my Blazor WASM app with the sample code from the 'Ticketer' example from JamesNK at https://github.com/grpc/grpc-dotnet/tree/master/examples#ticketer and it works.
The ticketer shows how to use gRPC with authentication and authorization in ASP.NET Core. This example has a gRPC method marked with an [Authorize] attribute. The client can only call the method if it has been authenticated by the server and passes a valid JWT token with the gRPC call.
I create a token in 'Client/Shared/NavMenu.cs' (OnInitializedAsync()
) and use that token in calls to the gRPC-services in other pages.
Upvotes: 1
Reputation: 391
After lots of additional tests, i found a solution. While not perfect it is working fine so far.
Registration of the channel during startup
builder.Services.AddSingleton(async services =>
{
var httpClient = new HttpClient(new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()));
var baseUri = "serviceUri";
var authenticationService = services.GetRequiredService<IAccessTokenProvider>();
var tokenResult = await authenticationService.RequestAccessToken();
if(tokenResult.TryGetToken(out var token)) {
var credentials = CallCredentials.FromInterceptor((context, metadata) =>
{
if (!string.IsNullOrEmpty(token.Value))
{
metadata.Add("Authorization", $"Bearer {token.Value}");
}
return Task.CompletedTask;
});
var channel = GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions { HttpClient = httpClient, Credentials = ChannelCredentials.Create(new SslCredentials(), credentials) });
return channel;
}
return GrpcChannel.ForAddress(baseUri, new GrpcChannelOptions() { HttpClient = httpClient });
});
Since the channel is registered using async, it has to be injected as a Task
@inject Task<GrpcChannel> Channel
Upvotes: 6