Currently, TokenRefreshInfo is used to hold on to access tokens and refresh tokens. This is a scoped class that is valid for the duration of the request.
This explicitly scoped instance can be removed because there is already a scoped ClaimsIdentity whose BootstrapContext can hold any object. It can therefore also hold a TokenRefreshInfo instance.
We can even restrict ourselves to a TokenInfo instance if we make sure that whatever token we put in there has a sufficiently long lifetime to be used throughout the request: then we don't need the refresh token information and a TokenInfo with access token information and expiration date (for checking) should suffice.
The advantage is that it simplifies the refresh logic: ITokenRefreshProvider and its implementation RefreshTokenProvider are no longer needed, because you make sure that when the BootstrapContext is initialized, it will contain a sufficiently "fresh" token.
If we want to do this, we need to resolve three problems:
Today, Arc4u currently uses BootstrapContext to store a simple string (i.e. the access token value)
Today, Arc4u assumes in some providers that TokenRefreshInfo is a scoped instance obtained from the service provider: this will need to be rewritten.
For cookie-based authentication, the BootstrapContext is never set.
Resolving problem 1 is trivial. There are only 13 occurrences of BootstrapContext in Arc4u, 3 of them are assignments.
For example, changing the assignments:
identity.BootstrapContext = token.Token; can be replaced with identity.BootstrapContext = token; (2 occurrences)
identity.BootstrapContext = accessToken.RawData; can be replaced with identity.BootstrapContext = new TokenInfo("access_token", accessToken.RawData, accessToken.ValidTo.ToUniversalTime()); (1 occurrence)
For the other 10 occurrences, it's a simple matter of casting to the correct type. Perhaps it can be factored out in an extension method on ClaimsIdentity:
public static class ClaimsIdentityExtensions
{
public static bool TryGetAccessToken(this ClaimsPrincipal principal, [MaybeNullWhen(false)] out string accessToken)
{
if (principal.Identity is ClaimsIdentity claimsIdentity && claimsIdentity.BootstrapContext is TokenInfo tokenInfo)
{
accessToken = tokenInfo.Token;
return true;
}
else
{
accessToken = default;
return false;
}
}
}
Problem 2 is easy as well: we use the IApplicationContext to get at the principal. For example, here is what OidcTokenProvider would look like:
[Export(ProviderName, typeof(ITokenProvider))]
public class OidcTokenProvider : ITokenProvider
{
public const string ProviderName = "Oidc";
public OidcTokenProvider(IApplicationContext applicationContext)
{
_applicationContext = applicationContext;
}
private readonly IApplicationContext _applicationContext;
public Task<TokenInfo> GetTokenAsync(IKeyValueSettings settings, object platformParameters)
{
ArgumentNullException.ThrowIfNull(settings);
if (_applicationContext.Principal.Identity is ClaimsIdentity claimsIdentity && claimsIdentity.BootstrapContext is TokenInfo
tokenInfo)
return Task.FromResult(tokenInfo.AccessToken);
else
throw new InvalidOperationException($"The bootstrap context is not correctly initialized");
}
public ValueTask SignOutAsync(IKeyValueSettings settings, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}
Problem 3 is the most complicated one, but the cookie event ValidatePrincipal can be re-implemented to take care of the BootstrapContext initialization, making sure that the token is "fresh":
public class StandardCookieEvents : CookieAuthenticationEvents
{
public StandardCookieEvents(IOptions<OidcAuthenticationOptions> oidcOptions, IOptionsMonitor<OpenIdConnectOptions> openIdConnectOptions, ILogger<StandardCookieEvents> logger)
{
_httpClient = new HttpClient(); // never disposed, but StandardCookieEvents is a singleton anyway
_openIdConnectOptions = openIdConnectOptions;
_logger = logger;
_forceRefreshTimeout = oidcOptions.Value.ForceRefreshTimeoutTimeSpan;
}
private readonly ILogger<StandardCookieEvents> _logger;
private readonly IOptionsMonitor<OpenIdConnectOptions> _openIdConnectOptions;
private readonly HttpClient _httpClient;
private readonly TimeSpan _forceRefreshTimeout;
public override async Task ValidatePrincipal(CookieValidatePrincipalContext cookieCtx)
{
try
{
var tokens = cookieCtx.Properties.GetTokens();
var exp = tokens.First(t => t.Name == "expires_at");
var expires = DateTime.Parse(exp.Value, styles: DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
var timeRemaining = expires.Subtract(DateTime.UtcNow);
AuthenticationToken accessToken;
var refreshToken = tokens.FirstOrDefault(t => t.Name == "refresh_token");
//check to see if the token has expired
if (timeRemaining < _forceRefreshTimeout)
{
// We can only renew if there's a refesh token
if (refreshToken is null)
{
await RejectAndLogout(cookieCtx);
return;
}
var options = _openIdConnectOptions.Get(OpenIdConnectDefaults.AuthenticationScheme);
var cancellationToken = cookieCtx.HttpContext.RequestAborted;
var config = await options!.ConfigurationManager!.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
// Use the token client for standardized access. This is what also should happen in RefreshTokenProvider instead of the manual post request on the backchannel.tokenendpoint there.
var tokenClient = new TokenClient(_httpClient, new TokenClientOptions { Address = config.TokenEndpoint, ClientId = options.ClientId, ClientSecret = options.ClientSecret });
var tokenResponse = await tokenClient.RequestRefreshTokenAsync(refreshToken.Value, cancellationToken: cancellationToken);
// check for error while renewing - any error will trigger a new login.
if (tokenResponse.IsError)
{
if (tokenResponse.Exception is not null)
_logger.Technical().LogException(tokenResponse.Exception);
else
_logger.Technical().LogError($"{tokenResponse.ErrorType}: {tokenResponse.Error} {tokenResponse.ErrorDescription}");
await RejectAndLogout(cookieCtx);
return;
}
// set new token values
// Note that a new refresh token is never provided on ADFS 2019. We keep using the old one until it expires and the call to RequestRefreshTokenAsync will fail
if (!string.IsNullOrEmpty(tokenResponse.RefreshToken))
refreshToken.Value = tokenResponse.RefreshToken;
// the assumption we make here is that there is always an access_token
accessToken = tokens.First(t => t.Name == "access_token");
accessToken.Value = tokenResponse.AccessToken;
// set new expiration date
expires = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResponse.ExpiresIn);
exp.Value = expires.ToString("o", CultureInfo.InvariantCulture);
// set tokens in auth properties
cookieCtx.Properties.StoreTokens(tokens);
// trigger context to renew cookie with new token values
cookieCtx.ShouldRenew = true;
}
else
accessToken = tokens.First(t => t.Name == "access_token");
// Set the bootstrap token for the principal to the access token we've obtained. This is normally always possible.
if (cookieCtx.Principal?.Identity is ClaimsIdentity claimsIdentity)
claimsIdentity.BootstrapContext = new TokenRefreshInfo
{
AccessToken = new TokenInfo(accessToken.Name, accessToken.Value, expires),
// As not all the autorities are using a jwt token for the refresh token, the expiration date is not extracted from the token
// A refresh token is not always present: we need to handle the null case even if TokenRefreshInfo.RefreshToken is not nullable at this time.
RefreshToken = (refreshToken is null ? null : new TokenInfo(refreshToken.Name, refreshToken.Value, cookieCtx.Properties.ExpiresUtc!.Value.UtcDateTime))!
};
}
catch (Exception e)
{
_logger.Technical().LogException(e);
await RejectAndLogout(cookieCtx);
}
}
private static Task RejectAndLogout(CookieValidatePrincipalContext cookieValidationContext)
{
cookieValidationContext.RejectPrincipal();
return cookieValidationContext.HttpContext.SignOutAsync();
}
}
Currently,
TokenRefreshInfo
is used to hold on to access tokens and refresh tokens. This is a scoped class that is valid for the duration of the request.This explicitly scoped instance can be removed because there is already a scoped
ClaimsIdentity
whoseBootstrapContext
can hold any object. It can therefore also hold aTokenRefreshInfo
instance.We can even restrict ourselves to a
TokenInfo
instance if we make sure that whatever token we put in there has a sufficiently long lifetime to be used throughout the request: then we don't need the refresh token information and a TokenInfo with access token information and expiration date (for checking) should suffice.The advantage is that it simplifies the refresh logic:
ITokenRefreshProvider
and its implementationRefreshTokenProvider
are no longer needed, because you make sure that when theBootstrapContext
is initialized, it will contain a sufficiently "fresh" token.If we want to do this, we need to resolve three problems:
BootstrapContext
to store a simple string (i.e. the access token value)TokenRefreshInfo
is a scoped instance obtained from the service provider: this will need to be rewritten.BootstrapContext
is never set.Resolving problem 1 is trivial. There are only 13 occurrences of
BootstrapContext
in Arc4u, 3 of them are assignments.For example, changing the assignments:
identity.BootstrapContext = token.Token;
can be replaced withidentity.BootstrapContext = token;
(2 occurrences)identity.BootstrapContext = accessToken.RawData;
can be replaced withidentity.BootstrapContext = new TokenInfo("access_token", accessToken.RawData, accessToken.ValidTo.ToUniversalTime());
(1 occurrence)For the other 10 occurrences, it's a simple matter of casting to the correct type. Perhaps it can be factored out in an extension method on
ClaimsIdentity
:Problem 2 is easy as well: we use the
IApplicationContext
to get at the principal. For example, here is whatOidcTokenProvider
would look like:Problem 3 is the most complicated one, but the cookie event
ValidatePrincipal
can be re-implemented to take care of theBootstrapContext
initialization, making sure that the token is "fresh":