'Token doesn't get refreshed when sending request with Ajax

I'm using IdentityServer4 as a centralized auth server. I have a mvc app that acts as a client and works as expected unless I open a modal. When the user presses the edit button,I load the data into the modal and send it to the update endpoint in mvc client itself. Ajax request looks like this:

$.ajax({
        xhrFields: { withCredentials: true },
        crossDomain:true,
        url: url,
        type: 'POST',
        headers: {
            //added these because working request included them
            'Access-Control-Allow-Origin':'https://localhost:5001',
            'Upgrade-Insecure-Requests': 1,
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
        },
        data: data,
        success: function (res) {
            $(`#${id}-body`).html(res);
        }
    })

Here is the problem: If the token is expired, request sent from Ajax doesn't refresh it. connect/authorize endpoint gets called but it doesn't invoke the signin/oidc request as it normally does(eg. when refreshing the page or going to a view that isn't a modal.). The difference I noticed between these 2 (normal views/views called from Ajax) is,that the response size of the connect/authorize call has a slightly longer state query parameter,call from Ajax also invokes a preflight request first(which is understandable,but shouldn't be MVC endpoints that call IdentityServer endpoints make Options request as well?) and headers are also different:
Request from Ajax

Host: localhost:5001
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36
Access-Control-Allow-Origin: https://localhost:5001
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Upgrade-Insecure-Requests: 1
Request-Id: |be8b9ea0bfef4656a45fceb5c1f35e18.e173569e490b443c
sec-ch-ua-platform: "Windows"
Request-Context: appId=cid-v1:b526a0aa-46b6-4b9f-abe4-27f22e832ca5
Origin: https://localhost:6001
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://localhost:6001/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9

Request from mvc itself(This one works fine):

Host: localhost:5001
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Referer: https://localhost:6001/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9

Here is the IdentityServer4 config:


services.AddIdentityServer(x =>
            {
                x.Authentication.CookieLifetime = 
                TimeSpan.FromMinutes(15);
                x.UserInteraction.LoginUrl = LoginStr;
                x.UserInteraction.LogoutUrl = LogoutStr;
                
            })
            .AddAspNetIdentity<AppUser>()
            .AddIdentityServerConfiguration(env, Urls);

        
        services.ConfigureApplicationCookie(x =>
        {
            x.LoginPath = LoginStr;
            x.LogoutPath = LogoutStr;
            x.AccessDeniedPath = AccessDeniedPath;
        });

        services.AddCors(options =>
        {
            options.AddDefaultPolicy(
                builder =>
                {
                    builder
                    .WithOrigins(
                        "https://localhost:5001",
                        "https://localhost:6001")
                    .AllowAnyHeader()
                    .AllowAnyMethod()
                    .AllowCredentials();
                });
        });

MVC client config in IS4:

ClientId = "MVC Client",
            ClientName = "MVC App Name",
            AllowedGrantTypes = GrantTypes.Code,
            ClientSecrets = { new Secret("super-secret-code".Sha256()) },
            RedirectUris = { "https://localhost:6001/signin-oidc" },
            PostLogoutRedirectUris = { "https://localhost:6001/signout-callback-oidc" },
            RequirePkce = true,
            RequireConsent = false,
            AllowOfflineAccess = true,
            AllowedScopes =
            {
                //removed for brevity
            }

And client config on the MVC side:

x.SignInScheme = "Cookies";

            x.Authority = IDProvider;
            x.RequireHttpsMetadata = false;

            x.ClientId = "MVC Client";
            x.ClientSecret = "super-secret-code";
            x.ResponseType = "code";
            x.UsePkce = true;
            x.SaveTokens = true;
            x.GetClaimsFromUserInfoEndpoint = true;

            //scope and claims removed for brevity 

            x.SignInScheme = "Cookies";
            x.UseTokenLifetime = true;

So far, I have tried many things,such as messing with the cookie settings in both MVC and identityserver app,adding some more headers to the Ajax request etc. but to no avail.I probably can fix this via AuthenticationHandler,but would like to know if theres a simpler,cleaner way.



Solution 1:[1]

You can't call the authorize endpoint of IdentityServer in an API driven manner, or using a modal:

  • The third party SSO cookie from IdentityServer is stored in the browser. It will be dropped during an Ajax request due to the lack of a user gesture. There are other problens such as an Ajax client not having permissions to read response headers such as location, in order to follow redirects.

  • Instead you must perform a top level redirect - this is not possible in a modal dialog. You need to accept the top level redirect and deal with the usability impact.

API DRIVEN TRENDS

The Hypermedia Authentication API will support login via a modal dialog. This involves sending authentication requests with a particular media type. This is an emerging standard though, and not currently available in IdentityServer.

In the meantime, the most cutting edge way to login to a web app is via a form of Backend for Frontend called the Token Handler Pattern. This performs the OAuth work in an API driven manner and provides best control to the UI, eg the reasons behind using a modal dialog and perhaps losing the user's state.

SUMMARY

The best web security solutions involve a strict separation of web and API concerns. They are highly architectural though, and require a strong understanding of architecture. There are no quick wins unfortunately. If you are interested in web architectures, you may find our SPA Security Whitepaper useful.

Solution 2:[2]

Are you receiving any logs/errors in your request response?

I'm just wondering when you submit your token, does it get picked up by your controller?

If so, perhaps you could try sending the updated struct to an endpoint that acts as a gatekeeper microservice exposing some functionality to store a reference of the data in memory so that if anything happens resulting in the loss of state between requests we can always pull the value from memory.

Then if you can you could send the updated value across a WebSocket and attach an event listener use a service worker to have it show up in your UI creating a pub/sub effect.

So you would register the service worker as soon as you submit your Ajax request:

const registerServiceWorker = async () => {
  if ('serviceWorker' in navigator) {
    try {
      const registration = await navigator.serviceWorker.register(
        '/sw-test/sw.js',
        {
          scope: '/sw-test/',
        }
      );
      if (registration.installing) {
        console.log('Service worker installing');
      } else if (registration.waiting) {
        console.log('Service worker installed');
      } else if (registration.active) {
        console.log('Service worker active');
      }
    } catch (error) {
      console.error(`Registration failed with ${error}`);
    }
  }
};

// ...

registerServiceWorker();

Then you add your event listener that will wait for the incoming data

self.addEventListener('fetch', (event) => {
  event.respondWith(
    // magic goes here
  );
});

You could use Service Navigation Preload as indicated in the example below to start downloading resources as soon as the fetch request is made, and in parallel with service worker bootup ensuring download starts immediately on the navigation to a page, rather than having to wait until the service worker has booted.

const enableNavigationPreload = async () => {
  if (self.registration.navigationPreload) {
    // Enable navigation preloads!
    await self.registration.navigationPreload.enable();
  }
};

self.addEventListener('activate', (event) => {
  event.waitUntil(enableNavigationPreload());
});

Just an idea?

Reference

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Gary Archer
Solution 2 0x0147k3r