Call WebApi with JWT Token from Blazor WebAssembly

Viewed 34

I am getting an unexpected Unauthorized response from an Api when using JWT in Blazor WebAssembly. Note, I am not trying to secure anything on the WebAssembly client; just the API endpoint. I have deliberately left out expiry validation.

Server

appsettings.json

{
  "JwtSecurity": {
    "Key": "RANDOM_KEY_MUST_NOT_BE_SHARED",
    "Issuer": "https://localhost",
    "Audience": "https://localhost",
    "ExpiryDays": 1
  }
}

Program.cs

// Service registration
builder.Services
    .AddAuthentication(auth =>
    {
        auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =>
    {
        options.SaveToken = true;
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["JwtSecurity:Issuer"],
            ValidateAudience = true,
            ValidAudience = builder.Configuration["JwtSecurity:Audience"],
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["JwtSecurity:Key"])),
            RequireExpirationTime = false,
            ValidateLifetime = false
        };
    });

// Configure the HTTP request pipeline.

// SignalR Compression
app.UseResponseCompression();

if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

// Logs the received token
app.UseJwtTokenHandler();

//explicitly only use blazor when the path doesn't start with api
app.MapWhen(ctx => !ctx.Request.Path.StartsWithSegments("/api"), blazor =>
{

    blazor.UseBlazorFrameworkFiles();
    blazor.UseStaticFiles();

    blazor.UseRouting();

    blazor.UseEndpoints(endpoints =>
    {
        endpoints.MapHub<Cosmos.App.Server.Hubs.TillSiteHub>("/tradingsessionhub");
        endpoints.MapFallbackToFile("index.html");
    });
});

//explicitly map api endpoints only when path starts with api
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), api =>
{
    api.UseStaticFiles();
    api.UseRequestLogging();

    api.UseRouting();
    api.UseAuthentication();
    api.UseAuthorization();
    // HAVE ALSO TRIED
    // api.UseAuthentication();
    // api.UseRouting();
    // api.UseAuthorization();

    api.UseErrorHandling();

    api.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
});

app.Run();
  • This link suggests UseRouting should come before UseAuthentication and UseAuthorisation.
  • This link suggests UseRouting should come between them.

Have tried both to no avail.

Token Generation on Login

Helper Class

public class JwtHelper
{
    public static JwtSecurityToken GetJwtToken(
        string username,
        string signingKey,
        string issuer,
        string audience,
        TimeSpan expiration,
        Claim[] additionalClaims = null)
    {
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub,username),
            // this guarantees the token is unique
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        if (additionalClaims is object)
        {
            var claimList = new List<Claim>(claims);
            claimList.AddRange(additionalClaims);
            claims = claimList.ToArray();
        }

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        return new JwtSecurityToken(
            issuer: issuer,
            audience: audience,
            expires: DateTime.UtcNow.Add(expiration),
            claims: claims,
            signingCredentials: creds
        );
    }
}

Controller Method for Login

    Guid userGid = await loginManager.LoginAsync(request.Email!, request.Password!);

    if (userGid == default)
    {
        return base.NotFound();
    }

    List<Claim> claims = new List<Claim>();
    claims.Add(new Claim(ClaimTypes.NameIdentifier, userGid.ToString()));
    claims.Add(new Claim(ClaimTypes.Name, userGid.ToString()));

    string key = configuration["JwtSecurity:Key"];
    string issuer = configuration["JwtSecurity:Issuer"];
    string audience = configuration["JwtSecurity:Audience"];
    string expiryDays = configuration["JwtSecurity:ExpiryDays"];
    TimeSpan expiry = TimeSpan.FromDays(Convert.ToInt32(expiryDays));

    var token = JwtHelper.GetJwtToken(
        userGid.ToString(),
        key,
        issuer,
        audience,
        expiry,
        claims.ToArray());

    LoginResponse response = new(new JwtSecurityTokenHandler().WriteToken(token));

    return base.Ok(response);

The token string response is stored in Local Storage.

Client

Adding header to Http Client

// GetTokenAsync retrieve the string from Local Storage
_httpClient.DefaultRequestHeaders.Authorization =
    new System.Net.Http.Headers.AuthenticationHeaderValue(
        "bearer",
        (await _accessControlStateManager.GetTokenAsync())!.Write());

Server Logging of Received Token by middleware

public class JwtTokenHandlerMiddleware
{
    readonly RequestDelegate _next;
    readonly ILogger _logger;

    public JwtTokenHandlerMiddleware(
        RequestDelegate next,
        ILoggerFactory loggerFactory)
    {
        _next = next;
        _logger = loggerFactory.CreateLogger(typeof(JwtTokenHandlerMiddleware).FullName!);
    }

    public async Task Invoke(HttpContext context)
    {
        JwtSecurityToken? jwt = context.GetJwtTokenFromAuthorizationHeader();

        if (jwt != null)
        {
            _logger.LogInformation("Request received with token: {uri}", context.Request.GetDisplayUrl());
            _logger.LogInformation("Token: {token}", jwt.Write());

        }
        else
        {
            _logger.LogInformation("Request received from ANONYMOUS: {uri}", context.Request.GetDisplayUrl());
        }

        await _next(context);
    }
}

        public static JwtSecurityToken? GetJwtTokenFromAuthorizationHeader(this HttpContext httpContext)
        {
            string text = httpContext.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last();
            if (string.IsNullOrWhiteSpace(text))
            {
                return null;
            }

            return new JwtSecurityTokenHandler().ReadJwtToken(text);
        }
  • I have confirmed from the logs that the JWT is being received
  • Using jwt.io I can confirm that the token logged in the request middleware can be read

jwt.io output

enter image description here

Given all of the above, it seems that:

  • I am generating JWT correcly.
  • JWT is being stored and retrieved correctly from Local Storage
  • JWT is being received in the Authorization header in the request
  • The controller is secured at Controller level using Authorize attribute (no roles mentioned)

But still I get Unauthorized.

Any advice?

Attempts to Isolate

Disable validation parameters

Tried:

options.TokenValidationParameters = new TokenValidationParameters
{
    ValidateIssuer = false,
    ValidateAudience = false,
    ValidateIssuerSigningKey = false,
    RequireExpirationTime = false,
    ValidateLifetime = false
};

And added simple test controller:

using Microsoft.AspNetCore.Authorization;

namespace Cosmos.App.Server.Controllers;
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class TestController : ControllerBase
{
    [HttpGet]
    [Route("test")]
    public async Task<IActionResult> TestAsync()
    {
        await Task.Delay(1);

        return Ok("Hello");
    }
}

But same issue.

1 Answers

Found it!

The issue was that I was caching the returned string from login as a Token so I could quickly access claims in the client (whilst saving the string received from login in LocalStorage).

Then, when putting the token on the HttpClient, if I had a cached token, I was writing it out to string to populate the Authorization on the Http Request.

The problem is that the string received from the initial login, e.g.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5NGU2YWZhNy00NGYwLTRlNTUtODgxMy0xMTRmNGY1OWE2NzIiLCJqdGkiOiI2MWYwYTRiMi0wNjQwLTRiMjgtYmM2Mi0zMDZlYTVmYmJiM2UiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6Ijk0ZTZhZmE3LTQ0ZjAtNGU1NS04ODEzLTExNGY0ZjU5YTY3MiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiI5NGU2YWZhNy00NGYwLTRlNTUtODgxMy0xMTRmNGY1OWE2NzIiLCJleHAiOjE2NjM5NjEwMjMsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0IiwiYXVkIjoiaHR0cHM6Ly9sb2NhbGhvc3QifQ.5l9LRYIx3wXruW7BMa1DDbEoltVgP6Fbfkc2O03XAAY

was truncated when reading back into a token for caching. It appears to have truncated what I presume is the signing key. The following was missing:

5l9LRYIx3wXruW7BMa1DDbEoltVgP6Fbfkc2O03XAAY

This meant I had the full string stored in local storage but a cached token without the signing key.

When then used the cached token to write to string for the authorization header I got:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5NGU2YWZhNy00NGYwLTRlNTUtODgxMy0xMTRmNGY1OWE2NzIiLCJqdGkiOiI2MWYwYTRiMi0wNjQwLTRiMjgtYmM2Mi0zMDZlYTVmYmJiM2UiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6Ijk0ZTZhZmE3LTQ0ZjAtNGU1NS04ODEzLTExNGY0ZjU5YTY3MiIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiI5NGU2YWZhNy00NGYwLTRlNTUtODgxMy0xMTRmNGY1OWE2NzIiLCJleHAiOjE2NjM5NjEwMjMsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0IiwiYXVkIjoiaHR0cHM6Ly9sb2NhbGhvc3QifQ.

without the suffix of the signing key.

This meant that the validation of that string failed authorization on the server, even though jwt.io would happily read it.

Using the full string that I'd stored in Local Storage instead of a 'written' string from the cached token solved the problem.

Related