5

Microsoft recommend against using HttpContext in Blazor Server (here). To work around the issue of how to pass user tokens to a Blazor Server app, Microsoft recommend storing the tokens in a Scoped service (here). Jon McGuire’s blog suggests a similar approach that stores the tokens in Cache (here).

Microsoft’s approach above works just fine as long as the user stays within the same Blazor Server connection. However if the access_token is refreshed and the user then reloads the page either by pressing F5 or by pasting a URL into the address bar, then an attempt is made to retrieve the tokens from the cookie. By this time, the access_token and refresh_token in the cookie are no longer valid. Jon McGuire mentions this problem at the end of his blog post and refers to it as Stale Cookies (here). He gives hints about a possible solution, but is very light on implementation instructions. There are many comments at the bottom of that post from people unable to implement a solution, with no apparent working solution suggested. I spent a lot of time searching for a solution and all I found were people asking for one and not receiving any answers that worked.

Having found a solution that seems to work well, and also seems fairly principled, I thought it might be worth sharing my solution here. I would welcome any constructive criticism or suggestions for any significant improvements.

Rob
  • 1,214
  • 1
  • 11
  • 20

1 Answers1

4

Edit 20220715: After some feedback on our approach from Dominic Baier we removed our Scoped UserSubProvider service in favour of using AuthenticationStateProvider instead. This has simplified our approach. I have edited the following answer to reflect this change.


This approach combines advice from Microsoft on how to pass tokens to a Blazor Server app (here), with server side storage of tokens in a Singleton service for all users (inspired by Dominick Baier’s Blazor Server sample project on GitHub here).

Instead of capturing the tokens in the _Host.cshtml file and storing them in a Scoped service (like Microsoft do in their example), we use the OnTokenValidated event in a similar way to Dominick Baier’s sample, storing the tokens in a Singleton service that holds tokens for all Users, we call this service ServerSideTokenStore.

When we use our HttpClient to call an API and it needs an access_token (or refresh_token), then it retrieves the User’s sub from an injected AuthenticationStateProvider, uses it to call ServerSideTokenStore.GetTokensAsync(), which returns a UserTokenProvider (similar to Microsoft’s TokenProvider) containing the tokens. If the HttpClient needs to refresh the tokens then it populates a UserTokenProvider and saves it by calling ServerSideTokenStore.SetTokensAsync().

Another issue we had was if a separate instance of the web browser is open while the app restarts (and therefore loses the data held in ServerSideTokenStore) the user would still be authenticated using the cookie, but we’ve lost the access_token and refresh_token. This could happen in production if the application is restarted, but happens a lot more frequently in a dev environment. We work around this by handling OnValidatePrincipal and calling RejectPrincipal() if we cannot get a suitable access_token. This forces a round trip to IdentityServer which provides a new access_token and refresh_token. This approach came from this stack overflow thread.

(For clarity/focus, some of the code that follows excludes some standard error handling, logging, etc.)

Getting the User sub claim from AuthenticationStateProvider

Our HttpClient gets the user's sub claim from an injected AuthenticationStateProvider. It uses the userSub string when calling ServerSideTokenStore.GetTokensAsync() and ServerSideTokenStore.SetTokensAsync().

    var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
    string userSub = state.User.FindFirst("sub")?.Value;

UserTokenProvider

    public class UserTokenProvider
    {
        public string AccessToken { get; set; }
        public string RefreshToken { get; set; }
        public DateTimeOffset Expiration { get; set; }
    }

ServerSideTokenStore

    public class ServerSideTokenStore
    {
        private readonly ConcurrentDictionary<string, UserTokenProvider> UserTokenProviders = new();
    
        public Task ClearTokensAsync(string userSub)
        {
            UserTokenProviders.TryRemove(userSub, out _);
            return Task.CompletedTask;
        }
    
        public Task<UserTokenProvider> GetTokensAsync(string userSub)
        {
            UserTokenProviders.TryGetValue(userSub, out var value);
            return Task.FromResult(value);
        }
    
        public Task StoreTokensAsync(string userSub, UserTokenProvider userTokenProvider)
        {
            UserTokenProviders[userSub] = userTokenProvider;
            Return Task.CompletedTask;
        }
    }

Startup.cs ConfigureServices (or equivalent location if using .NET 6 / whatever)

    public void ConfigureServices(IServiceCollection services)
    {
        // …
        services.AddAuthentication(…)
        .AddCookie(“Cookies”, options =>
        {
            // …
            options.Events.OnValidatePrincipal = async context =>
            {
                if (context.Principal.Identity.IsAuthenticated)
                {
                    // get user sub 
                    var userSub = context.Principal.FindFirst(“sub”).Value;
                    // get user's tokens from server side token store
                    var tokenStore =
                        context.HttpContext.RequestServices.GetRequiredService<IServerSideTokenStore>();
                    var tokens = await tokenStore.GetTokenAsync(userSub);
                    if (tokens?.AccessToken == null 
                        || tokens?.Expiration == null 
                        || tokens?.RefreshToken == null)
                    {
                        // if we lack either an access or refresh token,
                        // then reject the Principal (forcing a round trip to the id server)
                        context.RejectPrincipal();
                        return;
                    }
                    // if the access token has expired, attempt to refresh it
                    if (tokens.Expiration < DateTimeOffset.UtcNow) 
                    {
                        // we have a custom API client that takes care of refreshing our tokens 
                        // and storing them in ServerSideTokenStore, we call that here
                        // …
                        // check the tokens have been updated
                        var newTokens = await tokenStore.GetTokenAsync(userSubProvider.UserSub);
                        if (newTokens?.AccessToken == null 
                            || newTokens?.Expiration == null 
                            || newTokens.Expiration < DateTimeOffset.UtcNow)
                        {
                            // if we lack an access token or it was not successfully renewed, 
                            // then reject the Principal (forcing a round trip to the id server)
                            context.RejectPrincipal();
                            return;
                        }
                    }
                }
            }
        }
        .AddOpenIdConnect(“oidc”, options =>
        {
            // …
            options.Events.OnTokenValidated = async n =>
            {
                var svc = n.HttpContext.RequestServices.GetRequiredService<IServerSideTokenStore>();
                var culture = new CultureInfo(“EN”) ;
                var exp = DateTimeOffset
                          .UtcNow
                          .AddSeconds(double.Parse(n.TokenEndpointResponse !.ExpiresIn, culture));
                var userTokenProvider = new UserTokenProvider() 
                {
                    AcessToken = n.TokenEndpointResponse.AccessToken,
                    Expiration = exp,
                    RefreshToken = n.TokenEndpointResponse.RefreshToken
                }
                await svc.StoreTokensAsync(n.Principal.FindFirst(“sub”).Value, userTokenProvider);
            };
            // …
        });
        // …
    }
Rob
  • 1,214
  • 1
  • 11
  • 20
  • When you refresh the access token, do you also update the cookie? Because `OnValidatePrincipal` is not called if the cookie is expired, `CookieAuthenticationHandler` checks for cookie expiration first. When a logged-in user refreshes the page, if the cookie is expired, the `OnValidatePrincipal` is not called. If do you update the cookie, how do you do it? I think this is the actual missing piece in the McGuire’s blog post. – WriteEatSleepRepeat Aug 12 '22 at 09:44
  • There's no way legitimate way to update the cookie from Blazor Server as Blazor Server doesn't cannot use HttpContext. Our workaround here avoids the need to use the cookie for ```access``` and ```refresh``` tokens after they are first read. I think if the authentication cookie has expired then the user would need to log in again. In our case we're using a Session cookie, so the cookie will expire after the user has closed their browser and they will need to log in again if they come back to the site - this is how we want it to work. – Rob Aug 23 '22 at 19:59
  • @Rob This is amazing, thank you. I can't believe I can't find anything like this on the internet anywhere. Do you also have an example on how you add the `access token` to the `HttpClient`? And what happens when the token is expired when you call the service, that won't be always cought by `OnValidatePrincipal` right? Because that event only fires when you refresh your page. – Schoof Nov 03 '22 at 11:53
  • 1
    @Schoof "I can't find anything like this on the internet anywhere" you just did ;) We use our own ```HttpClient``` implementation for various reasons. It sets the ```access_token``` in the request header ```HttpRequestMessage.Headers.Add("Authorization", $"Bearer {UserTokenProvider.AccessToken}");``` before doing so it proactively checks if the ```access_token``` is more than half way through its life and if so requests a new one from IdentityServer. If you use IdentityModel's ```HttpClient``` then it can take care of a lot of that stuff for you (see Dominick Baier's sample, link above). – Rob Nov 03 '22 at 16:01
  • @Rob That is very true! :D. I kind of want to do the same thing, I've tried using a `DelegatingHandler` to set the `access_token`, but it seems like you aren't able to use the `AuthenticationStateProvider` there (`'GetAuthenticationStateAsync was called before SetAuthenticationState.'`). So I guess I'm just going to have to call the same method in all the methods in my `HttpClient`. We also don't use IdentityServer, so some values are a bit different so I think we unfortunately can't use the IdentityModel's `HttpClient`. – Schoof Nov 04 '22 at 11:32
  • 1
    @Schoof, our ```HttpClient``` sends all requests using the same ```private ... SendRequest``` method which creates the ```HttpRequestMessage```, so we only need to call our ```SetAccessToken``` method from there, passing it the ```HttpRequestMessage``` so that it can set the ```bearer``` header on that. – Rob Nov 04 '22 at 15:11
  • @Rob Have you tested this approach with load balanced websites? – Schoof Nov 07 '22 at 10:33
  • 1
    @Schoof no, we're not using load balancing with our Blazor Server project and don't expect to need to. If you are, and assuming your solution is preserving the state of other Scoped services across Blazor circuits, then I think this solution would still work. If you plan to test this then it would be interesting to know how it goes. – Rob Nov 07 '22 at 14:58