GFlisch / Arc4u

Apache License 2.0
23 stars 18 forks source link

Investigate if it is possible to eliminate TokenRefreshInfo and use ClaimsIdentity.BootstrapContext instead. #90

Open vvdb-architecture opened 11 months ago

vvdb-architecture commented 11 months ago

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:

  1. Today, Arc4u currently uses BootstrapContext to store a simple string (i.e. the access token value)
  2. Today, Arc4u assumes in some providers that TokenRefreshInfo is a scoped instance obtained from the service provider: this will need to be rewritten.
  3. 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:

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();
    }
}
rdarko commented 9 months ago

will be discussed in a separate meeting between V and G