Redirect to Identity Server Login page from AngularJs http web api request

3.9k views Asked by At

I am trying to redirect to Identity Server's default login page when calling an API controller method from Angular's $http service.

My web project and Identity Server are in different projects and have different Startup.cs files.

The web project Statup.cs is as follows

 public class Startup
{
     public void Configuration(IAppBuilder app)
     {
         AntiForgeryConfig.UniqueClaimTypeIdentifier = Thinktecture.IdentityServer.Core.Constants.ClaimTypes.Subject;
         JwtSecurityTokenHandler.InboundClaimTypeMap = new Dictionary<string, string>();

         app.UseCookieAuthentication(new CookieAuthenticationOptions
         {
             AuthenticationType = "Cookies",                
         });

         var openIdConfig = new OpenIdConnectAuthenticationOptions
         {
             Authority = "https://localhost:44301/identity",
             ClientId = "baseballStats",
             Scope = "openid profile roles baseballStatsApi",
             RedirectUri = "https://localhost:44300/",
             ResponseType = "id_token token",
             SignInAsAuthenticationType = "Cookies",                 
             UseTokenLifetime = false,
             Notifications = new OpenIdConnectAuthenticationNotifications
             {
                 SecurityTokenValidated = async n =>
                 {
                     var userInfoClient = new UserInfoClient(
                                  new Uri(n.Options.Authority + "/connect/userinfo"),
                                  n.ProtocolMessage.AccessToken);

                     var userInfo = await userInfoClient.GetAsync();

                     // create new identity and set name and role claim type
                     var nid = new ClaimsIdentity(
                        n.AuthenticationTicket.Identity.AuthenticationType,
                         Thinktecture.IdentityServer.Core.Constants.ClaimTypes.GivenName,
                         Thinktecture.IdentityServer.Core.Constants.ClaimTypes.Role);

                     userInfo.Claims.ToList().ForEach(c => nid.AddClaim(new Claim(c.Item1, c.Item2)));

                     // keep the id_token for logout
                     nid.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));

                     // add access token for sample API
                     nid.AddClaim(new Claim("access_token", n.ProtocolMessage.AccessToken));

                     // keep track of access token expiration
                     nid.AddClaim(new Claim("expires_at", DateTimeOffset.Now.AddSeconds(int.Parse(n.ProtocolMessage.ExpiresIn)).ToString()));

                     // add some other app specific claim
                     nid.AddClaim(new Claim("app_specific", "some data"));

                     n.AuthenticationTicket = new AuthenticationTicket(
                         nid,
                         n.AuthenticationTicket.Properties);

                     n.Request.Headers.SetValues("Authorization ", new string[] { "Bearer ", n.ProtocolMessage.AccessToken });
                 }
             }
         };

         app.UseOpenIdConnectAuthentication(openIdConfig);

         app.UseResourceAuthorization(new AuthorizationManager());

         app.Map("/api", inner =>
         {
             var bearerTokenOptions = new IdentityServerBearerTokenAuthenticationOptions
             {
                 Authority = "https://localhost:44301/identity",
                 RequiredScopes = new[] { "baseballStatsApi" }                     
             };

             inner.UseIdentityServerBearerTokenAuthentication(bearerTokenOptions);
             var config = new HttpConfiguration();
             config.MapHttpAttributeRoutes();
             inner.UseWebApi(config);
         });                                                 
     }
}

You will notice that the API is secured with bearer token authentication, whereas the rest of the app uses OpenIdConnect.

The Identity Server Startup.cs class is

public class Startup
{
    public void Configuration(IAppBuilder app)
    {
        var policy = new System.Web.Cors.CorsPolicy
        {
            AllowAnyOrigin = true,
            AllowAnyHeader = true,
            AllowAnyMethod = true,
            SupportsCredentials = true
        };

        policy.ExposedHeaders.Add("Location");
        app.UseCors(new CorsOptions
        {
            PolicyProvider = new CorsPolicyProvider
            {
                PolicyResolver = context => Task.FromResult(policy)
            }
        });
        app.Map("/identity", idsrvApp =>
        {
            idsrvApp.UseIdentityServer(new IdentityServerOptions
            {
                SiteName = "Embedded IdentityServer",
                SigningCertificate = LoadCertificate(),

                Factory = InMemoryFactory.Create(
                    users: Users.Get(),
                    clients: Clients.Get(),
                    scopes: Scopes.Get())
            });
        });            
    }

    X509Certificate2 LoadCertificate()
    {
        return new X509Certificate2(
            string.Format(@"{0}\bin\Configuration\idsrv3test.pfx", AppDomain.CurrentDomain.BaseDirectory), "idsrv3test");
    }
}

Notice that I have added a CorsPolicy entry in order to allow the Web App to hopefully redirect to the Login page. In addition, the Cors policy exposes the Location request header, since it contains the url that I would like to redirect to.

The Web Api controller method is secured using the Authorize Attribute, like so

  [HttpPost]
    [EnableCors(origins: "*", headers: "*", methods: "*")]
    [Authorize]
    public PlayerData GetFilteredPlayers(PlayerInformationParameters parameters)
    {
        var playerInformation = composer.Compose<PlayerInformation>().UsingParameters(parameters);

        var players = playerInformation.Players
            .Select(p => new {                    
            p.NameLast,
            p.NameFirst,
            p.Nickname,
            p.BirthCity,
            p.BirthState,
            p.BirthCountry,
            p.BirthDay,
            p.BirthMonth,
            p.BirthYear,
            p.Weight,
            p.Height,
            p.College,
            p.Bats,
            p.Throws,
            p.Debut,
            p.FinalGame
        });

        var playerData = new PlayerData { Players = players, Count = playerInformation.Count, Headers = GetHeaders(players) };            

        return playerData;
    }

The angular factory makes a call to $http, as shown below

baseballApp.factory('playerService', function ($http, $q) {
return {
    getPlayerList: function (queryParameters) {
        var deferred = $q.defer();
        $http.post('api/pitchingstats/GetFilteredPlayers', {
            skip: queryParameters.skip,
            take: queryParameters.take,
            orderby: queryParameters.orderby,
            sortdirection: queryParameters.sortdirection,
            filter: queryParameters.filter
        }).success(function (data, status) {
            deferred.resolve(data);
        }).error(function (data, status) {
            deferred.reject(status);
        });

        return deferred.promise;
    }
}});

When this call occurs, the response status is 200, and in the data, the html for the login page is returned.

Moreover, I can see on Chrome's Network tab that the response has a Location header with the url of the Login page. However, if I set up an http interceptor, I only see the Accept header has been passed to the javascript.

Here are the http headers displayed in Chrome's network tab:

Http Headers

The response does not have the Access-Control-Allow-Origin header for some reason.

So I have the following questions:

Is there a way I could get access to the Location header of the response in the angular client code to redirect to it?

How might I be able to get the server to send me a 401 instead of 200 in order to know that there was an authentication error?

Is there a better way to do this, and if so, how?

Thanks for your help!

EDIT:

I have added a custom AuthorizeAttribute to determine what http status code is returned from the filter.

The custom filter code

 public class BearerTokenAutorizeAttribute : AuthorizeAttribute
{
    private const string AjaxHeaderKey = "X-Requested-With";
    private const string AjaxHeaderValue = "XMLHttpRequest";
    protected override void HandleUnauthorizedRequest(System.Web.Http.Controllers.HttpActionContext actionContext)
    {
        var headers = actionContext.Request.Headers;
        if(IsAjaxRequest(headers))
        {
            if (actionContext.RequestContext.Principal.Identity.IsAuthenticated)
                actionContext.Response.StatusCode = System.Net.HttpStatusCode.Forbidden;
            else
                actionContext.Response.StatusCode = System.Net.HttpStatusCode.Unauthorized;
        }

        base.HandleUnauthorizedRequest(actionContext);
        var finalStatus = actionContext.Response.StatusCode;
    }

    private bool IsAjaxRequest(HttpRequestHeaders requestHeaders)
    {
        return requestHeaders.Contains(AjaxHeaderKey) && requestHeaders.GetValues(AjaxHeaderKey).FirstOrDefault() == AjaxHeaderValue;
    }

I have observed two things from this: first, the X-Requested-With header is not included in the request generated by the $http service on the client side. Moreover, the final http status returned by the base method is 401 - Unauthorized. This implies that the status code is changed somewhere up the chain.

Please don't feel like you have to respond to all the questions. Any help would be greatly appreciated!

1

There are 1 answers

0
kidroca On

You have probably configured the server correctly since you are getting the login page html as a response to the angular $http call -> it is supposed to work this way:

angularjs $http

Note that if the response is a redirect, XMLHttpRequest will transparently follow it, meaning that the outcome (success or error) will be determined by the final response status code.

You are getting a 200 OK response since that is the final response as the redirect is instantly followed and it's result resolved as the $http service outcome, also the response headers are of the final response


One way to achieve the desired result - browser redirect to login page:

Instead of redirecting the request server side (from the web project to the Identity Server) the web api controller api/pitchingstats/GetFilteredPlayer could return an error response (401) with a json payload that contains a {redirectUrl: 'login page'} field or a header that could be read as response.headers('x-redirect-url') then navigate to the specified address using window.location.href = url

Similar logic can often be observed configured in an $httpInterceptors that handles unauthorized access responses and redirects them to the login page - the redirect is managed on the client side