Gleb Skripnikov
Gleb Skripnikov

Reputation: 301

Blazor Client (Web Assembly) AuthenticationState updates only after page reloading

I have a problem with Blazor authentication. I have AuthenticationStateProvider implementation and everything works fine, but after login or logout I need to manually refresh page to update AuthenticationState.

For example I have a Profile.razor page component with @attribute [Authorize]. I can't open this page after login, like I'm not authorized, but after page reloading everything is fine. Same thing with logout.

I suspect that NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()) does nothing, but I can't understand what is wrong.

TokenAuthenticationStateProvider.cs - Implementation of AuthenticationStateProvider

public class TokenAuthenticationStateProvider : AuthenticationStateProvider
{
    private readonly TokenStorage tokenStorage;

    public TokenAuthenticationStateProvider(TokenStorage tokenStorage)
    {
        this.tokenStorage = tokenStorage;
    }

    public void StateChanged()
    {
        NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); // <- Does nothing
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var token = await tokenStorage.GetAccessToken();
        var identity = string.IsNullOrEmpty(token)
            ? new ClaimsIdentity()
            : new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt");
        return new AuthenticationState(new ClaimsPrincipal(identity));
    }

    private static IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
    {
        var payload = jwt.Split('.')[1];
        var jsonBytes = ParseBase64WithoutPadding(payload);
        var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
        return keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()));
    }

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

TokenStorage.cs - Access and Refresh tokens storage

public class TokenStorage
{
    private readonly ILocalStorage localStorage;

    public TokenStorage(
        ILocalStorage localStorage)
    {
        this.localStorage = localStorage;
    }

    public async Task SetTokensAsync(string accessToken, string refreshToken)
    {
        await localStorage.SetItem("accessToken", accessToken);
        await localStorage.SetItem("refreshToken", refreshToken);
    }

    public async Task<string> GetAccessToken()
    {
        return await localStorage.GetItem<string>("accessToken");
    }

    public async Task<string> GetRefreshToken()
    {
        return await localStorage.GetItem<string>("refreshToken");
    }

    public async Task RemoveTokens()
    {
        await localStorage.RemoveItem("accessToken");
        await localStorage.RemoveItem("refreshToken");
    }
}

AccountService.cs - Service with login and logout methods. I call authState.StateChanged() to update AuthenticationState

public class AccountService
{
    private readonly TokenStorage tokenStorage;
    private readonly HttpClient httpClient;
    private readonly TokenAuthenticationStateProvider authState;
    private readonly string authApiUrl = "/api/authentication";

    public AccountService(
        TokenStorage tokenStorage,
        HttpClient httpClient,
        TokenAuthenticationStateProvider authState)
    {
        this.tokenStorage = tokenStorage;
        this.httpClient = httpClient;
        this.authState = authState;
    }

    public async Task Login(LoginCredentialsDto credentials)
    {
        var response = await httpClient.PostJsonAsync<AuthenticationResponseDto>($"{authApiUrl}/login", credentials);
        await tokenStorage.SetTokensAsync(response.AccessToken, response.RefreshToken);
        authState.StateChanged();
    }

    public async Task Logout()
    {
        var refreshToken = await tokenStorage.GetRefreshToken();
        await httpClient.GetJsonAsync<AuthenticationResponseDto>($"{authApiUrl}/logout/{refreshToken}");
        await tokenStorage.RemoveTokens();
        authState.StateChanged();
    }
}

App.razor

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly" Context="routeData">
        <Found>
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    <h1>Not authorized!</h1>
                </NotAuthorized>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Profile.razor

@page "/profile/{UserName}"
@attribute [Authorize]

<h1>Profile</h1>

@code {
    ...
}

Startup.cs - Client Startup

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddValidatorsFromAssemblyContaining<LoginCredentialsDtoValidator>();
        services.AddStorage();

        services.AddScoped<TokenStorage>();
        services.AddScoped<AccountService>();

        services.AddScoped<TokenAuthenticationStateProvider>();
        services.AddScoped<AuthenticationStateProvider, TokenAuthenticationStateProvider>();

        services.AddAuthorizationCore();
    }

    public void Configure(IComponentsApplicationBuilder app)
    {
        app.AddComponent<App>("app");
    }
}

Upvotes: 9

Views: 7011

Answers (2)

Robert McLaws
Robert McLaws

Reputation: 2622

The reason it didn't work is because you're relying on DI to do the instantiation work for you, and both calls create separate instances of the same provider.

If you want to do it right, try this:

var provider = new TokenAuthenticationStateProvider();
services.AddSingleton(c => provider);
services.AddSingleton<AuthenticationStateProvider>(c => provider);

That way, no matter how you resolve the service, you'll get the same instance. If this is a client-side app, you don't need a Scoped instance because the application is running locally inside a single browser window.

HTH!

Upvotes: 0

Gleb Skripnikov
Gleb Skripnikov

Reputation: 301

I found my mistake. The problem was in Startup.cs file on client side.

Instead of:

services.AddScoped<TokenAuthenticationStateProvider>();
services.AddScoped<AuthenticationStateProvider, TokenAuthenticationStateProvider>();

I need to register my services this way:

services.AddScoped<TokenAuthenticationStateProvider>();
services.AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<TokenAuthenticationStateProvider>());

Now everything works!

Upvotes: 11

Related Questions