CORS in Ajax-requests against an MVC controller with IdentityServer3-authorization
Asked Answered
C

5

8

I'm currently working on site that uses various Ajax-requests to save, load and autocomplete data. It is build using C#, MVC and JQuery. All actions on the MVC controllers require the users to be authorized, and we use IdentityServer3 for authentication. It was installed using NuGet, and the current version is 2.3.0.

When I open the page and push buttons, everything is working just fine. The problem seem to occur when a certain session expires. If I stay idle for a while, and try to use an Ajax-function, it generates the following error:

XMLHttpRequest cannot load https://identityserver.domain.com/connect/authorize?client_id=Bar&redirect_uri=http%3a%2f%2flocalhost%3a12345&response_mode=form_post&response_type=id_token+token&scope=openid+profile+email+phone+roles [...]. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:12345' is therefore not allowed access.

From what I know about Ajax, the problem itself is pretty simple. The MVC site has lost track of the current session, and it is asking the client to authenticate again. The response I get from the Ajax-request is a "302 Found", with a Location-header that points to our IdentityServer. The IdentityServer happens to be on another domain, and while this works fine when you are performing regular HTTP-requests, it does not work particularly well for Ajax-requests. The "Same Origin Policy" is straight up blocking the Ajax-function from authenticating. If I refresh the page, I will be redirected to the IdentityServer and authenticate normally. Things will then go back to normal for a few minutes.

The solution is probably to add an extra header in the response message from the IdentityServer, that explicitly states that cross-origin requests are allowed for this service.

I am currently not getting this header from the IdentityServer (checked in Fiddler).

According to the docs, it should be enabled by default. I have checked that we have indeed enabled CORS this way:

factory.CorsPolicyService = new Registration<ICorsPolicyService>(new DefaultCorsPolicyService { AllowAll = true });

This is one of my clients:

new Client
{
    Enabled = true,
    ClientName = "Foo",
    ClientId = "Bar",
    ClientSecrets = new List<Secret>
    {
        new Secret("Cosmic")
    },
    Flow = Flows.Implicit,
    RequireConsent = false,
    AllowRememberConsent = true,
    AccessTokenType = AccessTokenType.Jwt,
    PostLogoutRedirectUris = new List<string>
    {
        "http://localhost:12345/",
        "https://my.domain.com"
    },
    RedirectUris = new List<string>
    {
        "http://localhost:12345/",
        "https://my.domain.com"
    },
    AllowAccessToAllScopes = true
}

These settings do not work. I am noticing that I have an extra forward slash in the URIs here, but if I remove them, I get the default IdentityServer-error that states that the client is not authorized (wrong URI). If I deploy the site (instead of running a localhost debug), I use the domain name without a trailing slash, and I get the exact same behaviour as I do in debug. I do notice that there is no trailing slash in the error message above, and I figured this could be the problem until I saw the same thing in the deployed version of the site.

I also made my own policy provider, like this:

public class MyCorsPolicyService : ICorsPolicyService
{
    public Task<bool> IsOriginAllowedAsync(string origin)
    {
        return Task.FromResult(true);
    }
}

... and I plugged it into the IdentityServerServiceFactory like this:

factory.CorsPolicyService = new Registration<ICorsPolicyService>(new MyCorsPolicyService());

The idea is for it to return true regardless of origin. This did not work either; exactly the same results as before.

I've read about a dozen other threads on this particular subject, but I'm getting nowhere. To my knowledge, we are not doing anything unusual when it comes to the setup of the different sites. It's all pretty much out-of-the-box. Any advice?

----- UPDATE -----

The problem persists. I have now tried some fresh tactics. I read somewhere that cookie authentication was bad for Ajax-requests, and that I should be using bearer tokens instead. I set this up in Ajax like this:

$(function () {
    $(document).ajaxSend(function (event, request, settings) {
        console.log("Setting bearer token.");
        request.setRequestHeader("Authorization", "Bearer " + $bearerToken);
    });
});

Both the console in Chrome and Fiddler confirms that the token is indeed present and sent by JQuery. The token I use comes from the access_token-property on claims principal object from HttpContext.GetOwinContext().Authentication.User.

This didn't do much. I still get a 302-response from the server, and Fiddler reveals that the token is not sent on the following Ajax-request (which is a GET-request) to the IdentityServer.

From there, I read this thread: Handling CORS Preflight requests to ASP.NET MVC actions I tried to put this code in to the startup.cs of the IdentityServer, but there does not appear to be a "preflight" request going in. All I see in Fiddler is this (from the beginning):

1 - The initial Ajax-request from the client to the MVC controller:

POST http://localhost:12345/my/url HTTP/1.1
Host: localhost:12345
Connection: keep-alive
Content-Length: pretty long
Authorization: Bearer <insert long token here>
Origin: http://localhost:12345
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Accept: application/json, text/javascript, */*; q=0.01
X-Requested-With: XMLHttpRequest
Referer: http://localhost:12345/my/url
Accept-Encoding: gzip, deflate
Accept-Language: nb-NO,nb;q=0.8,no;q=0.6,nn;q=0.4,en-US;q=0.2,en;q=0.2
Cookie: OpenIdConnect.nonce.<insert 30 000 lbs of hashed text here>

param=fish&morestuff=salmon&crossDomain=true

2 - The redirect response from the MVC controller:

HTTP/1.1 302 Found
Cache-Control: private
Location: https://identityserver.domain.com/connect/authorize?client_id=Bar&redirect_uri=http%3a%2f%2flocalhost%3a12345%2f&response_mode=form_post&response_type=id_token+token&scope=openid+profile+email [...]
Server: Microsoft-IIS/10.0
X-AspNetMvc-Version: 5.2
X-AspNet-Version: 4.0.30319
Set-Cookie: OpenIdConnect.nonce.<lots of hashed text>
X-SourceFiles: <more hashed text>
X-Powered-By: ASP.NET
Date: Fri, 15 Jan 2016 12:23:08 GMT
Content-Length: 0

3 - The Ajax-request to the IdentityServer:

GET https://identityserver.domain.com/connect/authorize?client_id=Bar&redirect_uri=http%3a%2f%2flocalhost%3a12345%2f&response_mode=form_post&response_type=id_token+token&scope=openid+profile+email [...]
Host: identityserver.domain.com
Connection: keep-alive
Accept: application/json, text/javascript, */*; q=0.01
Origin: http://localhost:12345
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Referer: http://localhost:12345/my/url
Accept-Encoding: gzip, deflate, sdch
Accept-Language: nb-NO,nb;q=0.8,no;q=0.6,nn;q=0.4,en-US;q=0.2,en;q=0.2

4 - The response from IdentityServer3

HTTP/1.1 302 Found
Content-Length: 0
Location: https://identityserver.domain.com/login?signin=<some hexadecimal id>
Server: Microsoft-IIS/8.5
Set-Cookie: SignInMessage.<many, many, many hashed bytes>; path=/; secure; HttpOnly
X-Powered-By: ASP.NET
Date: Fri, 15 Jan 2016 12:23:11 GMT

5 - The meltdown of Chrome

XMLHttpRequest cannot load https://identityserver.domain.com/connect/authorize?client_id=Bar&blahblahblah. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:12345' is therefore not allowed access.

Clavicorn answered 13/1, 2016 at 13:50 Comment(0)
M
1

I came across this problem as well and UseTokenLifetime = false was not solving the problem since you loose the token validity on STS.

When I tried to reach the authorized api method, I still got 401 even if I was valid on Owin.

The solution I found is keeping UseTokenLifetime = true as default but to write a global ajax error handler (or angular http interceptor) something like this:

$.ajaxSetup({
global: true,
error: function(xhr, status, err) {
    if (xhr.status == -1) { 
       alert("You were idle too long, redirecting to STS") //or something like that
       window.location.reload();
    }
}});

to trigger the authentication workflow.

Maryjanemaryjo answered 21/5, 2016 at 22:4 Comment(1)
Doesn't work. xhr.status is 0. I suggest you try out my solution.Puglia
D
2

I was having a similar issue using OWIN Middleware for OpenIDConnect with a different identity provider. However, the behavior occurred after 1 hour instead of 5 minutes. The solution was to check if the request was an AJAX request, and if so, force it to return 401 instead of 302. Here is the code that performed this:

app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
    ClientId = oktaOAuthClientId,
    Authority = oidcAuthority,
    RedirectUri = oidcRedirectUri,
    ResponseType = oidcResponseType,
    Scope = oauthScopes,
    SignInAsAuthenticationType = "Cookies",
    UseTokenLifetime = true,
    Notifications = new OpenIdConnectAuthenticationNotifications
    {
        AuthorizationCodeReceived = async n =>
        {
            //...
        },
        RedirectToIdentityProvider = n => //token expired!
        {
            if (IsAjaxRequest(n.Request))
            {
                n.Response.StatusCode = 401;//for web api only!
                n.Response.Headers.Remove("Set-Cookie");
                n.State = NotificationResultState.HandledResponse;
            }
            return Task.CompletedTask;
        },
    }
});

Then, I used an Angular interceptor to detect a statusCode of 401, and redirected to the authentication page.

Derogative answered 12/1, 2017 at 22:42 Comment(0)
K
1

I had this issue recently, it was caused by the header X-Requested-With being sent with the AJAX request. Removing this header or intercepting it and handling it with a 401 will put you on the right track.

If you don't have this header, the issue is most likely being caused by a different header triggering the Access-Control-Allow-Origin response.

As you found, nothing you do in Identity Server regarding CORS will solve this.

Korella answered 13/1, 2016 at 15:36 Comment(1)
I've looked into this, and I did make some small measure of progress. Changing X-Requested-With and/or the Referer-headers did nothing. However, I suddenly noticed that one significant difference between my regular, working, HTTP-requests and my Ajax-requests was that the cookies were missing. I did this to my Ajax: $.ajaxSetup({ xhrFields: { withCredentials: true } }); and suddenly I get the same server response as I do with a normal HTTP-request. I get the "submit this form"-page. Even so, the same error still shows up in my Javascript console. Exactly the same behaviour.Clavicorn
C
1

As it turns out, the problem was in the client configuration in MVC. I was missing the UseTokenLifetime property, which should have been set to false.

app.UseOpenIdConnectAuthentication(
    new OpenIdConnectAuthenticationOptions
    {
        ClientId = "Bar",
        Scope = "openid profile email phone roles",
        UseTokenLifetime = false,
        SignInAsAuthenticationType = "Cookies"
    [...]

For some reason, IdentityServer sets all these cookies to expire within 5 minutes of them being distributed. This particular setting will override IdentityServer's tiny expiration time, and instead use aprox. 10 hours, or whatever the default is in your client application.

One could say that this is good enough for solving the problem. It will however inevitably return if the user decides to spend 10 hours idling on the site, clicking nothing but Ajax-buttons.

https://github.com/IdentityServer/IdentityServer3/issues/2424

Clavicorn answered 18/1, 2016 at 14:11 Comment(1)
Please note this is a band aid and not a fix. The request should still work even when UseTokenLifeTime = true. You will still see the same issue if you sit on idle for long enough. I have come to the conclusion that this is an issue in owin specificatlly.Feel
M
1

I came across this problem as well and UseTokenLifetime = false was not solving the problem since you loose the token validity on STS.

When I tried to reach the authorized api method, I still got 401 even if I was valid on Owin.

The solution I found is keeping UseTokenLifetime = true as default but to write a global ajax error handler (or angular http interceptor) something like this:

$.ajaxSetup({
global: true,
error: function(xhr, status, err) {
    if (xhr.status == -1) { 
       alert("You were idle too long, redirecting to STS") //or something like that
       window.location.reload();
    }
}});

to trigger the authentication workflow.

Maryjanemaryjo answered 21/5, 2016 at 22:4 Comment(1)
Doesn't work. xhr.status is 0. I suggest you try out my solution.Puglia
P
1

Assumptions:

  • .NET Framework 4.8 WebForms
  • OWIN-based auth lib i.e. Microsoft.Owin.Security.OpenIdConnect v4.2.2.0
  • UseOpenIdConnectAuthentication() with Azure AD endpoint
  • UseTokenLifetime=true

In Layout.Master:

$.ajaxSetup({
  global: true,
  error: function (xhr, status, err) {
    if (xhr.status == 401) {
      window.location.reload();
    }
  }
});

In startup.cs:

app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
  ...
  Notifications = new OpenIdConnectAuthenticationNotifications()
  {
    ...
    RedirectToIdentityProvider = RedirectToIdentityProvider
  }
});

...

public Task RedirectToIdentityProvider(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
{
  if (IsAjaxRequest(context.Request))
  {
    context.Response.StatusCode = 401;
    context.Response.Headers.Remove("Set-Cookie");
    context.State = NotificationResultState.HandledResponse;
  }
}

public bool IsAjaxRequest(this IOwinRequest request)
{
  if (request == null)
  {
    throw new ArgumentNullException("Woopsie!");
  }
  var context = HttpContext.Current;
  var isCallbackRequest = false;
  if (context != null && context.CurrentHandler != null && context.CurrentHandler is System.Web.UI.Page page)
  {
    isCallbackRequest = page.IsCallback;
  }
  return isCallbackRequest || (request.Cookies["X-Requested-With"] == "XMLHttpRequest") || (request.Headers["X-Requested-With"] == "XMLHttpRequest");
}
Puglia answered 23/11, 2022 at 23:29 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.