Search code examples
blazer

How to force a Blazer server application back to the login page when the auth expires


I have a Blazer server application where I have been tasked with implementing a simple authentication scheme, using our own login page. I've not done this before in a Blazer app, so starting afresh.

I followed this tutorial, and at the top of each page I have a @attribute [Authorize]. The login and logout works fine. After the Cookie timeout (e.g. from here)

 builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
 {
   options.LoginPath = Constants.Routes.LOGIN;
   options.LogoutPath = Constants.Routes.LOGOUT;
   options.Cookie.Name = Constants.AUTH_COOKIE_NAME;       
   options.ExpireTimeSpan = TimeSpan.FromSeconds(10); // for testing
  // options.SlidingExpiration = true;
   options.AccessDeniedPath = Constants.Routes.LOGIN; 
 });

In the above I have just set to 10 seconds for testing.

If I try to navigate to another page, then I am redirected to the login page, which is exactly what I want.

The problem is, if I have a page open, and just leave it sitting there, it does NOT redirect to the login page once the expiration expired.

I Initially just put a timer in MainLayout.razor to monitor and then manually do the redirect, but to my horror, MainLayout.razor is actually disposed even though you can still see its DOM elements in devtools! Bizarre!

So I added a scoped service to add the timer, to check for the authorization state, where I constructor inject AuthenticationStateProvider. IN the timer I call AuthenticationStateProvider.GetAuthenticationStateAsync() to see when the auth has expired.

First problem was I found that even after my timeout, the user returned from GetAuthenticationStateAsync was still set to IsAuthorized as true! I tried all sort of schemes to refresh this, but nothing worked.

So I thought I would add the expiry time as one of the Claims, so I have...

 DateTime expiry = DateTime.UtcNow.AddSeconds(10);

  // We just need to add something, as we are not supporting usernames or roles initially
  var claims = new List<Claim>()
  {
    new(ClaimTypes.Name, Constants.DEFAULT_AUTH_USER),
    new(ClaimTypes.Role, Constants.DEFAULT_AUTH_ROLE),
    new(ClaimTypes.Expiration, expiry.ToString("o", CultureInfo.InvariantCulture)) // <---- expiry
  };

  var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
  var principal = new ClaimsPrincipal(identity);
  await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);

  // NavigateTo will throw an exception. We need to allow this to bubble up for this to work!
  NavigationManager.NavigateTo("/", true);
}

So, that got around that problem, in my service I can now retrieve an react to this expiry time.

The last problem now is how to navigate to the login page from within the service?

I have of course injected NavigationManager but the NavigationManager.NavigateTo() just throws an exception. Perhaps as the timer is not on the main thread.

I then tried to inject IJSRuntime to run some JavaScript to click the logout button.I am not sure if this would have got to the server, but I found that JS methods cannot be called from within a scoped service (the JS function was just not called - and may not have worked anyway).

I then read a post somewhere (there are so many posts I read I have lost track) tried to inject a IServiceProvider, get a component, and then perhaps call a method on the component to logout out.

Of course I wanted to get something guaranteed to be there as we have no idea what page may be active. Getting the MainLayout is no good as it is gone (even though it is still in the DOM - bizarre).

As test I then tried to get just the HomePage while on the home page, and this time I find that IServiceProvider is disposed!

I tried adding the following...

 builder.Services.TryAddEnumerable(
   ServiceDescriptor.Scoped<CircuitHandler, UserCircuitHandler>());

as mentioned in some of the MS documentation, injecting the AuthenticationStateProvider into here, and watching out for the AuthenticationStateChanged event, but this just never fired, then was not sure what I could do within here anyway.

I am now out of ideas, all I want to do is go to the login page when the auth expires.

Would anyone know how to have this occur either automatically, or how I can manually navigate to the login page?


Solution

  • We got some semblance of auto logout redirection working with a combination of classes and services. Some pointers that helped get us in the right direction.

    Stuff we pulled into the Services configuration:

      builder.Services.AddHttpContextAccessor();
      builder.Services.AddScoped<AuthStateService>();
      builder.Services.AddScoped<AuthenticationStateProvider, CookieAuthStateProvider>();
      builder.Services.TryAddEnumerable(ServiceDescriptor.Scoped<CircuitHandler, AuthCircuitHandler>());
    
    1. Referring to the "Circuit handler to capture users for custom services" in this Microsoft Doco - this was actually really useful. Snippet:

        ...
        public override Task OnCircuitOpenedAsync(Circuit circuit,
            CancellationToken cancellationToken)
        {
          m_authenticationStateProvider.AuthenticationStateChanged +=
              AuthenticationChanged;
      
          return base.OnCircuitOpenedAsync(circuit, cancellationToken);
        }
      
        // internal handler for Authentication State change
        private void AuthenticationChanged(Task<AuthenticationState> task)
        {
          _ = UpdateAuthentication(task);
      
          async Task UpdateAuthentication(Task<AuthenticationState> task)
          {
              AuthenticationState state = await task; 
              // notify that authentication state has changed
              m_authStateService.AuthenticationStateChanged(state);
            }
          }
        }
        ...
      
    2. Implemented a CookieAuthStateProvider that extends RevalidatingServerAuthenticationStateProvider, overriding ValidateAuthenticationStateAsync to check the Cookie expiration using HttpContextAccessor - returning false if cookie is expired. Not sure if the HttpContextAccessor will work in general - but it has worked well enough for our limited use case.

      protected override Task<bool> ValidateAuthenticationStateAsync(AuthenticationState authenticationState, CancellationToken cancellationToken)
      {
        HttpContext context = HttpContextAccessor.HttpContext;
      
        if (context == null)
          return Task.FromResult(false);
      
        var opt = context.RequestServices
          .GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
          .Get(CookieAuthenticationDefaults.AuthenticationScheme);
      
        var cookie = opt.CookieManager.GetRequestCookie(context, Constants.AUTH_COOKIE_NAME);
      
        var val = opt.TicketDataFormat.Unprotect(cookie);
      
        // authentication is valid if it has not yet expired
        return Task.FromResult(val.Properties.ExpiresUtc.HasValue && val.Properties.ExpiresUtc > DateTime.UtcNow);
      }
      
    3. In the AuthStateService implementation expose an event handler for authentication state change.

        internal class AuthStateService
        {
          internal event Action<AuthenticationState> OnAuthenticationStateChange;
      
          internal void AuthenticationStateChanged(AuthenticationState state)
          {
            OnAuthenticationStateChange?.Invoke(state);
          }
        }
      
    4. In Razor component, inject AuthStateService, and subscribe to NavigationManager.NavigateTo method to go wherever needed when AuthenticationState is false.

      @inject AuthStateService AuthService;
      @inject NavigationManager NavigationManager
      
      protected override void OnAfterRender(bool firstRender)
      {
        if (firstRender)
        {
          AuthService.OnAuthenticationStateChange += AuthServiceOnOnAuthenticationChange;
        }
      }
      
      private void AuthServiceOnOnAuthenticationChange(AuthenticationState obj)
      {
        if (obj.User?.Identity?.IsAuthenticated == false)
          NavigationManager.NavigateTo('/login', true);
      }
      

    This feels like a lot of indirection. Maybe this could be simplified further, ¯_(ツ)_/¯, but for now it has met our needs.