Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Blazor OpenID Connect API Token Refresh - Forty Years of Code #42

Open
utterances-bot opened this issue Feb 4, 2020 · 18 comments
Open

Comments

@utterances-bot
Copy link

Blazor OpenID Connect API Token Refresh - Forty Years of Code

Correctly refreshing OIDC access tokens for Blazor server-side apps

https://mcguirev10.com/2019/12/17/blazor-openid-connect-api-token-refresh.html

Copy link

HrDahl commented Feb 4, 2020

We do actually have the issue with users logging in and out as they please with persistent login. So currently, we are running in to the problem that you referred to last in this article.

Could you give any hints as to how you implemented the "forced" login/refresh of the tokens when a user reappears on the site after e.g. days?

I expect we need some additional logic in the OnGet() function in _HostAuthModel.cs.

Thanks!

@MV10
Copy link
Owner

MV10 commented Feb 4, 2020

@HrDahl Right, add a new handler (specific to refresh, not in OnGet) but most of the code is in that "Stale Cookies" section. That'll ensure the cookies are up to date once they've logged in again. If you need help with the persistent login itself, refer to the approach in my January 2018 article:

https://mcguirev10.com/2018/01/12/persistent-login-with-identityserver.html

It's basically the same as long as you route Blazor through pages with HttpContext.

Copy link

Thanks for your Blog. Ive some questions about that particular code.
Right now the Expiration in the Cache is never updated after refreshing the AccessToken.
Maybe i dont unterstand it correctly, but i now have the behaviour that the user is automatic logged out because of expiration time.
And i am not get it with the Stale Cookie Problem. Sorry, i am new to Blazor :)
At which event i have to call my controller via httpclient to update the Cookies from my Blazorcache?
Any help appreciated :) Thanks!

Copy link

90% finished, I don't get the point of creating tutorial and not finish it. 'Stale cookies' section has 'tokenResponse' in the code, assuming that when the time for refreshing the token comes, we call the controller which handles refreshing the cookies.

Copy link

anddrzejb commented Jul 10, 2020

@MV10 I tried to follow your advice on dealing with stale cookies, but probably did not understand exactly what you meant.
I created a controller in my blazor server project with 1 method that accepts sid. The controller injects the cache with tokens saved after refresh in validator. From validator, after refreshing I was trying to call that controller using http client, but this is obviously not the way. Could you possibly provide a sample of how to do this?

@ViRuSTriNiTy
Copy link

ViRuSTriNiTy commented Jul 10, 2020

@anddrzejb You can implement an access token refresh via IdentityModel.AspNetCore, see IdentityModel/IdentityModel.AspNetCore#121 my comment below.

Id token should be handled with a silent renew as stated here (but didn't solved this myself yet): IdentityModel/IdentityModel.AspNetCore#124

@anddrzejb
Copy link

@ViRuSTriNiTy I was already using IdentityModel.AspNetCore, but had to temporarily abandon it, because couldn't figure out how to force it to provide role claim through ClientCretentials flow. It is included in the access_token when I obtain it using
var accessToken = await HttpContext.GetTokenAsync("access_token")
and set it using
HttpClient.SetBearerToken(accessToken),
however when I try to use IdentityModel.AspNetCore extensions on HttpClient, the claim is not passed in access token (investigated it with jwt.io). But I digress.

I tried to follow your suggestion for access token (for starters). There is a lot to unpack for someone like me who is just starting with IdentityServer. Anyway, It seems to me that you are injecting into
public class BlazorServerAuthState : RevalidatingServerAuthenticationStateProvider the AccessTokenManagementService.
I figured I had to add first all the stuff you mention there in StartUp.cs, mostly create UserTokenStore and add it as transient, AddAccessTokenManagement() and AddHttpClient().AddUserAccessTokenHanlder(). But I am getting an error:

Unable to resolve service for type 'IdentityModel.AspNetCore.AccessTokenManagement.AccessTokenManagementService' while attempting to activate 'ClientOne.FutureExternalServices.BlazorServerAuthState'.

Soo...what did I miss?

@ViRuSTriNiTy
Copy link

ViRuSTriNiTy commented Jul 10, 2020

@anddrzejb I'm sorry, I pointed you in the wrong direction as my comments in the given links are already deprecated. I removed the AccessTokenManagementService from the AuthenticationStateProvider implementation again as it would periodically execute HTTP requests which is imho not necessary. IdentityModel.AspNetCore implements the access token refresh in UserAccessTokenHandler but this handler cannot be used in Blazor as HttpContext is null when app is hosted. So i switched back to my initial approach to add the bearer token manually where a HTTP request is executed by

  • injecting AuthenticationStateProvider and IAccessTokenManagementService

  • requesting the auth state from AuthenticationStateProvider

    var authenticationState = await _authenticationStateProvider.GetAuthenticationStateAsync();

  • getting the access token from IAccessTokenManagementService whereas the access token is retrieved from IUserTokenStore and is automatically refreshed by the service implementation when the access token is about to expire

    var accessToken = await _accessTokenManagementService.GetUserAccessTokenAsync(authenticationState?.User);
    
    if (!string.IsNullOrWhiteSpace(accessToken))
    {
        httpRequestMessage.SetBearerToken(accessToken);
    } 
    

Here is my implementation of IUserTokenStore:

public class UserTokenStore : IUserTokenStore
{
    static readonly ConcurrentDictionary<Guid, UserAccessToken> _userAccessTokenDictionary =
        new ConcurrentDictionary<Guid, UserAccessToken>();

    public Task<UserAccessToken> GetTokenAsync(ClaimsPrincipal user)
    {
        var issuerUserId = user.GetIssuerUserId();

        if (issuerUserId.HasValue)
        {
            if (_userAccessTokenDictionary.TryGetValue(issuerUserId.Value, out var result))
            { 
                return Task.FromResult(result);
            }
        }

        return Task.FromResult<UserAccessToken>(null);
    }

    public Task StoreTokenAsync(ClaimsPrincipal user, string accessToken, DateTimeOffset expiration, string refreshToken)
    {
        var issuerUserId = user.GetIssuerUserId();
        if (issuerUserId.HasValue)
        {
            _userAccessTokenDictionary.AddOrUpdate(issuerUserId.Value,
                new UserAccessToken
                {
                    AccessToken = accessToken,
                    RefreshToken = refreshToken,
                    Expiration = expiration
                },

                (key, value) =>
                {
                    value.AccessToken = accessToken;
                    value.RefreshToken = refreshToken;
                    value.Expiration = expiration;

                    return value;
                }
            );
        }

        return Task.CompletedTask;
    }

    public Task ClearTokenAsync(ClaimsPrincipal user)
    {
        return Task.CompletedTask;
    }
}

You need to init the user token store by passing the tokens in _Host.cshtml to App.razor like

@{
    var initialApplicationState = new InitialApplicationState
    {
        IdToken = await HttpContext.GetTokenAsync("id_token"),
        AccessToken = await HttpContext.GetTokenAsync("access_token"),
        RefreshToken = await HttpContext.GetTokenAsync("refresh_token")
    };

    var expiresAt = await HttpContext.GetTokenAsync("expires_at");
    if (DateTimeOffset.TryParse(expiresAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out var expiration))
    {
        initialApplicationState.Expiration = expiration;
    }
    else
    {
        initialApplicationState.Expiration = DateTimeOffset.UtcNow;
    }
}

<component type="typeof(App)" param-InitialState="initialApplicationState" render-mode="Server" />

My App.razor looks like this:

@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IUserTokenStore UserTokenStore

...

@code{
    [Parameter] public InitialApplicationState InitialState { get; set; }

    protected override async Task OnInitializedAsync()
    {
        var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();

        await UserTokenStore.StoreTokenAsync(authenticationState.User, InitialState.AccessToken,
            InitialState.Expiration, InitialState.RefreshToken);

        await base.OnInitializedAsync();
    }
}

Finally the Startup modifications to glue this all together:

// add custom user token store to avoid that AddAccessTokenManagement() registers its
// own store based on HttpContext (HttpContext is not available in blazor server side)
services.AddTransient<IUserTokenStore, UserTokenStore>();

var tokenBuilder = services.AddAccessTokenManagement(...);

tokenBuilder.ConfigureBackchannelHttpClient();

services
    // add a shared HttpClient ...
    .AddHttpClient("MyHttpClient", (serviceProvider, httpClient) =>
    {
        httpClient.BaseAddress = ...;
    })
    // ... that is injected into the following API clients ...
    .AddTypedClient<...>();

Hope this helps.

@anddrzejb
Copy link

anddrzejb commented Jul 10, 2020

@ViRuSTriNiTy please correct me if I am wrong, but from what I see here, you basically abandoned the solution proposed by the tutorial on refreshing the tokens using RevalidatingServerAuthenticationStateProvider in favour of the automatic implementation of refresh in IdentityModel.AspNetCore.AccessTokenManagement.AccessTokenManagementService. I think I managed to use your solution and I think I like it, even though it feels more "magical". I am not sure if there is a lot of benefit here in comparison (more things happening in _Host.cshtml which to be honest I do not like much, new things happenig in App.razor) to the solution from the tutorial.
However your solution basically just offered a different approach to a problem that was already solved but not to the stale cookies problem. The tokens are refreshed, but cookies stay unchanged....Unless there is something I missed?

Also, I was surprised you suggested using httpRequestMessage.SetBearerToken(accessToken); - I thought one of the features of the IdentityModel.AspNetCore was automatic inclusion of tokens into HttpClient headers.

@ViRuSTriNiTy
Copy link

@anddrzejb Yeah, my bad, i've read your question not thoroughly enough, instead of "stale cookies" I did pick up "stale tokens". Just forget what I wrote then ^^

Copy link

Thank you for this amazing blog. I got it working using Azure AD, but I have to set "GetClaimsFromUserInfoEndpoint" to false. Otherwise, after a successful login the event "OnRemoteFailure" is fired. I don't know what's wrong there.
Can you post a final article to illustrate how to stale the cookies? I think I'm not the only one lost there.
Thank you Jon!

Copy link

skrue commented Mar 25, 2021

@MV10 Thank you for your great writeup and sample code. Writing a Blazor Server app was all fun and games until I had to implement OIDC with AWS Cognito and your articles have helped me a lot.
As some of the commenters before, I am now running into the "Stale Cookies Problem". Unfortunately this makes the application more or less unusable in production. I would really appreciate a quick "Part 4" of the series that shows how to solve the problem. I think that with a lot of trial and error I could come up with some sort of solution, but it would probably be a very ugly one... Thank you!

@MV10
Copy link
Owner

MV10 commented Mar 25, 2021

Unfortunately I'm not currently working with either technology and I lack the time to do any follow-up work. I still think they're both great tech and the right direction, but sometimes you go where the paychecks are...

Copy link

tmurali commented Jul 26, 2021

@MV10 ,Great article and learned a lot about ValidateAuthenticationStateAsync .
But I am wondering if using Cache for this purpose introduces scaling issues which OIDC intended to remove. Just may be my lack of understanding any light on this would great help.

@MV10
Copy link
Owner

MV10 commented Jul 27, 2021

@tmurali I've never heard it said that OIDC was meant to improve scalability (up? out?) -- if asked, I'd say its primary value is standardizing auth flows and providing a separation of concerns. The use of cache definitely contributes to your app's memory profile and needs to be considered along with everything else that goes into profiling the type and number of servers needed by a production system. However, when I was working with this tech, I found the overhead of Blazor itself to have the largest impact on our server scalability assessments. Unfortunately I'm not actively using any of this today thanks to arbitrary decisions by management.

Copy link

tmurali commented Jul 27, 2021

Thanks again for a prompt response. What I meant by scaling issue is , using cached data we are tying a user to a particular server in a load balanced environment. With OAuth and openID and JWT reference token we are not tied to a server just palm of the token validation to Identity server. But as you mentioned in all your three article feels like Security for Blazor server app is left as a after thought from MS. Hope they will add bit more support in .net6. And sorry to hear you are not using this tech stack anymore. Honestly without this guidance would have struggled with our implementation.

@MV10
Copy link
Owner

MV10 commented Jul 27, 2021

@tmurali Server Side Blazor + SignalR ties the user to a specific server, too...

Copy link

I found this blog post very helpful, but also struggled for a long time with the stale cookies issue. I have a solution to the stale cookies issue that seems to work for us. I've documented it here in case it is useful to others: https://stackoverflow.com/questions/72868249

I would welcome any constructive feedback. Or upvotes! :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests