1

In my Blazor app (which uses Azure B2C), I want to be able to call an endpoint whether the user is authenticated or not.

I've searched quite a bit, and everything I find says I should create two HttpClients (example), one for anonymous and one for authenticated, or use IHttpClientFactory with named clients.

The problem is I am using Strawberry Shake which only allows me to configure HttpClient once (it is using a named client and IHttpClientFactory internally).

Their documentation gives a simple example of setting authentication:

services
    .AddConferenceClient()
    .ConfigureHttpClient((serviceProvider, client) =>
    {
        var token = serviceProvider.GetRequiredService<ISomeService>().Token;
    });

I thought I could use this to conditionally select which handler(s) I wanted, but the only ways I can find to get the token (IAccessTokenProvider.RequestAccessToken()) or validate authentication (Task<AuthenticationState>) require async calls, which are not allowed in this context. Even .Result doesn't work (not that I wanted to use it anyway).

My last thought is that maybe I could accomplish this by inheriting from BaseAddressAuthorizationMessageHandler or chaining handlers, but I can't figure out how. I even tried copying the source code and modifying it, but still couldn't get it to work (UPDATE: Actually, that did work, but it still seems less than ideal).

So many approaches seem workable, but ultimately fail me. How can I get this to work? Please provide code example if possible.

Dan Friedman
  • 4,941
  • 2
  • 41
  • 65

1 Answers1

0

It's a bit old question already but I encountered the same issue and couldn't find any proper solution.

I was able to make it work, by registering the GraphQL client and custom AuthorizationMessageHandler like this:

builder.Services.TryAddTransient<GraphQlAuthorizationMessageHandler>();
builder.Services
    .AddConferenceClient()
    .ConfigureHttpClient(client =>
    {
        client.BaseAddress = new Uri("http://localhost:5000/graphql");
    }, 
    configureClientBuilder =>
    {
        configureClientBuilder.AddHttpMessageHandler<GraphQlAuthorizationMessageHandler>();
    });

Where my GraphQlAuthorizationMessageHandler is simply:

public sealed class GraphQlAuthorizationMessageHandler : AuthorizationMessageHandler
{
    public GraphQlAuthorizationMessageHandler(IAccessTokenProvider provider, NavigationManager navigation)
        : base(provider, navigation)
    {
        base.ConfigureHandler(new [] {"http://localhost:5000/graphql" });
    }
}   

It works because the existing AuthorizationMessageHandler (github) class already contains the logic that adds the token to the Authorization header. The only requirement to make it work is to configure the handler with the URLs for which we want it to inject the header.

EDIT:

If you would like to handle both authenticated and unauthenticated users then it will require a little bit more code. Simply overriding AuthorizationMessageHandler will not work and you will need to implement your own handler.

As an example, I based my handler on AuthorizationMessageHandler with the required changes to make it work also for unauthenticated user - in such case, it will not inject the authorization header. You can implement your own custom logic inside SendAsync if you would like to add additional checks or maybe use a separate service for providing the authentication state.


public sealed class GraphQlAuthorizationMessageHandlerSettings
{
    public IEnumerable<string> AuthorizedUrls { get; set; } = null!;
    public IEnumerable<string>? Scopes { get; set; }
    public string RedirectUri { get; set; }
}

/// <summary>
/// Entirely based on AuthorizationMessageHandler from Microsoft.AspNetCore.Components.WebAssembly.Authentication
/// src: https://github.com/dotnet/aspnetcore/blob/main/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/AuthorizationMessageHandler.cs
/// </summary>
public sealed class GraphQlAuthorizationMessageHandler : DelegatingHandler, IDisposable
{
    private readonly IAccessTokenProvider _provider;
    private readonly AuthenticationStateChangedHandler _authenticationStateChangedHandler;
    private AccessToken _lastToken;
    private AuthenticationHeaderValue _cachedHeader;
    private Uri[] _authorizedUris;
    private AccessTokenRequestOptions _tokenOptions;

    /// <summary>
    /// Initializes a new instance of <see cref="AuthorizationMessageHandler"/>.
    /// </summary>
    /// <param name="provider">The <see cref="IAccessTokenProvider"/> to use for provisioning tokens.</param>
    /// <param name="navigation">The <see cref="NavigationManager"/> to use for performing redirections.</param>
    public GraphQlAuthorizationMessageHandler(
        IAccessTokenProvider provider,
        NavigationManager navigation,
        IOptions<GraphQlAuthorizationMessageHandlerSettings> configuration)
    {
        _provider = provider;

        // Invalidate the cached _lastToken when the authentication state changes
        if (_provider is AuthenticationStateProvider authStateProvider)
        {
            _authenticationStateChangedHandler = _ => { _lastToken = null; };
            authStateProvider.AuthenticationStateChanged += _authenticationStateChangedHandler;
        }

        ConfigureHandler(configuration.Value.AuthorizedUrls, configuration.Value.Scopes, configuration.Value.RedirectUri);
    }

    /// <inheritdoc />
    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.");
        }

        bool userAuthenticated = false;
        // check if user is authenticated
        if (_provider is AuthenticationStateProvider authStateProvider)
        {
            userAuthenticated = (await authStateProvider.GetAuthenticationStateAsync()).User.Identity?.IsAuthenticated == true;
        }
        
        if (userAuthenticated && _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);
                }
                else
                {
                    Console.WriteLine("Failed to get token");
                }
            }

            // 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);
    }

    /// <summary>
    /// Configures this handler to authorize outbound HTTP requests using an access token. The access token is only attached if at least one of
    /// <paramref name="authorizedUrls" /> is a base of <see cref="HttpRequestMessage.RequestUri" />.
    /// </summary>
    /// <param name="authorizedUrls">The base addresses of endpoint URLs to which the token will be attached.</param>
    /// <param name="scopes">The list of scopes to use when requesting an access token.</param>
    /// <param name="returnUrl">The return URL to use in case there is an issue provisioning the token and a redirection to the
    /// identity provider is necessary.
    /// </param>
    /// <returns>This <see cref="AuthorizationMessageHandler"/>.</returns>
    public GraphQlAuthorizationMessageHandler 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;
    }

    void IDisposable.Dispose()
    {
        if (_provider is AuthenticationStateProvider authStateProvider)
        {
            authStateProvider.AuthenticationStateChanged -= _authenticationStateChangedHandler;
        }
        Dispose(disposing: true);
    }
}

Now the ConfigureHandler method is called from within the constructor so it is needed also to Configure the new options like that:

builder.Services.Configure<GraphQlAuthorizationMessageHandlerSettings>(config =>
{
    config.AuthorizedUrls = new [] { "http://localhost:5000/graphql" };
});
Wiktor
  • 754
  • 1
  • 7
  • 24
  • But how does it work for unauthenticated users? – Dan Friedman Feb 23 '23 at 22:12
  • @DanFriedman You are right, it only works for authenticated. For unauthenticated users, there is a need to add a lot of boilerplate code. I added an example of a handler based on the existing AuthorizationMessageHandler. It's not an ideal solution, but I couldn't see a better way of doing this with the current implementation of DelegationHandler and Request pipeline. – Wiktor Feb 24 '23 at 17:13