3

My application is an ASP.NET Core 9 Web API. I configure authentication with this extension method:

public static void SetupAuthentication(this IServiceCollection services)
{
    var authSettings = services.BuildServiceProvider().GetService<IOptionsSnapshot<AuthSettings>>()?.Value;

    // JWT Configuration
    services
        .AddAuthentication(options =>
        {
            options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
        })
        .AddJwtBearer(options =>
        {
            options.SaveToken = true;
            options.RequireHttpsMetadata = false;
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidateAudience = true,
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                ValidIssuer = authSettings!.Jwt.Issuer,
                ValidAudience = authSettings!.Jwt.Audience,
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authSettings!.Jwt.Secret)),
                ClockSkew = TimeSpan.Zero,
            };
        })
        .AddGoogle(options =>
        {
            options.ClientId = authSettings!.Google.ClientId;
            options.ClientSecret = authSettings!.Google.ClientSecret;
        });
}

Elsewhere, I register TimeProvider with

public static void RegisterServices(this IServiceCollection services)
{
    services.AddScoped<ITokenService, TokenService>();

    // used for time manipulation and testing
    // we should use this instead of DateTime.Now
    services.TryAddSingleton(TimeProvider.System);
}

I use a method in TokenService to create JWTs that can be used to access the API. This method usually looks like this:

public AccessTokenResponse CreateAccessToken(ApplicationUser user, string[] roles)
{
    List<Claim> claims =
    [
        new(JwtRegisteredClaimNames.Sub, user.Id),
        new(JwtRegisteredClaimNames.Email, user.Email!),
        new(JwtRegisteredClaimNames.EmailVerified, user.EmailConfirmed.ToString()),
    ];

    claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

    var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));
    var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha512Signature);
    var expiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpiryInMinutes);

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(claims),
        Expires = expiresAt,
        SigningCredentials = credentials,
        Issuer = _jwtSettings.Issuer,
        Audience = _jwtSettings.Audience,
    };

    var tokenHandler = new JwtSecurityTokenHandler();
    var token = tokenHandler.CreateToken(tokenDescriptor);

    return new AccessTokenResponse(tokenHandler.WriteToken(token), expiresAt);
}

But now I wish to use Timeprovider so I can test token expiration plus some. So I updated the method, now it looks like this:

public AccessTokenResponse CreateAccessToken(ApplicationUser user, string[] roles)
{
    List<Claim> claims =
    [
        new(JwtRegisteredClaimNames.Sub, user.Id),
        new(JwtRegisteredClaimNames.Email, user.Email!),
        new(JwtRegisteredClaimNames.EmailVerified, user.EmailConfirmed.ToString()),
    ];

    claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

    var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));
    var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha512Signature);

    var now = timeProvider.GetUtcNow();
    var expiresAt = now.DateTime.AddMinutes(_jwtSettings.ExpiryInMinutes);
    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(claims),
        Expires = expiresAt,
        SigningCredentials = credentials,
        Issuer = _jwtSettings.Issuer,
        Audience = _jwtSettings.Audience,
        NotBefore = now.DateTime,
        IssuedAt = now.DateTime,
    };

    var tokenHandler = new JwtSecurityTokenHandler();
    var token = tokenHandler.CreateToken(tokenDescriptor);
    return new AccessTokenResponse(tokenHandler.WriteToken(token), expiresAt);
}

Of course, timeProvider is injected into the service for the DI container to resolve. Now, when I run my tests, I get this error:

[Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler]
Bearer was not authenticated. Failure message: IDX10223: Lifetime validation failed. The token is expired. ValidTo (UTC): '12/31/1999 11:15:00 PM', Current time (UTC): '4/30/2025 3:06:23 PM'.

OR

System.ArgumentException: IDX12401: Expires: '01/01/2000 00:15:00' must be after NotBefore: '30/04/2025 08:08:05'.
at System.IdentityModel.Tokens.Jwt.JwtPayload.AddFirstPriorityClaims(String issuer, String audience, IList1 audiences, Nullable1 notBefore, Nullable1 expires, Nullable1 issuedAt)

I've tried setting TimeProvider in my authentication configuration - no luck. I've tried setting a value for NowBefore and IssuedAt in SecurityTokenDescriptor - no luck.

For reference, here's the CustomWebApplicationFactory where I register a FakeTimeProvider:

public class CustomWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    public readonly FakeTimeProvider FakeTimeProvider = new();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            var timeProviderDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(TimeProvider));
            if (timeProviderDescriptor != null)
            {
                services.Remove(timeProviderDescriptor);
                services.AddSingleton<TimeProvider>(FakeTimeProvider);
            }
        });
    }
}

I've also tried initialising FakeTimeProvider with a specific date - no luck. Am I doing something wrong? Is there a specific way to setup TimeProvider in integration tests?

My aim is to use it everywhere, instead of the static DateTime methods.

0

2 Answers 2

3

Microsoft.AspNetCore.Authentication.JwtBearer 9.0.4 has a transitive dependency on Microsoft.IdentityModel.Tokens 8.0.1 where you can track what produced the exception with IDX10223 from your question:

... Failure message: IDX10223: Lifetime validation failed. The token is expired. ValidTo (UTC): '12/31/1999 11:15:00 PM', Current time (UTC): '4/30/2025 3:06:23 PM'.

to this code:

internal static LifetimeValidationResult ValidateLifetime(...)
...
DateTime utcNow = DateTime.UtcNow;

if (expires.HasValue && (expires.Value < DateTimeUtil.Add(utcNow, validationParameters.ClockSkew.Negate())))
    return new LifetimeValidationResult(
        notBefore,
        expires,
        ValidationFailureType.LifetimeValidationFailed,
        new ExceptionDetail(
            new MessageDetail(
                LogMessages.IDX10223,
                LogHelper.MarkAsNonPII(expires.Value),
                LogHelper.MarkAsNonPII(utcNow)),
            typeof(SecurityTokenExpiredException),
            new StackFrame(true)));

As you can see, the built-in validation logic is not using a TimeProvider. As suggested in the other answer by Ivan Voronin sysAdmin, the o.TokenValidationParameters.TimeProvider could be a solution and indeed it's been added as an internal property - see this commit. But at this time it's not publicly exposed / cannot be set by us.

Still, if we scroll a bit up in LifetimeValidationResult ValidateLifetime:

if (validationParameters.LifetimeValidator != null)
    return ValidateLifetimeUsingDelegate(notBefore, expires, securityToken, validationParameters);

we can see a workaround that is currently available - to provide a LifeTimeValidator delegate whose body has a reference to the DI registered TimeProvider. I find the least invasive way would be to create a simple implementation of IPostConfigureOptions<JwtBearerOptions> (post so it runs last and overwrites just the LifetimeValidator property):

public class CustomValidatorJwtBearerOptions :
        IPostConfigureOptions<JwtBearerOptions>
{
    private readonly TimeProvider _timeProvider;
    // just for test debugging visibility - optional
    private readonly ILogger<CustomValidatorJwtBearerOptions> _logger;

    public CustomValidatorJwtBearerOptions(TimeProvider timeProvider, ILogger<CustomValidatorJwtBearerOptions> logger) {
        _timeProvider = timeProvider;
        _logger = logger;
    }

    public void PostConfigure(string name, JwtBearerOptions options) {
        options.TokenValidationParameters.LifetimeValidator = (notBefore, expires, token, parameters) =>
        {
            DateTime utcNow = _timeProvider.GetUtcNow().UtcDateTime;
            // logic is based on https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/blob/8.0.1/src/Microsoft.IdentityModel.Tokens/Validation/Validators.Lifetime.cs#L100

            // if No Skew
            //if (notBefore.HasValue && utcNow < notBefore.Value)
            //{
            //    return false;
            //}
            //if (expires.HasValue && utcNow > expires.Value)
            //{
            //    return false;
            //}

            // with skew
            if (notBefore.HasValue && notBefore.Value > DateTimeUtil.Add(utcNow, parameters.ClockSkew)) {
                _logger.LogInformation("Token is not valid before {NotBefore}. Current time (with skew) is {AdjustedTime}.",
                    notBefore.Value.ToString("yyyy-MM-dd HH:mm:ss.fff"), DateTimeUtil.Add(utcNow, parameters.ClockSkew).ToString("yyyy-MM-dd HH:mm:ss.fff"));
                return false;
            }

            if (expires.HasValue && expires.Value < DateTimeUtil.Add(utcNow, parameters.ClockSkew.Negate())) {
                _logger.LogInformation("Token expired at {Expires}. Current time (with skew) is {AdjustedTime}.",
                    expires.Value.ToString("yyyy-MM-dd HH:mm:ss.fff"), DateTimeUtil.Add(utcNow, parameters.ClockSkew.Negate()).ToString("yyyy-MM-dd HH:mm:ss.fff"));
                return false;
            }
            return true;
        };
    }
}

so, we only need to add two lines in the WebApplicationFactory DI configuration:

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder) {
        builder.ConfigureTestServices(services =>
        {
            services.ConfigureOptions<CustomValidatorJwtBearerOptions>();
            services.AddSingleton<TimeProvider, FakeTimeProvider>();
        });
    }
}
Sign up to request clarification or add additional context in comments.

1 Comment

Hi. This solved my problem, thank you for the help.
0

JwtBearerOptions and the TokenValidationParameters instance are created before you swap TimeProvider in ConfigureTestServices, so the handler still uses TimeProvider.System while your TokenService uses the FakeTimeProvider.

patch:

builder.ConfigureTestServices(services =>
{
    var fakeClock = new FakeTimeProvider();
    services.RemoveAll<TimeProvider>();
    services.AddSingleton<TimeProvider>(fakeClock);

    // make the jwt middleware use th same clock
    services.PostConfigure<JwtBearerOptions>(
        JwtBearerDefaults.AuthenticationScheme,
        o =>
        {
            o.TimeProvider = fakeClock;
            o.TokenValidationParameters.TimeProvider = fakeClock;
        });
});

1 Comment

no luck, unfortunately. Also the TimeProvider doesn't exist on TokenValidationParameters. Any other suggestions?

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.