Managing Azure AD Web App sessions with OpenID Connect and Web API access token duration

INTRODUCTION

Some months ago, I came across an unexpected rare issue when dealing with AJAX calls in a MVC Web App that was making use of OpenID Connect (OIDC) protocol to provide authentication on Azure Active Directory (Azure AD). This MVC Web App was set up to call several Web APIs protected by Azure AD authentication too. You can find more information about this basic scenario made up of a Web App connecting to a Web API here. More in detail, Web APIs were employed in a simple straighforward way as if they were "microservices", but this specific point is only for further information, not related to the main issue.

In short, the issue was arising when an AJAX call was made after one hour without user interaction with the MVC Web App. A Cross Origin Resource Sharing (CORS) was thrown in the middle of a set of calls between the browser, the MVC Web App and the Azure AD endpoints. In this regard, not only had we fix this awful CORS error appearing in the browser console without informing the user but also find out and solve the main issue that was originating these unexpected calls. So, getting back to the main point, it turns out that Azure AD session and access token were lost and worse, if user was trying to save data, these data were lost too. Not fun at all. But, what was happening? What was the origin of the issue? Why a CORS error was arising?

After longer hours of investigations, a lot of time navigating through Microsoft official documentation, multiple technical blogs and forums, everything had to do with the way that Azure Active Directory Authentication Library (ADAL) middleware (MW) manages Azure AD sessions and Azure access token duration. An additional customized mechanism to automatically renew Azure AD sessions and access tokens before expiring were required. Let's see more in detail the issue and the fix.

BACKGROUND

First of all, I would like to comment briefly some key points revolving around this issue. I will provide you with links to get more information. This article aims to be mainly practical and yet, useful, anything else. So, let's see some relevant points.

Concerning the way of handling the CORS error, the fix was somewhat simple. Before going ahead, on one hand you should know that browsers handle AJAX 302 HTTP responses automatically and on the other hand, if an AJAX call is made to a different domain than ours, a CORS error will be thrown if the target domain was not configured to accept requests from ours. Browsers do not allow these AJAX cross requests with different origin domains by security reasons. So, we modified our application code to prevent the browser from getting 302 HTTP redirect responses when dealing with AJAX calls between different domains. To accomplish this goal, we implemented a handler to convert 302 HTTP responses (Found | Redirect to URL provided in the "Location" header) into 401 HTTP responses (Unathorized) including further information in custom HTTP headers. This way, javascript code in MVC Web App was able to detect this particular case and show a customized warning message to the final user. We had not resolved the main issue but at least, the user was informed about the error and even much more important, we started to handle 302 HTTP responses on AJAX calls appropriately. You can find more information and discussions about this at here. In any case, here is an example function "HandleAjaxRequest" called from Application_EndRequest method whithin Global.asax file you may take as a template:

private void HandleAjaxRequest()
{
    if (new HttpRequestWrapper(this.Request).IsAjaxRequest())
    {
        int statusCode = this.Response.StatusCode;
        bool isRedirectToAzureLogin = false;
        switch (statusCode)
        {
            case (int)HttpStatusCode.Found:
                /* 302 HTTP Response. "Location" header contains URL to redirect */
                string azureLoginURL = "https://login.microsoftonline.com/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";
                isRedirectToAzureLogin = this.Response.Headers["Location"].Contains(azureLoginURL);
                break;
            case (int)HttpStatusCode.OK:
                /* Place your custom code */
                break;
            default:
                break;
        }

        if (isRedirectToAzureLogin)
        {
            /* Convert 302 HTTP response to 401 HTTP Response */
            this.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
            this.Response.Output.Write(JsonConvert.SerializeObject(new
            {
                /* Consider this JSON object to handle it properly in your front-end code */
                /* Set your Custom Error or Warning Message */
                Error = "Your Azure AD session gets expired. Please, ..."; 
            }));
        }
    }
}

Regarding application architecture, here is a diagram where it is shown the typical flow performed when a browser interacts with a Web App and this one, in turn, with a Web API. You can see this picture with corresponding explanations at here.

Regarding the above flow I would like to point out some important key considerations:

  • When a new session is started with the user in MVC Web App a new cookie is returned to control this session. This "sesion cookie" is created based on "ID Token" and as long as this cookie is valid, user will be considered as authenticated in MVC Web App. If you are using, as I am, OpenID Connect (OIDC), then the "id_token" received by the MVC Web app during the user authentication transaction will be employed to create the cookie. In this regard, by default,the "id_token" will last one hour, and this duration will be the same for the "session cookie". By using ADAL, you have options to customize the cookie name and other properties.
  • In addition to this, this middleware (MW) provides sliding expiration by default. It is very important to notice as we must know what implies. In short, with sliding expiration to true ADAL tries to renew the cookie silently when the first request in the second half part of the windows expiration time is made. In other words, if we do not update the default values, ADAL will try to renew the cookie after 30 minutes (as the default duration is 60 minutes). Therefore, if the first request in the second half part is made by means of an AJAX request will likely get a CORS error. To avoid this, set the sliding expiration property to false.

Here is a code fragment to set the cookie name to ".AspNet.Cookies" by using ADAL:

private void ConfigureAzureADAuthentication(IAppBuilder app)
{
    /* Set authentication type to "Cookies" */         
    app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
    ...
    /* Add a cookie-based authentication middleware to the OWIN pipeline */
    app.UseCookieAuthentication(new CookieAuthenticationOptions()
    {
      CookieName = ".AspNet.Cookies",
      LogoutPath = new PathString("/Account/LogOff"),
      SlidingExpiration = false
    });
    ...
}
  • Delegated user identity by means of OpenID Connect (OIDC) is used to authenticate users between MVC Web App and Web API. As explained above, the "auth code" along with "App Client ID" and "Credentials" are used to call to "Azure AD Token Endpoint" to get an "access token" and a "refresh token" that will be used to gain access to the required resources in the Web API. The duration of the "access token" is one hour by default and, this will be the type of token and time valid for making authenticated requests to Web APIs. If we want not to have troubles with these tokens we should renovate them before expiration.
  • MVC Web App will use the "access token" to include it in an authorization HTTP header as a bearer token.
  • MVC Web App must save the "access token" in a secure way.

Here is the previous code fragment but modified to include the main steps to configure ADAL MW:

private void ConfigureAzureADAuthentication(IAppBuilder app)
{
    /* Set authentication type to "Cookies" */         
    app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
    ...
    /* Add a cookie-based authentication middleware to the OWIN pipeline */
    app.UseCookieAuthentication(new CookieAuthenticationOptions()
    {
      CookieName = ".AspNet.Cookies",
      LogoutPath = new PathString("/Account/LogOff"),
      SlidingExpiration = false
    });
    
    /* Adds the Microsoft.Owin.Security.OpenIdConnect.OpenIdConnectAuthenticationMiddleware into the OWIN runtime */
    app.UseOpenIdConnectAuthentication( new OpenIdConnectAuthenticationOptions
    {
        UseTokenLifetime = true,
        ClientId = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
        Authority = "https://login.microsoftonline.com/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
        PostLogoutRedirectUri = "https://localhost:XXXXX/",
        RedirectUri = "https://localhost:XXXXX/",

        Notifications = new OpenIdConnectAuthenticationNotifications()
        {
            RedirectToIdentityProvider = (context) => 
            { 
                Debug.WriteLine("Executing RedirectToIdentityProvider"); 
                return Task.FromResult(0); 
            }, 
            MessageReceived = (context) => 
            { 
                Debug.WriteLine("Executing MessageReceived"); 
                return Task.FromResult(0); 
            }, 
            SecurityTokenReceived = (context) =>
            { 
                Debug.WriteLine("Executing SecurityTokenReceived"); 
                return Task.FromResult(0); 
            }, 
            SecurityTokenValidated = (context) => 
            { 
                Debug.WriteLine("Executing SecurityTokenValidated"); 
                return Task.FromResult(0); 
            }, 
            AuthorizationCodeReceived = (context) => 
            { 
                Debug.WriteLine("Executing AuthorizationCodeReceived"); 
                return Task.FromResult(0); 
            }, 
            AuthenticationFailed = (context) => 
            { 
                Debug.WriteLine("Executing AuthenticationFailed"); 
                return Task.FromResult(0); 
            }
        }
    });
}
  • Access token duration should be short in time due to security reasons. You should take this into account in case of thinking of extending token duration, Perhaps a renewal process would be better. This last point will be the one used here. We will keep duration of access token and renew it in an automatic silent process.
  • ADAL provides open code to deal with all of this: enables developers to authenticate users, configure token cache that stores access tokens, refresh tokens, etc.
  • Our application is not a SPA (Single Page Application), so there is no sense for using ADAL.js.

CONTROLLING MVC WEB APP SESSION DURATION

Before going ahead, let me introduce what would happen if "session cookie" expired. According to my investigations:

  • If the next call to the MVC Web app is made without using AJAX, the ADAL MW included in the backend will detect this issue and try to renew automatically the cookie. When performing this action, you can see that the Web app connects to Azure and then, returns to the initial application calling page more or less in a proper way. It works but it is not very great from the perspective of user experience. And not sure if all data used in the call are properly managed. In any case, let me know by adding a comment or reaching out to me if you have more knowledge about this.
  • If the next call to the MVC Web App is made by using AJAX, you have a problem. As explained before, a CORS issue arises and user may lose data in the worst case. This is because MVC Web App tries to renew the authentication data, but as the call is initiated by using AJAX, the browser receives the CORS error when trying to connect to a different domain (https://login.microsoftonline.com). You may resolve this issue by adding your URL to the list of allowed origins in Azure, but it is very likely you are not allowed to employ this... This was precisely our case, so no more findings about solving the issue with this workaround.

Here is the error that we were receiving:

To reproduce this error, you just have to delete the "session cookie", in our case ".AspNet.Cookies", and try to make an AJAX call. As "session cookie" does not exist yet, the ADAL MW will try to authenticate the user again but due to this request is an AJAX call, it is likely (if not further code is added to handle properly HTTP 302 responses) that browser get the above error in the console. Let me point out again that the target URL for redirection belongs to another domain (https://login.microsoftonline.com) and that our domain were not configured as an allowed domain for CORS.

This having said, let the browser get to this point is a very bad practiceSo, we had to handle this HTTP 302 responses on AJAX calls in the way explained in the second paragraph of this section.

As explained in this great article Controlling a Web App’s session duration, there are two ways to manage the MVC Web App Session duration and skip this issue due to Azure AD session expiration. Let me say (one more time) that this duration matches the one of "session cookie" generated in the authentication process. To summarize, you may use:

  • Modify the default duration of the "session cookie" by making use of "UseTokenLifetime" property in "OpenIdConnectAuthenticationOptions" class employed by ADAL MW to configure the process. It indicates that the authentication session lifetime (e.g. cookies) should match that of the authentication token. If the token does not provide lifetime information then normal session lifetimes will be used. Hence, this setting will give you the chance of modifying cookie properties as you like. The problem with this is that session cookie is decoupled from "id_token" validity. This could be dangerous in some cases. But if you don't find any issues with this in your case, you just have to set "UseTokenLifetime" to "false".
app.UseOpenIdConnectAuthentication(
   new OpenIdConnectAuthenticationOptions {
    ... 
    UseTokenLifetime = false, 
   ...}
   );
  • If you don't want to decouple the duration of "session cookie" and "id_token" you may use the greatly described workaround in the article Controlling a Web App’s session duration. Basically, it comes to incorporating a hidden iframe for all the templates used by the Web App to make automatic calls to a MVC controller method forcing to make a call to renew the authentication data (cookie included, of course) on a regular basis. A very neat fix from my view. It is very important to set up the process to make the renewing calls before the cookie (or id_token) expiration time. This is achieved very easily by configuring an automatic javascript process in the front-end performed in a loop on a regular basis of, for instance, 45'. This value could be configured or extracted from a configuration file too. The only key condition is that it must be less than one hour.

Here is the simplified example code related to MVC Controller:

/* Action method, inside "Account" controller class, to force renewal of user authentication session */
public void ForceSignIn() 
{
   HttpContext.GetOwinContext().Authentication.Challenge(new AuthenticationProperties { RedirectUri = "/" }, 
   OpenIdConnectAuthenticationDefaults.AuthenticationType); 
}

And here is the simplified example HTML and javascript code employed to call silently in a hidden iframe to MVC Controller:

<iframe id="renewSession" hidden></iframe> 
 <script> 
     setInterval( function () 
         { @if (Request.IsAuthenticated) {
                 <text> 
                 var renewUrl = "/Account/ForceSignIn"; 
                 var element = document.getElementById("renewSession"); 
                element.src = renewUrl; 
                 </text> 
             }
         }, 
         1000*60*45
       );
   </script>

I have tested this code and it works very fine.

If you are not making use of calls to Web APIs, that would be all. But, if you are, what about "access tokens"? Are they valid too?

CONTROLLING "ACCESS TOKEN" DURATION TO ACCESS WEB APIS

Firstly, it is evident that if "access token" expires, an authentication error will be thrown when trying to access to the Web APIs. But, considering all the above commented and trying to take advantage of it, why not use the same code/strategy to renew "access token"? I mean, when using the "ForceSignin" method a new call to "Azure Authorization Endpoint" is made. And consequently, a new "auth code" is returned. It seems very straighforward, then. We just have to use ADAL MW, to renew the "access token", not required to use "refresh tokens". What's more, we do not even have to make any change in comparison with the initial code employed to save "access tokens" in cache, if previously implemented. The code employed for caching "access tokens" is out of the scope of this article. Basically, you should handle the "AuthorizationCodeReceived" notification in the ADAL MW to forward a request for getting the "access_token". Once the response is received you should save the token securely in a cache. You have all the code you need at here. Let me know if you need more information about this. No issues. I will try to help.

In any case, I checked this, and worked perfectly smiley Here is a simplified schema of the process carried out to accomplish our objective:

With this solution, both Azure AD "session cookies" and "access tokens" are always renewed before expiring, and as a consequence all kind of requests, irrespective AJAX or not, can make use of valid tokens. Not necessary to renew the token in the middle of a HTTP request, so it implies an improvement in the user experience. All of this is done silently, in a hidden iframe.

OTHER CONSIDERATIONS OR IMPROVEMENTS

The particular implementation for this solution is a bit more complex that of is used in the article below commented, but it is not important for our initial purpose. I would recommend control the loop process to make periodic calls in a separate partial view that you can reference from all your templates/views and consider the fact that you should control times taking into account different users, or browser may be closed and opened again keeping the same session, etc. As I did, you can use "localStorage" to store temp data (pending time to expire, user, etc.) and deal with all of this.

REFERENCES AND ACKNOWLEDGEMENTS

Authenticate using Azure AD and OpenID Connect

The OWIN OpenID Connect Middleware

Cloud Identity Blog

Github Azure Samples | AAD .Net Web App - Web API - OpenId Connect

Handle token timeout in Asp.Net MVC when using Azure AD

Comments (2) -

  • Thanks for your great article. I tried your solution, but having an issue with the re-authentication inside iframe.
    It seems that the auth_code is not retrieved correctly. Instead, it seems to be redirected to the login page, but it is failing as the X-Frame-Options set to 'deny'. Any thoughts on this issue?
    • Hi Harry,

      My solution is based on the use of "iframes" so that if they are not allowed in your website you will not be able to run it properly. I'd say you should update your web.config file in the following way:

      <system.webServer>
      <!-- Rest of your settings -->
      <httpProtocol>
              <customHeaders>
               <!-- Rest of your settings -->
              <add name="X-Frame-Options" value="SAMEORIGIN" />
              <!-- Rest of your settings -->
              </customHeaders>
      </httpProtocol>
      <!-- Rest of your settings -->
      </system.webServer>

      It will make iframes configured to request URLs from the same domain be accepted. This is precisely the case we need to apply this solution.

      I hope this is useful for you.

      KR.

Add comment