Search code examples
c#authenticationazure-active-directoryblazorblazor-webassembly

Azure MSAL Authentication and custom JWT Authentication in Blazor


I'm trying to add Azure MSAL Authentication to an existing Blazor WASM application that already handles authentication with JWT. But if I register a custom AuthenticationStateProvider I use, blazor throw at runtime:

Unhandled exception rendering component: Specified cast is not valid. System.InvalidCastException: Specified cast is not valid.

Looking through sources, It seems it throws because an MSAL class expects that IServiceProvider returns an IRemoteAuthenticationService<TRemoteAuthenticationState> as AuthenticationStateProvider see WebAssemblyAuthenticationServiceCollectionExtensions at line 65

That's the AuthenticationStateProvider implementation I use:

public class ApiAuthenticationStateProvider : AuthenticationStateProvider
{
    private readonly HttpClient _httpClient;
    private readonly ILocalStorageService _localStorage;

    public ApiAuthenticationStateProvider(AuthenticationHttpClient authHttpClient, ILocalStorageService localStorage)
    {
        _httpClient = authHttpClient.HttpClient;
        _localStorage = localStorage;
    }
    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var savedToken = await _localStorage.GetItemAsync<string>("authToken");

        if (string.IsNullOrWhiteSpace(savedToken))
        {
            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
        }

        _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", savedToken);

        return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(savedToken), "jwt")));
    }

    public void MarkUserAsAuthenticated(string email)
    {
        var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, email) }, "apiauth"));
        var authState = Task.FromResult(new AuthenticationState(authenticatedUser));
        NotifyAuthenticationStateChanged(authState);
    }

    public void MarkUserAsLoggedOut()
    {
        var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
        var authState = Task.FromResult(new AuthenticationState(anonymousUser));
        NotifyAuthenticationStateChanged(authState);
    }

    private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
    {
        var claims = new List<Claim>();
        var payload = jwt.Split('.')[1];
        var jsonBytes = ParseBase64WithoutPadding(payload);
        var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);

        keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles);

        if (roles != null)
        {
            if (roles.ToString().Trim().StartsWith("["))
            {
                var parsedRoles = JsonSerializer.Deserialize<string[]>(roles.ToString());

                foreach (var parsedRole in parsedRoles)
                {
                    claims.Add(new Claim(ClaimTypes.Role, parsedRole));
                }
            }
            else
            {
                claims.Add(new Claim(ClaimTypes.Role, roles.ToString()));
            }
            keyValuePairs.Remove(ClaimTypes.Role);
        }

        claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));

        return claims;
    }

    private byte[] ParseBase64WithoutPadding(string base64)
    {
        switch (base64.Length % 4)
        {
            case 2: base64 += "=="; break;
            case 3: base64 += "="; break;
        }
        return Convert.FromBase64String(base64);
    }
}

Which I register as follows: builder.Services.AddScoped<AuthenticationStateProvider, ApiAuthenticationStateProvider>();

Is there a way to solve the issue ? I've looked at this other SO question but I'm not been able to solve.


Solution

  • While using AuthenticationStateProvider one of the appropriate System.Security.Claims.AuthenticationTypes values must be added as a parameter along with claimsIdentity.

    i.e; To you blazor app you will have to add authenticationType parameter value with ClaimsIdentity so your code will be changed to: like

    var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(claims, "Needs Auth Type Here"));
    

    example:

    public class ApiAuthenticationStateProvider : AuthenticationStateProvider
    {
       public override Task<AuthenticationState> GetAuthenticationStateAsync()
       {
           ....
                   
           var claims = new[] { new Claim(ClaimTypes.Name, "[email protected]") };
           var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthenticationTypes.Password));
           var authState = Task.FromResult(new AuthenticationState(authenticatedUser));
           ...
    
           return Task.FromResult(authState);
       }
    }
    

    Note the AuthenticationTypes.Password parameter in the above code for ClaimsIdentity. Check to change it in all places wherever ClaimsIdentity is constructed.

    If needed check the auth type of user

    if (User.Identity.AuthenticationType == AuthenticationTypes.Password) {
        // ...
    }
    

    So that you can be able to login successfully.

    Or try using RemoteAuthenticationService as said in https://github.com/dotnet/aspnetcore/issues

    enter image description here

    Reference: .net core - Custom AuthenticationStateProvider Authentication Failing - Stack Overflow