Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
vvdb-architecture opened this issue Nov 17, 2023 · 1 comment
Labels
help wanted Extra attention is needed

Comments

@vvdb-architecture
Copy link
Contributor

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:

  • 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();
    }
}
@rdarko rdarko added the help wanted Extra attention is needed label Nov 22, 2023
@rdarko
Copy link
Collaborator

rdarko commented Jan 31, 2024

will be discussed in a separate meeting between V and G

@rdarko rdarko removed the help wanted Extra attention is needed label Jan 31, 2024
@rdarko rdarko added the help wanted Extra attention is needed label Jan 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

2 participants