IdentityServer4 and ASP.NET MVC

Introduction

In the previous post I talked about IdentityServer4 and ASP.NET Web API. All clients we worked with, was built against .NET Core. In this post we will talk about implementing authentication against IdentityServer4 using OpenID Connect for an ASP.NET MVC client. This would be useful for those who want to upgrade to IdentityServer4, and they have stable ASP.NET MVC web applications already in place.

I got motivated to write this post by this stackoverflow question

Versions

This post is written based on IdentityServer4 3.1.0, .NET Framework 4.5.2, and .NET Core 3.1.

Identity Server

To limit the scope of this post, we assume we already have a running, fully functional IdentityServer4 in place. Check here for setting up the IdentityServer4.

To define the ASP.NET MVC application as a client for IdentityServer we need to provide its information using the Client object. Before this step, let’s first review IdentityServer setup to make sure we are on the same page. Here is the code on Startup class of IdentityServer project:

namespace IdentityServer
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            ...

            var builder = services.AddIdentityServer()
                .AddInMemoryIdentityResources(Config.Ids)
                .AddInMemoryApiResources(Config.Apis)
                .AddInMemoryClients(Config.Clients)
                .AddTestUsers(TestUsers.Users);

            ...
        }
    }
}

To add the ASP.NET MVC client’s information, the Code change is on Config class of IdentityServer project:

namespace IdentityServer
{
    public static class Config
    {      
        ...
        
        public static IEnumerable<Client> Clients =>
            new Client[] 
            {
                new Client
                {
                    ClientName = ".NET 4 MVC website",
                    ClientId = "net4mvcclient",
                    ClientSecrets =
                    {
                        new Secret("secret3".Sha256())
                    },

                    AllowedGrantTypes = GrantTypes.Implicit,
                    RequireConsent = false,
                    AllowOfflineAccess = true,
                    
                    AllowAccessTokensViaBrowser = true, //To be able to get the token via browser

                    RedirectUris = { "http://localhost:49816/signin-oidc" },
                    PostLogoutRedirectUris = { "http://localhost:49816" }, //An existing route on MVC client

                    AllowedScopes = {"openid", "profile", "offline_access", "api1", "api2" }
                },
            };
            ...
    }
}
            

As you see in code above, I have set AllowedGrantTypes = GrantTypes.Implicit, which is is optimized for browser-based applications. Read more about implicit flow here. And PostLogoutRedirectUris is set to the ASP.NET MVC client’s root URL. For now, just set it as suggested. We will get back to it on Logout topic.

ASP.NET MVC client

Here, I’m assuming we already have a working ASP.NET MVC application in place. We are dealing with .NET Framework 4.5.2. Our goal is to authenticate against IdentityServer4 using OpenID Connect. To add OIDC authentication to the ASP.NET MVC application, we need to install these packages first:

install-package Microsoft.Owin.Host.Systemweb
install-package Microsoft.Owin.Security.OpenIdConnect
install-package Microsoft.Owin.Security.Cookies

Next we need to configure the cookie middleware. Then point the OpenID Connect middleware to the IdentityServer4 instance. Code change is on Startup class of ASP.NET MVC project:

[assembly: OwinStartup(typeof(Startup))]
namespace Net4MvcClient
{   
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = "Cookies"
            });

            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                Authority = "http://localhost:5000",
                ClientId = "net4mvcclient",
                ClientSecret = "secret3",
                RedirectUri = "http://localhost:49816/signin-oidc", 
                PostLogoutRedirectUri = "http://localhost:49816", //Same value as set on Config.cs on IdentityServer 
                ResponseType = "id_token token", // to get id_token + access_token 
                RequireHttpsMetadata = false,
                TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
                {
                    NameClaimType = "name"
                }, // This is to set Identity.Name 
                SignInAsAuthenticationType = "Cookies"
            });
        }
    }
}

In code above I have set ResponseType = "id_token token", this is to get access_token on client. We will use the access_token to call protected API later on. To test the authentication, a protected resource on ASP.NET MVC app is required. Protect the action by adding [Authorize] attribute. Here is an example:

namespace Net4MvcClient.Controllers
{
    public class HomeController : Controller
    {
        ...
        
        [Authorize]
        public ActionResult About()
        {
            return View((User as ClaimsPrincipal).Claims);
        }
        
        ...
    }
}

From now on About action is only available for logged-in users. To display the claims on authenticated token for the user, add following code to coresponsing view. In our case the view is About.cshtml:

@model IEnumerable<System.Security.Claims.Claim>

@{
    ViewBag.Title = "About";
}
<h2>@ViewBag.Title.</h2>
<h3>@ViewBag.Message</h3>

<p>Use this area to provide additional information.</p>
<dl>
    @foreach (var claim in Model)
    {
        <dt>@claim.Type</dt>
        <dd>@claim.Value</dd>
    }
</dl>

I added a link for About on _Layout.cshtml to call it.

@Html.ActionLink("About", "About", "Home")

Now if we click About link, it will trigger the authentication. IdentityServer will show the login screen and send a token back to the main application. The OpenID Connect middleware validates the token, extracts the claims and passes them on to the cookie middleware. The cookie middleware will in turn set the authentication cookie. The user is now signed in. We also can add a global authorize filter to the ASP.NET MVC application to make all resources protected.

Authorized resource

What we discussed above was authentication, and as long as user is logged-in they have access to the protected resources. In some cases, we may want grant specific access to a certain user. For authorization, we can protect a resource and limit the access to users with some criteria(e.g. name Bob Smith). To achieve this we can simply add [Authorize(Users = "Bob Smith")].

[Authorize(Users = "Bob Smith")]
public ActionResult Authorized()
{
    return View((User as ClaimsPrincipal));
}

With the code above we have an authorized resource in place, and it is just accessible by Bob Smith. For authorized resources the default behaviour is to re-directs all unauthorized users to IdentityServer. This can cause infinite loop. We can fix it by overriding AuthorizeAttribute - HandleUnauthorizedRequest. Here is sample code:

namespace Net4MvcClient.Infrastructure
{
    public class MyAuthorizeAttribute : AuthorizeAttribute
    {
        protected override bool AuthorizeCore(HttpContextBase httpContext)
        {
            return base.AuthorizeCore(httpContext);
        }

        protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
        {
            if (filterContext == null)
            {
                throw new ArgumentNullException("filterContext");
            }

            //Intercept results where person is authenticated but still doesn't have permissions
            if (filterContext.RequestContext.HttpContext.User.Identity.IsAuthenticated)
            {
                filterContext.Result = new RedirectResult("http://localhost:49816/Home/Unauthorized");
                return;
            }

            base.HandleUnauthorizedRequest(filterContext);
        }
    }
}

And using the custom attribute to protect the resource:

[MyAuthorize(Users = "Bob Smith")]
public ActionResult Authorized()
{
    return View((User as ClaimsPrincipal));
}

Now if a non-authenticated user tries to access /Authorized, the request will be redirected to IdentityServer login page. If user is logged-in but is not Bob Smith will be redirected to /Unauthorized.

Logout

To implement logout, add this code on MVC client:

namespace Net4MvcClient.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Logout()
        {
            System.Web.HttpContext.Current.GetOwinContext().Authentication.SignOut();
            return Redirect("/");
        }
    }
}

To test, I used my dummy action link on _Layout.cshtml again:

@Html.ActionLink("Logout", "Logout", "Home")

If we click on Logout link, the user is signed out. And About link will trigger the authentication again.

Redirect back to ASP.NET MVC app after signing out

Ref: Stackoverflow Issue

The redirect back to ASP.NET MVC client will not work by using above signout code. This issue exists even when we enable auto redirect on signout by setting AutomaticRedirectAfterSignOut to true. This issue had been reported on IdentityServer3, and has been fixed by setting IdTokenHint on logout. Working with IdentityServer4, we need to implement the similar fix manually on ASP.NET MVC app. We have to change OpenIdConnectAuthenticationNotifications. Add id_token claim for validated token, then pass it back to IdentityServer when redirecting.

The code changes is on startup class of ASP.NET MVC app:

namespace Net4MvcClient
{   
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            ...           

            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                ...

                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    SecurityTokenValidated = n =>
                    {
                        n.AuthenticationTicket.Identity.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));
                        return Task.FromResult(0);
                    },
                    RedirectToIdentityProvider = n =>
                    {
                        if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.Logout)
                        {
                            var id_token_claim = n.OwinContext.Authentication.User.Claims.FirstOrDefault(x => x.Type == "id_token");
                            if (id_token_claim != null)
                            {
                                n.ProtocolMessage.IdTokenHint = id_token_claim.Value;
                            }
                        }
                        return Task.FromResult(0);
                    }
                }
            });

            ...
        }
    }
}
            

The final step is to verify that the value of PostLogoutRedirectUris is set on Client config within IdentityServer and on OpenId Connect settings within ASP.NET MVC app. Both values must be the same and be an existing URL on ASP.NET MVC app. As mentioned above to auto redirect on signout we can set AutomaticRedirectAfterSignOut to true.

Call an authorized API endpoint using access token

Ref: Stackoverflow Issue

To call an authorized API using the access token, first we need to add the target API to the list of scopes in the OpenID Connect middleware configuration. Then We need to indicate that we want to request an access token via browser. We are already recieving access token as we set AllowAccessTokensViaBrowser = true on client’s configuration and ResponseType = "id_token token", on OpenID Connect middleware configuration.

To simplify we can save the access token in the cookie after authentication. Retrieve it from the claims principal and use it.

Here is code change on startup class of ASP.NET MVC client:

namespace Net4MvcClient
{   
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            ...           

            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                ...
                
                ResponseType = "id_token token",
                Scope = "openid profile api1", //api1 is the API we are trying to call 
                ...

                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    SecurityTokenValidated = n =>
                    {
                        n.AuthenticationTicket.Identity.AddClaim(new Claim("access_token", n.ProtocolMessage.AccessToken));
                        return Task.FromResult(0);
                    },
                }
            });

            ...
        }
    }
}
            

And to call the authorized API:

namespace Net4MvcClient.Controllers
{
    public class HomeController : Controller
    {
    
        ...

        [Authorize]
        public async Task<ActionResult> CallApi()
        {
            var user = User as ClaimsPrincipal;
            var accessToken = user.FindFirst("access_token").Value;

            var client = new HttpClient();
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            var response = await client.GetAsync("http://localhost:5001/identity");
            var content = await response.Content.ReadAsStringAsync();
            
            ViewBag.Json = JArray.Parse(content).ToString();
            return View("Json");
        }
        
        ...
   }
}

Sample Code

You can find the complete code here

See also in IdentityServer4

comments powered by Disqus