Applying Cookie-Stored Sessions With ASP.NET and OpenID Connect 1.0
Check out this great article on to learn how to create authentication and authorization protocols in your web application using ASP.NET.
Join the DZone community and get the full member experience.
Join For FreeAbstract
This article discusses the Cookie and OpenIdConnect middlewares, both from the Katana project. The main context is around of an ASP.NET MVC application that uses the Google’s OpenID Provider. It shows how to use cookie-stored sessions and how to set machine keys for cookies’ encryption/validation in web farms. In addition, it shows how to reduce the traffic for session cookies and how to mitigate cross-site request forgery attacks for Ajax requests, bringing a practical sample for the readers. In fact, this is not a systematic hands-on, it just highlights some important issues in web development. And, the readers should know the basics of Katana in order to understand the discussion.
1. Introduction
The majority of websites that offer some service for end-users apply at least one authentication mechanism. In most cases, these websites consider that each account belongs to only one person/entity; thus, an authenticated user can do a known set of actions (business model) on the website without repudiation. However, websites can face legal issues if they maintain their own database for user authentication.
Fortunately, single sign-on solves this issues by centralizing the user profile in one place and delegating the responsibility of keeping, authenticating, and providing information about users to a specific entity. In the modern context, such an entity is known as an authorization server, which must be reliable for websites and users.
A few years ago, with the emergence of OAuth 2.0 and its large adoption by big players of the Internet like Google, Facebook and others, some websites started to use this framework as an option for authentication, but OAuth was not designed for it. The aim of OAuth is to grant access for a client (website) so that it can do something in the resource server (Facebook, Google and so forth) on behalf of the resource owner (user).
Therefore, as OAuth 2.0 does not establish a way to get information about users and authentications, someone could implement his/her own API for solving this issue. However, if everyone does this, we will have many unstandardized APIs. Hence, OpenID Connect provides a standard way to deal with this issue. By definition, OpenID Connect (OIDC) is an identity layer on top of the OAuth 2.0, which enables applications to verify the user’s identity and obtain his/her basic profile information. It also establishes a minimum set of attributes that all authorization servers must provide, including information about user authentication.
Before talking about OIDC’s authentication flow, we need to know the following terms:
Claim – an informational piece about someone or something, which can be seen as an attribute. Examples of claims are age, gender, name, and so on.
Relying Party (RP) – the third-party application that requests user information. All relying parties have a Client ID and a Secret, the former is a public identification, and the latter is a password, which should be kept secret. Every OpenID Provider gives a Client ID and Secret pairing for each relying party that signs up.
Authorization Server – translating the OAuth 2.0 terms into this context, it can be seen as an entity responsible for issuing ID Tokens to the relying parties after successfully authenticating the user.
OpenID Provider (OP) – an Authorization Server that implements the OpenID Connect specification. It must maintain the user’s profile, issuing their claims when a relying party requests them.
User – a person or service that has a valid account on the OpenID Provider.
User agent – generally, the user’s web browser.
ID Token – a security token that uses the JSON Web Token (JWT) format and contains claims about user authentication and other requested claims. Furthermore, only secure channels should transmit it.
In OIDC, all endpoints are RESTful web services, and the user authentication process can follow one of three flows so that the relying party must choose the more suitable of them using the response_type parameter in the authentication request. The Implicit Flow (response_type=id_token) is intended for applications that run on the user agent environment (ECMAScript/JavaScript), in which the authorization endpoint provides all tokens, and the relying party does not need to use its Secret.
The sequence diagram of Figure 1 shows the main flow of Implicit Flow. The OIDC specification gives details about the operations 1.1, 2.1, 3.1, 4.1, and 5.1. When the user agent sends the first request, it has not authenticated anything yet; hence, the RP prepares a redirection request with the query string described in Section 3.2.2.1 of the OIDC specification. The RP must add the openid value into the scope parameter because OpenID Providers use it to recognize authentication requests. According to the specification, the RP can choose more than one value for the scope parameter, but they must be separated by a blank space.
Figure 1. OIDC's Implicit Flow
In the operation 4.1, the OP makes up an ID Token with all available information that the RP requests. Note that each OP can establish its own set of acceptable values for the scope parameter and must ignore unacceptable values. After receiving the ID Token (operation 5), the RP validates it and allows the user agent to access the protected endpoint. Although such an approach exposes the user information to the user agent, this is not a problem if we apply techniques to deal with issues like Cross-Site Request Forgery (CSRF), confidentiality, integrity and non-repudiation of the transmitted data.
2. How Can We Deal With OpenID Connect Using ASP.NET?
With the establishment of the Open Web Interface for.NET (OWIN) in 2013, the authentication/authorization processes became easier. By definition, OWIN is a standard interface between .NET web servers and web applications that decouples the server and application. Therefore, OWIN proposes the usage of middlewares (pieces of software that can intercept and modify HTTP requests and responses) rather than defining a processing chain/pipeline for them. The Microsoft’s OWIN implementation (called Katana) has a set of components that can run on the Internet Information Server (IIS) or can be self-hosted. Microsoft docs describe the Katana project and its usage in detail.
Among the bunch of existing Katana middlewares, OpenIdConnect is one for OIDC. Vittorio Bertocci’s article gives us a good explanation of this middleware and the OWIN architecture. As stated in that article, the use of cookies is an approach to identify incoming requests of authenticated users and, commonly, it is the so-called cookie-stored session or cookie-based session. When we combine the Cookie and OpenIdConnect middlewares, the user information retrieved from the ID Token becomes an AuthenticationTicket, which is encrypted and stored in a cookie.
Therefore, we should use our judgment to define claims that are essential for the application in order to keep the cookie as small as possible. Moreover, due to the diversity of user agents, some rules for cookie interoperability were established by the RFC 6265. In Section 6, it limits the amount and the size of cookies to 50 and 4KB, respectively. Hence, the maximum size of cookies per domain is 200KB (50 · 4KB).
Note: If the reader wants to follow this article from a practical perspective, there is a sample implementation here.
To authenticate end users with OpenID Connect, we will use two middlewares in a chain: Cookie and OpenIdConnect. The former is responsible for managing encryption/decryption of the AuthenticationTicket and its storage into a cookie, whereas the latter is responsible for all communication between the web application (client) and the OpenID Provider besides parsing the requested claims.
Assuming that the web application must provide a login page for unauthenticated users, we can set up Cookie and OpenIdConnect as follows:
Code Snippet 1. Configuration of Startup.cs File
// At ~/GoogleOpenIdSample/Startup.cs
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
LoginPath = new PathString("/dynamic/account"),
AuthenticationMode = AuthenticationMode.Active,
CookieSecure = CookieSecureOption.Always,
CookieHttpOnly = true,
CookiePath = "/dynamic"
});
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
ClientId = ConfigurationManager.AppSettings["GoogleCID"],
AuthenticationType = "Google",
Authority = "https://accounts.google.com",
AuthenticationMode = AuthenticationMode.Passive,
Scope = "openid profile email",
/*
This value must be equal to that registered in console.developers.google.com
Indeed, OIDC middleware doesn't call this action when we use response_type=id_token.
However, the Google's OP requires it.
*/
RedirectUri = "https://localhost:44336",
ResponseType = "id_token",
// Google advises to validate Audiences and Issuers
TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://accounts.google.com",
ValidateIssuer = true,
ValidAudience = ConfigurationManager.AppSettings["GoogleCID"],
ValidateAudience = true
},
Notifications = new OpenIdConnectAuthenticationNotifications
{
// At this moment, ID Token has already been validated.
SecurityTokenValidated = (ctx) =>
{
// We can remove claims that are not necessary in this context, mitigating the cookie size.
var identity = ctx.AuthenticationTicket.Identity;
identity.TryRemoveClaim(identity.FindFirst("azp"));
identity.TryRemoveClaim(identity.FindFirst("aud"));
identity.TryRemoveClaim(identity.FindFirst("nonce"));
.
.
.
.
}
}
});
}
}
In this scenario, the main property is the AuthenticationMode, which must be Active for Cookie and Passive for OpenIdConnect. When such a property is active for a given middleware, this middleware must handle unauthorized responses, providing authentication mechanisms for the user. And, when a middleware is in passive mode, it is ignored by the processing chain so that a Challenge is the only way of turning on this middleware. Therefore, the Cookie middleware will handle unauthorized responses providing the login page specified in the LoginPath property, and the OpenIdConnect will only act when it receives a Challenge.
Passive middlewares should define the AuthenticationType property, which acts as a middleware identifier. Thus, the Challenge can refer to a particular middleware using that property. Then, we could have multiple OpenIdConnect middlewares in passive mode, each one for a different OpenID Provider, and the user would choose one of them on the login page. Moreover, the OpenIdConnect uses the default AuthenticationType value to set the ClaimsIdentity’s AuthenticationType property.
Note: The SetDefaultSignInAsAuthenticationType method sets the default AuthenticationType value. See the fifth line of Code Snippet 1.
To insert a Challenge into the processing chain, call the Challenge method from the OWIN context: HttpContext.GetOwinContext().Authentication.Challenge(. . .)
To revoke a cookie-stored session, call the SignOut method from the OWIN context:
HttpContext.GetOwinContext().Authentication.SignOut(. . .)
On the other hand, if we do not define an AuthenticationType for the Cookie middleware, it uses the default AuthenticationType value (from the IAppBuilder instance) and, when its AuthenticationType matches with that in ClaimsIdentity, Cookie generates a cookie-stored session with the corresponding ClaimsPrincipal. For subsequent requests, Cookie parses the encrypted cookie and recovers the ClaimsPrincipal, and then the processing chain directly forwards the request to the application.
We must validate the Issuer and Audience of the ID Token as we receive it, these properties must be equal to http://accounts.google.com and Client ID, respectively. Therefore, in the OpenIdConnect middleware, we should set the TokenValidationParameters object with those values. We must explicitly declare the ValidateIssuer and ValidateAudience properties as true since the default value is false.
The OpenIdConnectAuthenticationNotification class contains several generic-delegate properties, which are invoked as specific "events" occur. Here, I only define the SecurityTokenValidated, which the middleware calls as soon as it validates the token and generates the ClaimsIdentity. For this case, I have removed some unnecessary claims (for this application) from AuthenticationTicket in order to reduce the cookie’s size.
A. Machine Key in Web Farm
In web farm environments, in which servers could run different instances of the same service, cookies are useful for keeping the session state so that state servers and load balancers do not need to worry about it. In addition, cookie-stored session benefits the horizontal scalability, since the session state does not stay on the server side.
Indeed, the Cookie middleware uses a machine key for encryption/decryption and validation of AuthenticationTickets (reference). By default, ASP.NET automatically set up the machine key instance when no configuration is available, hence, this is a problem for web farm environments because the clusters can have different machine key configuration. Consequently, a given cluster cannot be able to decrypt the cookie that another cluster encrypted.
Therefore, for ASP.NET applications to work properly in web farms, we need to set the same machine key configuration for all clusters. Generally, it's very easy to do, just set up the machine key element in the Web.config files. The Code Snippet 2 gives us an example. See the documentation for more details about the attributes.
Code Snippet 2
I'm using the HMACSHA256 hash algorithm for validation and AES for encryption/decryption, both using a 256-bit key.
<system.web>
.
.
.
<!-- Setting up the signature and encryption algorithms for MachineKey.
Indeed, the CookieAuthentication middleware uses MachineKey to encrypt AuthenticationTickets -->
<machineKey
validation="HMACSHA256"
decryption="AES"
validationKey="D6883865C0490AFA4907A046E838DD2C7B13B636694B552630C13770701B944A"
decryptionKey="2C3C48562E6FE018E71B69BDB27D06048A573C094A962AA9A1547C3D874C63B0" />
</system.web>
Note: The sample implementation has a practical example at https://localhost:44336/dynamic/Resource/Decryption
B. Defining the Dynamic Area
When the user agent receives cookies (in the response header) from the web application, it needs to embed such a cookie on every subsequent request at the same domain (assuming the cookie’s path is "/"). Hence, there is a waste of upload bitrate and a slowness in the communication, since the user agent will send an authentication cookie to request public contents that do not need authentication. However, this is not worrisome for three reasons:
The upload of cookies does not incur costs for end users because internet service providers do not charge their users for uploaded data. This is apparent on mobile network operators, which charge the users when they download something, but not when they upload something.
Disregarding protocol headers and assuming that the mean upload bitrate for 4G is 3Mbps (≅ 366KB/s), one bit-flow would be enough to carry 200KB of cookies. However, there are protocols headers and form data in real life, so we need to spend as little as possible with cookies. Fortunately, most web applications seldom need to use more than 10KB of cookies.
Finally, we can mitigate the cookie traffic putting the application logic into a specific Unified Resource Identifier (URI), for instance, "mydomain.com/dynamic." Thus, "/dynamic/*" must precede all services that require a session state like, "/dynamic/account/logoff" (in which account is the controller and logoff is the action), and the path attribute of the session cookie must be "/dynamic" as well. In this manner, static contents can be placed under the root like "/Content/site.css", "/Content/images/*", "/Scripts/*" and so forth.
As public contents do not require an authenticated session, if we put all services that require authentication/authorization on a specific URI and set the cookie's path to this URI, we would save upload bitrate. Fortunately, ASP.NET MVC has a resource called Areas that allows us to do it. In this manner, we can define an area for all actions labeled as "dynamic," like in Code Snippet 3 and Figure 2. Then, we must set up the CookiePath property of the Cookie middleware to "/dynamic" as well, so we ensure that the cookie-stored session will be available for protected services only. I did it on line 12 of Code Snippet 1.
Code Snippet 3
Defining the "dynamic" area with the RouteArea attribute.
// At ~/GoogleOpenIdSample/Areas/Dynamic/Controllers/ResourceController.cs
[Authorize]
[RouteArea("dynamic")]
[RoutePrefix("resource")]
[Route("{action=index}")]
public class ResourceController : Controller
{
.
.
.
}
// And, at ~/GoogleOpenIdSample/Areas/Dynamic/Controllers/AccountController.cs
[RouteArea("dynamic")]
[RoutePrefix("account")]
[Route("{action=index}")]
public class AccountController : Controller
{
.
.
.
}
Figure 2. Project folder structure
Note that this approach does not break the organizational purpose of MVC Areas. And, if we want that organization in our applications, we can append new areas to the "dynamic" area (nested areas).
C. Anti-Forgery for Ajax Requests
To deal with CSRF, ASP.NET MVC defines the AntiForgeryToken helper and the ValidateAntiForgeryToken filter. Commonly, we put the former into an HTML form and the latter on a controller action. The AntiForgeryToken generates a hidden field whose value is a token based on the current ClaimsIdentity, and it creates an anti-forgery cookie with a corresponding token. When the HTML form is submitted to the corresponding action, ValidateAntiForgeryToken compares the token coming from the form with the token of the anti-forgery cookie. If both tokens are still corresponding, the request proceeds with its cycle. Otherwise, the anti-forgery filter throws an exception (HttpAntiForgeryException) blocking the request.
However, the use of AntiForgeryToken with Ajax requests is slightly different. The easy way is to put the AntiForgeryToken helper in a view page, so when the user agent renders this page, the JavaScript code reads the token value from that hidden field (called __RequestVerificationToken) and composes an Ajax request with it. Then, the web application verifies the token integrity normally. Nevertheless, if the request’s content type is not application/x-www-form-urlencoded, the ValidateAntiForgeryToken filter cannot read the token from the sent stream. Consequently, the filter rejects all requests, even though the correct token is sent.
Therefore, we should put the token value into the HTTP's request header to avoid such a problem. We can do this by establishing a key-value pair for the anti-forgery token, so the JavaScript code reads the __RequestVerificationToken's value, composes this key-value pair and puts it into the HTTP's request header for all Ajax requests. Moreover, we need to create a new filter for anti-forgery validation, which must take the token value from the HTTP header and compare it with the anti-forgery cookie. Code Snippet 4 shows an example.
Code Snippet 4
Filter for anti-forgery token.
// At ~/GoogleOpenIdSample/Filters/XhrValidateAntiForgeryToken.cs
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class XhrValidateAntiForgeryToken : FilterAttribute, IAuthorizationFilter
{
private JsonResult AntiForgeryFail()
{
return new JsonResult
{
ContentEncoding = Encoding.UTF8,
ContentType = "application/json",
RecursionLimit = 2,
Data = new MessageWrapper<string>{ Success = false, Msg = "The anti-forgery validation has failed." }
};
}
public const string ANTI_FORGERY_HEADER = "X-Requested-With";
public void OnAuthorization(AuthorizationContext filterContext)
{
var tkFromCookie = filterContext.HttpContext.Request.Cookies[AntiForgeryConfig.CookieName];
string tkFromHeader = filterContext.HttpContext.Request.Headers[ANTI_FORGERY_HEADER];
if (tkFromCookie==null || String.IsNullOrWhiteSpace(tkFromHeader)){
filterContext.Result = this.AntiForgeryFail();
return;
}
try{ AntiForgery.Validate(tkFromCookie.Value, tkFromHeader); }
catch (HttpAntiForgeryException){ filterContext.Result = this.AntiForgeryFail(); }
}
}
Besides verifying the anti-forgery tokens, the filter above also returns a JSON message indicating an error when they are invalid. Thus, we can use any (valid) content type for Ajax requests without impairing the anti-forgery token verification.
Note: Both anti-forgery and session cookies should not be accessible for JavaScript code, and they should be transmitted on secure channels only.
3. Conclusion
This article dealt with some important issues in web development like machine key configuration for web farms, traffic reduction for session cookies and the definition of an anti-forgery filter for Ajax requests. As a mature technology, ASP.NET has proven to be ready for new authentication and authorization methods, since the OWIN's processing chain favors the implementation of them. And, although the sample implementation uses ASP.NET MVC, the issues addressed here exist in all web development technologies.
Opinions expressed by DZone contributors are their own.
Comments