ajax post request returns 400 Bad Request after refresh JWT Token

701 views Asked by At

ASP.NET Core MVC 5.

I am using Cookie Authentication with JWT Token as Claims for login authentication.

I also have an AutoValidateAntiForgeryToken attribute on each controller to deal with XSRF.

The JWT Token is set to expire in 30 minutes. In my CookieAuthenticationEvents.ValidatePrincipal(CookieValidatePrincipalContext context) event handler, If the token has expired, refresh the token.

At that time, to regenerate the authentication cookie with the new JWT Token, we set the CookeeValidatePrincipalContext.ShouldRenew property to true as shown below.

    var identity = new ClaimsIdentity(context.Principal.Identity);
    var sid = identity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Sid);
    identity.RemoveClaim(sid);
    identity.AddClaim(new Claim(ClaimTypes.Sid, newJwtToken));
    var newPrincipal = new ClaimsPrincipal(identity);
    context.ReplacePrincipal(newPrincipal);
    context.ShouldRenew = true;

As a result, 30 minutes after login, the AntiForgeryRequestToken held by the browser being invalid, and a POST using this RequestToken will result in a 400 Bad Request.

I would like to know the correct way to combine JWT Token and AntiForgery. Thank you.

Update: Until this JWT Token times out, ajax-posts with this AntiForgeryReqeuestToken will be accepted successfully.

Twenty minutes after logging in I click the "Update" button and it works correctly.

If I stay on the same screen and one minute later (i.e., 21 minutes after logging in) I push the "Update" button again, it still works correctly.

Repeat that and 30 minutes after logging in I push the button and I get a 400 Bad Request.

Here are some other code samples:

cshtml code:

@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Xsrf
@{
var xsrf = Xsrf.GetTokens(ViewContext.HttpContext); // GetAndStoreTokens are already called in a login page.
}

const ret = await $.post(
    '@Url.Action("Update")',
    {
        'id': 1234,
        'data1': "abc",
        '@(xsrf.FormFieldName)': '@(xsrf.RequestToken)'
    }
).promise();

ConfigureServices.cs:

services.AddScoped<CookieAuthenticationEventHandler>();
services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(x => {
        x.LoginPath = "/Home";
        x.AccessDeniedPath = "/Home";
        x.LogoutPath = "/Signout";
        x.EventsType = typeof(CookieAuthenticationEventHandler);
        x.ExpireTimeSpan = timeout;
        x.SlidingExpiration = true; 
    });

SigninController.SignIn(signinModel):

/* Check ID and Password */
var jwtToken = Authorize(signinModel);
Claim[] claims = { new Claim(ClaimTypes.Sid, jwtToken), };
var claimsIdentity = new ClaimsIdentity(
    claims,
    CookieAuthenticationDefaults.AuthenticationScheme
);
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
var timeout = config.GetValue<TimeSpan>("Timeout");
await HttpContext.SignInAsync(
    CookieAuthenticationDefaults.AuthenticationScheme,
    claimsPrincipal
);
HttpContext.User = claimsPrincipal;

the code creating a JWT Token( in the Authorize method):

public string CreateToken(IEnumerable<Claim> claims) {

    var timeStamp = DateTime.Now;
    var timeout = new TimeSpan("0.00:30:00");
    var token = new JwtSecurityToken(
        "https://unique-url",
        "https://unique-url",
        claims,
        timeStamp,
        expires: DateTime.Now.Add(timeout),
        signingCredentials: SigningCredentials
    );

    return new JwtSecurityTokenHandler().WriteToken(token);
}

CookieAuthenticationEventHandler:

public override async Task ValidatePrincipal(CookieValidatePrincipalContext context) {
    if(!await Validate(context)) {
        context.RejectPrincipal();
        await context.HttpContext.SignOutAsync();
    }
}

public async Task<bool> Validate(CookieValidatePrincipalContext context) {

    var jwtToken = context.Principal.Claims?.FirstOrDefault(x => x.Type == ClaimTypes.Sid)?.Value;
    var valid = await ValidatetokenAsync(jwtToken);

    if(valid) {
        return true;
    }

    var newJwtToken = await RefreshtokenAsync(jwtToken);
    if(string.IsNullOrEmpty(newJwtToken)) {
        return false;
    }

    var identity = new ClaimsIdentity(context.Principal.Identity);
    var sid = identity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Sid);
    identity.RemoveClaim(sid);
    identity.AddClaim(new Claim(ClaimTypes.Sid, newJwtToken));
    var newPrincipal = new ClaimsPrincipal(identity);
    context.ReplacePrincipal(newPrincipal);
    context.ShouldRenew = true;

    return true;
}

Update 2:

The root causes of this problem:

(1) The ClaimsPrincipal passed to HttpContext.SingInAsync contains a JWT Token.

(2) The expiration date of the login session is different from the expiration date of the JWT Token. The expiration date of the login session is extended each time it is accessed, but the JWT Token is refreshed when it expires.

(3) When the JWT Token expires and the token is refreshed, the ClaimsPrincipal of the HttpContext must also be updated.

(4) Because AntiForgery performs validation that depends on this ClaimsPrincipal, any AntiForgeryToken obtained before the JWT Token is refreshed will (suddenly) become invalid.

(5) Therefore, if HTTP-POST is performed when the JWT Token expires, AntiForgery will fail.

If this method is wrong, I would like to know the correct method. If this method is not wrong, please let me know how to work around it.

1

There are 1 answers

0
Jun1s On

[Solved]

It is unavoidable to rebuild the ClaimsPrincipal of CookieAuthentication when refreshing the JWT, but I was looking for a setting for AntiForgery to correctly validate the XSRF-TOKEN created before rebuilding even after rebuilding. .

And finally I found it in the source code of AspNetCore.AntiForgery. https://github.com/dotnet/aspnetcore/blob/v3.1.4/src/Antiforgery/src/Internal/DefaultClaimUidExtractor.cs#L25

AntiForgery uses an user identifier when generating and validating the XSRF-TOKEN, which is the ClaimType="sub" in ClaimsPrincipal, or ClaimType.NameIdentifier, ClaimType.Upn. If they are not found, AntiForgery will use all claims as the user identifier.

In my source, I was just passing a JWT as ClaimType.Sid to the ClaimsPrincipal for HttpContext.SignInAsync.

Because of this, after the JWT was refreshed and the ClaimsPrincipal was rebuilt, AntiForgery used the whole rebuilt ClaimsPrincipal to check the XSRF-TOKEN and validation failed.

By adding a ClaimType of "sub" to the ClaimsPrincipal, the 400 Bad Request no longer occurs.

Claim[] claims = {
    new Claim(ClaimTypes.Sid, jwtToken),
    new Claim("sub", loginUser.Id),
};