Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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.

like image 473
kiwhen Avatar asked Jan 13 '16 13:01

kiwhen


3 Answers

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.

like image 160
gterdem Avatar answered Oct 02 '22 22:10

gterdem


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.

like image 25
Scott Brady Avatar answered Oct 02 '22 21:10

Scott Brady


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.

like image 23
r590 Avatar answered Oct 02 '22 22:10

r590