IdentityModel / IdentityModel.AspNetCore

ASP.NET Core helper library for claims-based identity, OAuth 2.0 and OpenID Connect.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Option flag to disable ChallengeScheme-specific tokens

janruo opened this issue · comments

Please consider adding an option flag to disable the changes added by #278. Some arguments for this:

  1. It's kind of a breaking change. For example the following code:
// Get the token, renew if expired
var token = await HttpContext.GetUserAccessTokenAsync(new UserAccessTokenParameters
{
	ChallengeScheme = MyRemoteDefaults.RemoteScheme,
	SignInScheme = MyRemoteDefaults.SignInScheme
});

// Get the expiration of the token
var expires = await HttpContext.GetTokenAsync(MyRemoteDefaults.SignInScheme, "expires_at");

Prior to v4.2.0 expires variable would always contain the current expiration of the token, even after a refresh. After v4.2.0 expires variable will always contain the expiration for the initial sign in, no matter how many times it's refreshed.

  1. It increases the size of the authentication ticket considerably. Prior to v4.2.0 the authentication ticket properties would always look something like this, because refreshing updated the default tokens:
.AuthScheme = MyRemote
.Token.access_token = <jwt>
.Token.id_token = <jwt>
.Token.refresh_token = <code>
.Token.token_type = bearer
.Token.expires_at = <expres at>
.TokenNames = access_token;id_token;refresh_token;token_type;expires_at
.expires = ...
.issued = ...

After v4.2.0 the tickets will look something like this, because refreshing creates new tokens and leaves the expired tokens in place:

.AuthScheme = MyRemote
.Token.access_token = <INVALID jwt>
.Token.id_token = <jwt>
.Token.refresh_token = <INVALID code>
.Token.token_type = bearer
.Token.expires_at = <INVALID expires at>
.TokenNames = access_token;id_token;refresh_token;token_type;expires_at
.expires = ...
.issued = ...
.Token.access_token||MyRemote = <CURRENT jwt>
.Token.expires_at||MyRemote = <CURRENT expires at>
.Token.refresh_token||MyRemote = <CURRENT code>

In my case the new auth ticket is 1.1KB larger.

Please consider adding an option flag to disable the changes added by #278. Some arguments for this:

  1. It's kind of a breaking change. For example the following code:
// Get the token, renew if expired
var token = await HttpContext.GetUserAccessTokenAsync(new UserAccessTokenParameters
{
	ChallengeScheme = MyRemoteDefaults.RemoteScheme,
	SignInScheme = MyRemoteDefaults.SignInScheme
});

// Get the expiration of the token
var expires = await HttpContext.GetTokenAsync(MyRemoteDefaults.SignInScheme, "expires_at");

Prior to v4.2.0 expires variable would always contain the current expiration of the token, even after a refresh. After v4.2.0 expires variable will always contain the expiration for the initial sign in, no matter how many times it's refreshed.

  1. It increases the size of the authentication ticket considerably. Prior to v4.2.0 the authentication ticket properties would always look something like this, because refreshing updated the default tokens:
.AuthScheme = MyRemote
.Token.access_token = <jwt>
.Token.id_token = <jwt>
.Token.refresh_token = <code>
.Token.token_type = bearer
.Token.expires_at = <expres at>
.TokenNames = access_token;id_token;refresh_token;token_type;expires_at
.expires = ...
.issued = ...

After v4.2.0 the tickets will look something like this, because refreshing creates new tokens and leaves the expired tokens in place:

.AuthScheme = MyRemote
.Token.access_token = <INVALID jwt>
.Token.id_token = <jwt>
.Token.refresh_token = <INVALID code>
.Token.token_type = bearer
.Token.expires_at = <INVALID expires at>
.TokenNames = access_token;id_token;refresh_token;token_type;expires_at
.expires = ...
.issued = ...
.Token.access_token||MyRemote = <CURRENT jwt>
.Token.expires_at||MyRemote = <CURRENT expires at>
.Token.refresh_token||MyRemote = <CURRENT code>

In my case the new auth ticket is 1.1KB larger.

@janruo @leastprivilege Happy to take this and upon review to come back with a PR.

yes please

Hi @janruo,

I've reviewed your request. Initially, I think it's worth clarifying certain aspects of IdentityModel.AspNetCore.

Consider the code extract below, taken from 'IdentityModel.AspNetCore.AccessTokenManagement.AuthenticationSessionUserAccessTokenStore.StoreTokenAsync'; Token names are only appended with the Challenge Scheme, if it is included in the optional UserAccessTokenParameters

if (!string.IsNullOrEmpty(parameters.ChallengeScheme))
{
               refreshTokenName += $"||{parameters.ChallengeScheme}";     
               tokenName += $"||{parameters.ChallengeScheme}";
               expiresName += $"||{parameters.ChallengeScheme}";
}

Taking account of the role the ChallengeScheme parameter plays, there are, at least in the short term 2 approaches you might avail yourself of;

  1. Omit the value (in your case MyRemote) from the parameters.ChallengeScheme, in which case by way of example ".Token.expires_at||MyRemote" would instead be ".Token.expires_at".
  2. Continue setting parameters.ChallengeScheme, and be explicit regarding token naming and storage.

If you go with approach 2, you might want to do something similar to the code snippet below;

options.Events.OnUserInformationReceived = async context =>
{
YourTokenService.StoreTokenAsync((context.ProtocolMessage.AccessToken, context.ProtocolMessage.RefreshToken), context.Properties, authSettings, context.ProtocolMessage.IdToken);                        
}

In YourTokenService.StoreTokenAsync you can obviously populate authenticationProperties.Items, as needed, calling authenticationProperties.StoreTokens persisting your updates.

To wrap up, assuming you have previously explicitly stored tokens with ChallengeScheme "||MyRemote" appended, then a subsequent call to GetTokenAsync, would require appending “||MyRemote”, see example below.

// Get the expiration of the token
var expires = await HttpContext.GetTokenAsync(MyRemoteDefaults.SignInScheme, "expires_at||MyRemote");

To better understand your use case, can I ask why you explicitly set the optional UserAccessTokenParameters.ChallengeScheme?

On a final note, I am submitting a proposed solution that would make "ChallengeScheme-specific tokens" entirely opt-in, more on that to come shortly.

@leastprivilege, and @brockallen, As mentioned in my remarks above to @janruo, I've committed code on my fork(Providing a way of opting into ChallengeScheme specific token storage and retrieval) that provides a way of opting into ChallengeScheme-specific token storage, with no side effects for those that don't wish to. Although I'm not convinced I have nailed the naming yet, the approach seems simple and solid to me, please review and let me know your thoughts?

To better understand your use case, can I ask why you explicitly set the optional UserAccessTokenParameters.ChallengeScheme?

Sorry, I decided to not use this library because of the current issue, this issue, and potential problems with future versions. Instead I implemented similar functionality myself, taking some inspiration from this library.

The reason I specified the scheme explicitly was that it simply didn't work without it. Or maybe just refreshing didn't work without it, I don't remember exactly.

So I guess it should

  • allow a way to opt-in the challenge specific stuff (b/c it's a nice scenario)
  • if it is used, there should be a better cleanup to get rid of the non-challenge bound artifacts and thus reduce the size of the cookie again

right?

@leastprivilege

  • allow a way to opt-in the challenge-specific stuff (b/c it's a nice scenario)
    I think what I have committed here achieves this.

  • if it is used, there should be a better cleanup to get rid of the non-challenge bound artifacts and thus reduce the size of the cookie again
    Two things I was thinking with regards to cleanup;

  1. As is the case for my requirement upon authentication it makes sense to me that as the last task e.g. in options.Events.OnUserInformationReceived, you should be able to grab any additional tokens, do a token exchange, etc and at that point clear out any non-challenge bound artifacts as well as set up all your challenge-bound artifacts.
  2. In the event that you have a reason for clean-up elsewhere, I was thinking of something similar to the below;
public async Task ClearTokenAsync(
            ClaimsPrincipal user,
            List<string> tokenNamesList,
            UserAccessTokenParameters? parameters = null)
        {
            parameters ??= new UserAccessTokenParameters();
            var result = await _contextAccessor!.HttpContext!.AuthenticateAsync(parameters.SignInScheme)!;

            if (result != null)
            {
                result.Properties.Items.Remove(TokenNamesKey);
                foreach (var tokenName in tokenNamesList)
                {
                    result.Properties.Items.Remove($"{TokenPrefix}{tokenName}");
                }
                result.Properties.Items.Add(new KeyValuePair<string, string?>(TokenNamesKey, string.Join(";", result.Properties.Items.Select(t => t.Key).ToList())));
                await _contextAccessor.HttpContext.SignInAsync(parameters.SignInScheme, user, result.Properties);
            }
        }

which isn't dissimilar to what is already there as a //TODO

public Task ClearTokenAsync(
            ClaimsPrincipal user,
            UserAccessTokenParameters? parameters = null)
        {
            // todo
            return Task.CompletedTask;
        }

If the above seems like a reasonable approach I can get a PR in for further refinement/discussion. Let me know your thoughts.

he reason I specified the scheme explicitly was that it simply didn't work without it. Or maybe just refreshing didn't work without it, I don't remember exactly.

@janruo It's not clear to me what you mean by "I specified the scheme explicitly was that it simply didn't work without it", I'd certainly be interested to understand though if you can provide some detail.

@janruo It's not clear to me what you mean by "I specified the scheme explicitly was that it simply didn't work without it", I'd certainly be interested to understand though if you can provide some detail.

I don't have that code anymore, but IIRC it was probably throwing this:

throw new InvalidOperationException("No OpenID Connect authentication scheme configured for getting client configuration. Either set the scheme name explicitly or set the default challenge scheme");

Called from GetUserAccessTokenAsync() -> RefreshUserAccessTokenAsync() -> GetRefreshTokenRequestAsync() -> GetOpenIdConnectSettingsAsync()

I have multiple different challenge schemes, so I can't set any of them as a default -> I needed to set the parameter explicitly.

@leastprivilege

  • allow a way to opt-in the challenge-specific stuff (b/c it's a nice scenario)
    I think what I have committed here achieves this.
  • if it is used, there should be a better cleanup to get rid of the non-challenge bound artifacts and thus reduce the size of the cookie again
    Two things I was thinking with regards to cleanup;
  1. As is the case for my requirement upon authentication it makes sense to me that as the last task e.g. in options.Events.OnUserInformationReceived, you should be able to grab any additional tokens, do a token exchange, etc and at that point clear out any non-challenge bound artifacts as well as set up all your challenge-bound artifacts.
  2. In the event that you have a reason for clean-up elsewhere, I was thinking of something similar to the below;
public async Task ClearTokenAsync(
            ClaimsPrincipal user,
            List<string> tokenNamesList,
            UserAccessTokenParameters? parameters = null)
        {
            parameters ??= new UserAccessTokenParameters();
            var result = await _contextAccessor!.HttpContext!.AuthenticateAsync(parameters.SignInScheme)!;

            if (result != null)
            {
                result.Properties.Items.Remove(TokenNamesKey);
                foreach (var tokenName in tokenNamesList)
                {
                    result.Properties.Items.Remove($"{TokenPrefix}{tokenName}");
                }
                result.Properties.Items.Add(new KeyValuePair<string, string?>(TokenNamesKey, string.Join(";", result.Properties.Items.Select(t => t.Key).ToList())));
                await _contextAccessor.HttpContext.SignInAsync(parameters.SignInScheme, user, result.Properties);
            }
        }

which isn't dissimilar to what is already there as a //TODO

public Task ClearTokenAsync(
            ClaimsPrincipal user,
            UserAccessTokenParameters? parameters = null)
        {
            // todo
            return Task.CompletedTask;
        }

If the above seems like a reasonable approach I can get a PR in for further refinement/discussion. Let me know your thoughts.

@leastprivilege do the suggestions seem reasonable?
@brockallen in case @leastprivilege isn't available now, just giving you a bump.

OK - let's start with making the challenge specific thing opt-in first.

Then we can think about the cleanup - ideally I do not want that the user needs to write any code for that. But we can discuss this later.

Could you create a PR for review?

OK - merged. I will do a release today...

@janruo FYI PR has been merged and released #298

@leastprivilege @janruo are we able to close this issue now?

Fix is merged and published to Nuget