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:
- 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.
- 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:
- 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.0expires
variable will always contain the expiration for the initial sign in, no matter how many times it's refreshed.
- 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;
- 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".
- 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?
-
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;
- 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.
- 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:
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.
- 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;
- 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.
- 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...
@leastprivilege @janruo are we able to close this issue now?
Fix is merged and published to Nuget