AspNetCore.Docs icon indicating copy to clipboard operation
AspNetCore.Docs copied to clipboard

Changing access token during SignalR session (with websockets protocol)

Open simeyla opened this issue 5 years ago • 19 comments

The following phrase is emphasized on https://docs.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-2.2:

The access token function you provide is called before every HTTP request made by SignalR. If you need to renew the token in order to keep the connection active (because it may expire during the connection), do so from within this function and return the updated token.

However it's easy to confuse 'http request' here with the concept of 'message sent'.

When using websockets there is only one http request made when you first connect - after that the token isn't sent again and my function isn't called. The above paragraph kind of implies it will automagically refresh itself when it changes which for websockets isn't entirely accurate.

Can you make it clearer what the recommended procedure is for changing access token when connected with websockets. I assume I'm supposed to just disconnect and reconnect.

Although to be frank this is kind of a massive pain and I'm almost tempted to just send the access token as part of the message (which in my case is rarely needed anyway).


Document Details

Do not edit this section. It is required for docs.microsoft.com ➟ GitHub issue linking.

simeyla avatar Jul 06 '19 01:07 simeyla

1 million times this. I'm bashing my head against the desk wondering why my accessTokenFactory method isn't being called once the connection is established, wondering if I'm doing something wrong. This note is completely misleading and should be removed. What is MSFT's recommended approach? Is it in fact to disconnect and reconnect when a new access token is received? This seems like a massive oversight.

mmulhearn avatar Sep 06 '19 15:09 mmulhearn

+1 here also. Very confusing. I've also found that the Authorize attribute doesnt really protect you from expired tokens also. https://github.com/aspnet/AspNetCore/issues/5283 So with Websockets, once you set up a connection.. on the client side you're not easily able to keep your tokens up to date: and then on the server side: it doesn't check if they're expired anyway...

calhamdower avatar Sep 16 '19 06:09 calhamdower

tl;dr: Does using a pattern of establishing/reestablishing websocket-based connections to a hub on an as-needed basis create too much connection overhead (or defeat the purpose of web-sockets to begin with) to be useful as a solution to the above problems? (The idea being that each re-established connection would use a token re-fresh pattern)

Story: I'm currently trying to write a live multi-user app using Blazor client-side with a feature-rich server-side using signalr. I have signalr connections working and token validation working with all of the above issues with authorization (tokens only validated on connection, and web-socket calls to hub methods are 'authorized' with expired tokens indefinitely on the same connection as was established when the token was valid. Re-connection attempts fail authorization with an expired token).

All that said, if I'm going to stick with jwt auth, is it just better to use a short expiry and a refresh token pattern, with a policy that between client-side actions that don't occur frequently enough connections are dropped, as a solution as opposed to having to have "timed tasks" on both the client side and server side? (Those tasks being what causes expired tokens to be re-issued?)

darkpq avatar Oct 16 '19 03:10 darkpq

Microsoft, what is your guidance regarding expiring tokens both on the client and the server?

Tommigun1980 avatar May 01 '20 01:05 Tommigun1980

This would cause a lot of errors goes undetected. I am hoping that someone can provide a viable solution or workaround.

kherona avatar May 18 '20 18:05 kherona

Here is another request about the same issue: https://github.com/dotnet/AspNetCore.Docs/issues/18265

kherona avatar May 18 '20 18:05 kherona

Microsoft, there seems to be quite a lot of developers who are confused about how to refresh the access token in a SignalR connection (me included), as the token factory seems to be executed only on connection start and if the token is revoked it's not checked for again. Could you please clarify what the suggested flow is for handling this as it is not brought up in the documentation. Thanks so much.

Tommigun1980 avatar May 19 '20 17:05 Tommigun1980

I think this is something we’ll have to think about more but let me ask some questions about the expected behavior:

Whats the ideal experience? Do you want to have the token expire and have the physical connection drop? That’s the easiest thing to do. The client would then re-connect and rerun then access token factory client side and you could re run whatever logic you need to to get a new token.

If we don’t do that does it mean the physical connection stays open and it’s less clear what it would do, how the client would re-negotiate etc

davidfowl avatar May 19 '20 17:05 davidfowl

IMO, the access token factory should maintain access token state and once the token expires, should invoke for a new one. If a new one is provided and is valid, the connection maintains, otherwise the connection drops.

mmulhearn avatar May 19 '20 18:05 mmulhearn

@davidfowl I believe we should invoke new refresh token as suggested by mmulhearn, this is how we do it in non-websocket anyway, however the feature should be supported on client side/server side or both. Currently on server side we have read query_string and set Context.Token while I would expect the SignalR middle ware to automatically take care of that.

Now on the clientside I would suggest to add onAuthenticationFailure retry/reconnect (i.e 401 received) behavior similar to withAutomaticReconnect, then the accessTokenFactory can re-read whatever value we provided and attempt to reconnect the connection with the new accessTokenFactory.

kherona avatar May 19 '20 18:05 kherona

What's the state of this? Curious what the team has come up with or decided on before I roll my own refresh logic.

CodyPaul avatar Mar 05 '21 18:03 CodyPaul

Somebody at Microsoft needs to take point on these issues. Using SignalR has been more time consuming than doing everything from scratch, as it doesn’t handle the most primitive cases. @CodyPaul Considering the complete lack of interest to fix this most primitive issue don’t hold your breath for a resolution any time soon. The current handling of the token clearly shows it’s not meant for production use and you’ll need to make your own anyway.

Tommigun1980 avatar Mar 06 '21 02:03 Tommigun1980

We're looking to invest in this area (whether doc or feature) during this time frame. Perhaps we should write something even if temporary in the docs to explain the current options (even if they aren't good).

davidfowl avatar Mar 06 '21 02:03 davidfowl

We're looking to invest in this area (whether doc or feature) during this time frame. Perhaps we should write something even if temporary in the docs to explain the current options (even if they aren't good).

Any news @davidfowl ?

Niproblema avatar Jun 14 '22 14:06 Niproblema

@BrennanConroy can you provide an update based on our discussion yesterday?

davidfowl avatar Jun 14 '22 14:06 davidfowl

@BrennanConroy can you provide an update based on our discussion yesterday?

Anxiously awaiting reply

Niproblema avatar Jun 17 '22 12:06 Niproblema

I think this is something we’ll have to think about more but let me ask some questions about the expected behavior:

Whats the ideal experience? Do you want to have the token expire and have the physical connection drop? That’s the easiest thing to do. The client would then re-connect and rerun then access token factory client side and you could re run whatever logic you need to to get a new token.

If we don’t do that does it mean the physical connection stays open and it’s less clear what it would do, how the client would re-negotiate etc

Hi @davidfowl,

Don't have the answer on how it'd reconnect, but it seems there's an actual way to update the HttpContext from a hub method:

var httpContext = this.Context.GetHttpContext()!;
httpContext.Items["access_token"] = updatedToken;
... re-authenticate user ...
httpContext.User = authenticateResult.Principal

And the only thing that prevent this new user to be used is how HubConnectionContext.Usermethod has been implemented.

 public virtual ClaimsPrincipal User
        {
            get
            {
                if (_user is null)
                {
                    _user = Features.Get<IConnectionUserFeature>()?.User ?? new ClaimsPrincipal();
                }
                return _user;
            }
        }

It'd work fine with this new code IMO as all subsequent calls to HubConnectionContext.Userwould use the new user:

public virtual ClaimsPrincipal User
        {
            get
            {
               return Features.Get<IConnectionUserFeature>()?.User ?? new ClaimsPrincipal();
            }
        }

This only thing is that theres no way to extend/proxy HubConnectionContext because of HubConnectionHandler and as mentionned here: https://github.com/dotnet/aspnetcore/issues/41709

public override async Task OnConnectedAsync(ConnectionContext connection)
        {
           ...
           var connectionContext = new HubConnectionContext(connection, contextOptions, _loggerFactory);
          ...
       }

willykurmann avatar Aug 11 '22 09:08 willykurmann

Here is how I did it;

        private void ProlongConnectionAuthenticationExpiration(ClaimsPrincipal claimsPrincipal)
        {
            if (claimsPrincipal == null)
            {
                throw new ArgumentNullException(nameof(claimsPrincipal));
            }

            // Get validation parameters.
            TokenValidationParameters? validationParameters = _serviceProvider.GetService<TokenValidationParameters>();
            if (validationParameters == null)
            {
                throw new InvalidOperationException("Failed to determine validation parameters.");
            }

            // Define new expiration time.
            string? newExpirationTime = claimsPrincipal.Claims.FirstOrDefault(claim => claim.Type == "exp")?.Value;
            if (newExpirationTime == null && validationParameters.ValidateLifetime)
            {
                throw new InvalidOperationException("Newly provided claims do not contain expiration time.");
            }
            if (!long.TryParse(newExpirationTime, out long unixSecondsExpiration))
            {
                throw new InvalidOperationException("Failed to parse new expiration time.");
            }

            DateTimeOffset expirationTimeUtc;
            try
            {
                expirationTimeUtc = DateTimeOffset.FromUnixTimeSeconds(unixSecondsExpiration).Add(validationParameters.ClockSkew);
            }
            catch (ArgumentOutOfRangeException)
            {
                throw new InvalidOperationException("Failed to parse new expiration time. Out of range exception");
            }

            // Get internal http context
            IHttpContextFeature? contextFeature = Context.Features.Get<IHttpContextFeature>();

            if (contextFeature == null)
            {
                throw new InvalidOperationException("Failed to resolve feature " + nameof(IHttpContextFeature));
            }

            PropertyInfo? pi = contextFeature.GetType().GetProperty("AuthenticationExpiration", BindingFlags.Instance | BindingFlags.NonPublic);
            if (pi == null)
            {
                throw new InvalidOperationException("Failed to resolve http connection context fields.");
            }
            object? previousExpirationObject = pi.GetValue(contextFeature);
            if (previousExpirationObject == null)
            {
                throw new InvalidOperationException("Failed to determine previous expiration time.");
            }
            DateTimeOffset previousExpiration = (DateTimeOffset)previousExpirationObject;

            // Log update.
            _logger.LogDebug("Prolonging authentication for connection {0}. Previous expiration [{1}]. Prolonged expiration [{2}].",
                Context.ConnectionId,
                previousExpiration.ToUniversalTime().ToString(),
                expirationTimeUtc.ToString());

            // Update AuthenticationResult feature if used.
            IAuthenticateResultFeature? authResultFeature = Context.Features.Get<IAuthenticateResultFeature>();
            if (authResultFeature != null)
            {
                AuthenticationProperties ap = new AuthenticationProperties();
                AuthenticationTicket authTicket = new AuthenticationTicket(claimsPrincipal, ap, JwtBearerDefaults.AuthenticationScheme);
                authResultFeature.AuthenticateResult = AuthenticateResult.Success(authTicket);
            }

            // Set new expiration date.
            pi.SetValue(contextFeature, expirationTimeUtc);
        }

Nasty, but that's how it goes with bad frameworks. Expected nothing less from microsoft managed projects

Niproblema avatar Aug 11 '22 09:08 Niproblema

Nasty, but that's how it goes with bad frameworks. Expected nothing less from microsoft managed projects

It's quite incredible that the response from Microsoft was that "they'll have to think about it". Think about it, years after release? This is such a common case that it's incredible it wasn't thought about when the framework was made. Like... how can you miss adding any kind of mechanism for expiring tokens? I'm so glad I stopped using these kinds of Microsoft products, I was naive enough to think they'd save me some time.

Tommigun1980 avatar Aug 22 '22 01:08 Tommigun1980

Is there a fix for this yet? It's been like 3 years.

Gruski avatar Jan 05 '23 05:01 Gruski

No, there is no fix, you can follow this issue https://github.com/dotnet/aspnetcore/issues/5297

davidfowl avatar Jan 05 '23 05:01 davidfowl

Microsoft please dont be so slow by fixing so important aspekts from the framework 5 Years is not excusable

DeepWorksStudios avatar May 09 '23 12:05 DeepWorksStudios