Securing Blazor WebAssembly Apps

I think what we ended up with is mostly based on Auth0 ASP.NET Core Web API SDK Quickstarts: Authorization

1 Like

Thanks for sharing that with the rest of community!

@Tim_Bassett I donā€™t know am I suppose to do that. Because the same API is used by two different Angular applications. It may effect the existing application. Is there any way to do that from blazor client side application by implementing Custom Authorization Message Handler?
Anyhow I will give a try this option too.

@Tim_Bassett I have resolved it on my own trails by implementing a few custom handlers. I have taken a AuthorizationMessageHandler.cs class file and added it in my wasm client project with CustomAuthorizationMessageHandler.cs this name and modified the SendAsync method with my own logic to get identity token from the session storage and adding that token in the request authorization header.

Code Snippet: CustomAuthorizationMessageHandler.cs

public class CustomAuthorizationMessageHandler : DelegatingHandler
{
    private readonly IAccessTokenProvider _provider;
    private readonly NavigationManager _navigation;
    private readonly IJSRuntime jsRuntime;
    private AuthenticationHeaderValue _cachedHeader;
    private Uri[] _authorizedUris;
    private AccessTokenRequestOptions _tokenOptions;
    private CachedAuthSettings authSettings;
    private CachedOidcUser _user;
    private DateTime? _tokenExpiresAt;



    /// <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 CustomAuthorizationMessageHandler(
        IAccessTokenProvider provider,
        NavigationManager navigation,
        IJSRuntime jsRuntime)
    {
        _provider = provider;
        _navigation = navigation;
        this.jsRuntime = jsRuntime;
    }

    /// <inheritdoc />
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var now = DateTimeOffset.Now;
        var utcDefaultDateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc);

        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 (_user == null || _tokenExpiresAt == null || now >= _tokenExpiresAt?.AddMinutes(-5))
            {
                string key = "Microsoft.AspNetCore.Components.WebAssembly.Authentication.CachedAuthSettings";
                string authSettingsRAW = await jsRuntime.InvokeAsync<string>("sessionStorage.getItem", key);
                authSettings = JsonSerializer.Deserialize<CachedAuthSettings>(authSettingsRAW);
                string userRAW = await jsRuntime.InvokeAsync<string>("sessionStorage.getItem", authSettings?.OidcUserKey);
                var cachedUser = JsonSerializer.Deserialize<CachedOidcUser>(userRAW);

                if (!string.IsNullOrEmpty(cachedUser?.IdentityToken))
                {
                    var handler = new JwtSecurityTokenHandler();
                    var decodedValue = handler.ReadJwtToken(cachedUser?.IdentityToken);
                    _tokenExpiresAt = decodedValue.ValidTo.ToLocalTime();
                    _user = cachedUser;
                    _cachedHeader = new AuthenticationHeaderValue("Bearer", _user.IdentityToken);
                }
                else
                {
                    var tokenResult = _tokenOptions != null ?
                        await _provider.RequestAccessToken(_tokenOptions) :
                        await _provider.RequestAccessToken();

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

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

And then I have created a ConfiguredAuthorizationMessageHandler.cs class which is derived from CustomAuthorizationMessageHandler class for setting authorized URLs.
Code Snippet: ConfiguredAuthorizationMessageHandler.cs

public class ConfiguredAuthorizationMessageHandler : CustomAuthorizationMessageHandler
{
    public ConfiguredAuthorizationMessageHandler(IAccessTokenProvider provider,
        NavigationManager navigationManager, IJSRuntime jsRuntime, IConfiguration configuration)
        : base(provider, navigationManager, jsRuntime)
    {
        ConfigureHandler(
           authorizedUrls: new[] { configuration["ApiBaseUrl"] }
           );
    }
}

After that, I have injected those classes into the Program Main method.
Code Snippet: Program.cs

        builder.Services.AddScoped<ConfiguredAuthorizationMessageHandler>();

        builder.Services.AddHttpClient("ServerAPI", client => client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]))
           .AddHttpMessageHandler<ConfiguredAuthorizationMessageHandler>();

        builder.Services.AddScoped<ITestService>(provider =>
                new TestService(provider.GetService<IHttpClientFactory>().CreateClient("ServerAPI")));

I have created two models for serializing session storage JSON data.

Code Snippet: CachedAuthSettings.cs and CachedOidcUser.cs

public class CachedAuthSettings
{
    [JsonPropertyName("authority")]
    public string Authority { get; set; }
    [JsonPropertyName("metadataUrl")]
    public string MetadataUrl { get; set; }
    [JsonPropertyName("client_id")]
    public string ClientId { get; set; }
    [JsonPropertyName("defaultScopes")]
    public IEnumerable<string> DefaultScopes { get; set; }
    [JsonPropertyName("redirect_uri")]
    public string RedirectUri { get; set; }
    [JsonPropertyName("post_logout_redirect_uri")]
    public string PostLogoutRedirectUri { get; set; }
    [JsonPropertyName("response_type")]
    public string ResponseType { get; set; }
    [JsonPropertyName("response_mode")]
    public string ResponseMode { get; set; }
    [JsonPropertyName("scope")]
    public string Scope { get; set; }

    public string OidcUserKey => $"oidc.user:{Authority}:{ClientId}";
}

public class CachedOidcUser
{
    [JsonPropertyName("id_token")]
    public string IdentityToken { get; set; }
    [JsonPropertyName("access_token")]
    public string AccessToken { get; set; }
    [JsonPropertyName("refresh_token")]
    public string RefreshToken { get; set; }
    [JsonPropertyName("token_type")]
    public string TokenType { get; set; }
    [JsonPropertyName("scope")]
    public string Scope { get; set; }
    [JsonPropertyName("expires_at")]
    public int ExpiresAt { get; set; }
}

Reference:

  1. Reading id_token from session storage.
  2. Configuring Authorized URLs
  3. JWT token decoder
  4. WebAssembly.Authentication Repo -Microsoft
1 Like

Wait @Prasanth_Reddy, Iā€™m not sure about what you are trying to accomplish, but only access tokens should be used to call APIs. ID tokens are meant just to prove that the user has been authenticated and they may also provide userā€™s data. ID tokens are not meant to call APIs

1 Like

Ohh my bad @andrea.chiarelli, till now we are using id_token for authenticating the user which is not supposed to do.
image

We have implemented our backend API to accept id_token instead of the access token. Thatā€™s the reason I have configured it in that way. Thanks for letting me know about security. Please share if there are any documents to configure access token authentication at the API end and why we are not supposed to use id_token. These can help us to take a step ahead, Thanks in Advance.

You can start from this document to get an overview about tokens.

2 Likes

This topic was automatically closed 27 days after the last reply. New replies are no longer allowed.

I started with a blank .NET 6 blazor wasm project (hosted) and followed your steps. All ok until I have secured the API.
Even if I added in program.cs the AddHttpClient I get always a 401 and the token is not passed (I donā€™t have any authorization: response.header)

How can I be sure that in my quiz page Iā€™m using the ā€œrightā€ ā€œServerApiā€ httpClient that append the token and not the usual one?

thanks

Hey @SandroRiz, welcome to the Auth0 Community! :wave:

Before trying to guess whatā€™s the problem with your application, I warn you that the sample project accompanying my article was tested with .NET 5.0, so I canā€™t guarantee that everything goes smoothly even with .NET 6.0.
Anyway, in the next few weeks, we will update it to .NET 6.0.

Based on what you tell me, especially if you are not getting any error, it looks like you defined a named HTTP client but are not using it to make your requests to the server.

Please, make sure that the name you assigned to the client at definition time (AddHttpClient ()) matches the name at creation time (CreateClient ()). Consider that the name is case sensitive (ā€œServerAPIā€ != ā€œServerApiā€).

In a way, I hope this is the case :slightly_smiling_face:

unfortunately the snippet in the program.cs is ok (I copied yours :slight_smile: )
in the quizviewer.razor you says
@inject HttpClient Http
is a new addition (it is inside the comment with the pointing-hand) but that inject was already there in the previous stepsā€¦ am I missing something ?

Hey all,
Iā€™ve done a bit of a deep dive into the Blazor + Auth0 logout issues that a lot of people, including myself, are plagued with and my findings are here along with a workaround for .NET 6:

Iā€™ll put up an API proposal and hopefully the extended metadata settings will be available out the box for .NET 7.

1 Like

in the quizviewer.razor you says
@inject HttpClient Http
is a new addition (it is inside the comment with the pointing-hand) but that inject was already there in the previous steps

Honestly, Iā€™m not sure I understand correctly :thinking:
You say it was there from the previous steps, but I canā€™t see another place where this is done in the article.
I find just two places where you should add @inject HttpClient Http: in Client/Pages/QuizViewer.cs and in Client/Pages/QuizViewer.razor

Aside question: have you tried to download and run the sample app project?
If not, please try it. Just to be sure itā€™s not a matter of code.

Hey @Hawxy, thank you so much for going deep on this issue. :pray: Thatā€™s a great news! :tada:

Hi Andrea, thanks for your patience and support

you donā€™t have a quizviewer.cs (you use @code inside the razor component)
Check my screenshot please about my perplexity

here if you want my Project (is the standard template where I secured weather service)
https://github.com/microgate-it/BlazorWithAuth0

About your sample project I downloaded and changed the 2 appsettings but I had another issue (both with 5 or 6 and with/without a default audience)

Thanks again

Have you tried deleting this line?
https://github.com/microgate-it/BlazorWithAuth0/blob/master/BlazorWasmWithAuth0/Client/Program.cs#L19

The call to builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>() .CreateClient("ServerAPI")); above will cause the default HttpClient resolved to be the one that includes the bearer token.

2 Likes

Hi @SandroRiz, in my experience, the error you get usually depends on a wrong domain in the appsettings.json. Please, double check it is written correctly.

Regarding the missing token in your HTTP requests, please, consider @Hawxyā€™s suggestion.

Sorry for the misunderstanding on the code. I will plan to correct the article.

You got it !! Iā€™m very dumb to not see what was below :slight_smile: thanks a lot

I have to say that I must add Default Audience even with .NET 6, so maybe the bug is not fixed

1 Like

I have to say that I must add Default Audience even with .NET 6, so maybe the bug is not fixed

The audience has to be passed via an additional parameter:
options.ProviderOptions.AdditionalProviderParameters.Add("audience", builder.Configuration["Auth0:Audience"]);

3 Likes