Introduction
In my recent project, we wanted to use Thinktecture Identity Server for our authentication/authorization needs and while doing that exercise, I have come across many issues related to configuration and many findings. I just wanted to share my findings so that it will be useful to fellow developers.
The above diagram shows what I wanted to achieve and I think this is what a typical web application looks like.
What I Wanted to Achieve
- Install & Configure Identity Server
- Extend Identity Server to use our own data store to check the user credentials and get user claims
- Implement SecurityToken Caching
- Pass the token to our REST API Services
There are many articles you should definitely go through before reading this article. I have given all references at the bottom of the article. I will be discussing only the main points and solutions for each.
Identity Server Installation
I was able to install the identity server without any issues.There are many useful resources available and you should not have any problem. I had minor problems when using the self signed certificates as they are not trusted when used from other machines. So I wanted to go with OpenSSL so that I can set up a real world certificate authority and issue certificates as I wanted.
The other thing I wanted to achieve was to get the identity server check the user credentials against our own database rather than its own data store.
Certificates
Identity Server needs at least one SSL certificate for running as it needs to be hosted on HTTPS. It needs 2 more certificates for signing the security tokens and encryption but you can use the same certificate for all 3 requirements. So one certificate should be OK for now.
REM Create CA root certificate
openssl req -x509 -nodes -days 3650 -subj "/C=US/L=Redmond/O=XYZ/OU=Technology/CN=XYZ Inc"
-newkey rsa:2048 -keyout xyzCA.key -out xyzCA.crt -config openssl.conf
openssl pkcs12 -export -out xyzCA.pfx -inkey xyzCA.key -in xyzCA.crt
Install the Root certificate on all the 3 servers (STS Server, UI Server, API Server). If they are all one server, then just install once.
@echo off
rem set server="sts.xyz.com"
set /p server="Enter Server Name: " %=%
REM Create SSL certificate for IIS, which trusts the root certificate
openssl req -nodes -days 3650
-subj "/C=US/L=Redmond/O=XYZ/OU=Technology/CN=%server%"
-newkey rsa:2048 -keyout %server%.key -out %server%.csr -config openssl.conf
openssl x509 -req -days 3650 -in %server%.csr -CA xyzCA.crt
-CAkey xyzCA.key -CAcreateserial -out %server%.crt
openssl pkcs12 -export -out %server%.pfx -inkey %server%.key
-in %server%.crt -name "Server Certificate - %server%"
pause
Once you have the certificates, follow the Identity Server setup video available at http://vimeo.com/51088126 and it should be pretty straight forward.
Here are some screen shots of the configuration you need to create:
Extending Identity Server
We wanted to use our own database to store user details like claims, etc. as it's integrated with the existing application.
You can follow this link which gives step by step instructions.
Handling Complex Claims
Usually Claims are stored as simple key/value pair and both are of type "string
" to keep it simple and reduce dependencies. But we wanted to store some extra information (like an object) along with Claim. I started with just serializing the complex object into a JSON string and storing that value as Claim value and I was able to deserialize it at the receiving end using JSON.NET. Even though this works, I found a good article where I found a more elegant approach. You can read about it here.
UI Web Application - Configuration
At the UI end, we did not want to redirect the users to the identity server site (which is what you normally see). Instead, we want to have a login screen of our own just like a regular forms authentication site and just use the STS in the background to check user credentials and get the claims associated.
The web.config entries are as follows:
<system.identitymodel>
<identityconfiguration savebootstrapcontext="true">
<audienceuris>
<add value="http://www.xyz.com/yourapp">
</audienceuris>
<securitytokenhandlers>
<add type="System.IdentityModel.Tokens.JwtSecurityTokenHandler,
System.IdentityModel.Tokens.Jwt, Version=2.0.0.0, Culture=neutral,
PublicKeyToken=31bf3856ad364e35">
<securitytokenhandlerconfiguration>
<issuertokenresolver type="System.IdentityModel.Tokens.X509CertificateStoreTokenResolver,
System.IdentityModel, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<certificatevalidation certificatevalidationmode="PeerOrChainTrust"
trustedstorelocation="LocalMachine" revocationmode="NoCheck">
<issuernameregistry type="System.IdentityModel.Tokens.ValidatingIssuerNameRegistry,
System.IdentityModel.Tokens.ValidatingIssuerNameRegistry,
Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35">
<authority name="http://identityserver.v2.xyz.com/trust">
<keys>
<add thumbprint="C2B2219F3CAC53658E796C0402360D90AEFA08FC">
</keys>
<validissuers>
<add name="http://identityserver.v2.xyz.com/trust">
</validissuers>
</authority>
</issuernameregistry>
</securitytokenhandlerconfiguration>
</securitytokenhandlers>
<claimsauthorizationmanager
type="IdentityServer.Demo.Common.Security.CustomAuthorizationManager,
IdentityServer.Demo.Common">
</identityconfiguration>
</system.identitymodel>
<system.identitymodel.services>
<federationconfiguration>
<cookiehandler mode="Default" requiressl="false">
<wsfederation realm="http://www.xyz.com/yourapp"
issuer="https://sts.xyz.com/issue/wsfed"
passiveredirectenabled="false" requirehttps="true">
</federationconfiguration>
</system.identitymodel.services>
We need to use the ws-trust end point to achieve what we want and here is the code for both Login
/Logoff
methods (AccountController.cs):
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public ActionResult Login(LoginModel model, string returnUrl)
{
(model.UserName, model.Password, persistCookie: model.RememberMe))
if (ModelState.IsValid)
{
var cp = GetClaimsFromIdentityServer(model.UserName, model.Password);
if (cp != null)
{
var token = new SessionSecurityToken(cp) {
IsReferenceMode = true
};
FederatedAuthentication.WSFederationAuthenticationModule.
SetPrincipalAndWriteSessionToken(token, true);
return RedirectToLocal(returnUrl);
}
}
ModelState.AddModelError
("", "The user name or password provided is incorrect.");
return View(model);
}
private ClaimsPrincipal GetClaimsFromIdentityServer(string username, string password)
{
const string WS_TRUST_END_POINT = "https://{0}/issue/wstrust/mixed/username";
var sts =ConfigurationManager.AppSettings["IdentityServer"];
var factory = new System.ServiceModel.Security.WSTrustChannelFactory
(new UserNameWSTrustBinding(SecurityMode.TransportWithMessageCredential),
string.Format(WS_TRUST_END_POINT, sts));
factory.TrustVersion = TrustVersion.WSTrust13;
factory.Credentials.UserName.UserName = username;
factory.Credentials.UserName.Password = password;
var rst = new System.IdentityModel.Protocols.WSTrust.RequestSecurityToken
{
RequestType = RequestTypes.Issue,
KeyType = KeyTypes.Bearer,
TokenType = TokenTypes.JsonWebToken,
AppliesTo = new EndpointReference
("http://www.xyz.com/yourapp")
};
var st = factory.CreateChannel().Issue(rst);
var token = st as GenericXmlSecurityToken;
var handlers = FederatedAuthentication.FederationConfiguration.
IdentityConfiguration.SecurityTokenHandlers;
var jwtToken = handlers.ReadToken(new XmlTextReader
(new StringReader(token.TokenXml.OuterXml))) as JwtSecurityToken;
var identity = handlers.ValidateToken(jwtToken).First();
var principal = new ClaimsPrincipal(identity);
return principal;
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult LogOff()
{
System.Web.HttpContext.Current.Session.Clear();
FormsAuthentication.SignOut();
try
{
FederatedAuthentication.SessionAuthenticationModule.SignOut();
FederatedAuthentication.SessionAuthenticationModule.DeleteSessionTokenCookie();
FederatedAuthentication.WSFederationAuthenticationModule.SignOut(false);
}
catch (Exception)
{
}
return RedirectToAction("Index", "Home");
}
Send the Token to API Services
I have used RestSharp
to call our API Server and it works pretty well and lot simpler than HttpClient
. Sample call looks like below:
public ActionResult Index()
{
ViewBag.Message = "Called the API
along with user claims and got the response below";
var restClient = new RestClient(ConfigurationManager.AppSettings
["ApiServer"]) { Authenticator = new TokenAuthenticator() };
string url = "api/Values";
var request = new RestRequest(url, Method.GET);
var data = restClient.Execute<dynamic>(request);
ViewBag.Data = JValue.Parse(data.Content);
var email = ViewBag.Data.Email;
return View();
}
The source code for the TokenAuthenticator.cs is given below:
public class TokenAuthenticator : IAuthenticator
{
public void Authenticate(IRestClient client, IRestRequest request)
{
var token = ClaimsPrincipal.Current.GetTokenString();
if (!string.IsNullOrEmpty(token))
{
var header = new AuthenticationHeaderValue("Bearer", token);
request.AddHeader("Authorization", header.ToString());
}
}
}
API Services - Configuration
Web.Config entries is as shown below:
<system.identitymodel>
<identityconfiguration savebootstrapcontext="true">
<audienceuris>
<add value="http://www.xyz.com/yourapp">
<add value="http://www.xyz.com/yourapp/api">
</audienceuris>
<securitytokenhandlers>
<add type="System.IdentityModel.Tokens.JwtSecurityTokenHandler,
System.IdentityModel.Tokens.Jwt, Version=2.0.0.0, Culture=neutral,
PublicKeyToken=31bf3856ad364e35">
<securitytokenhandlerconfiguration>
<issuertokenresolver type="System.IdentityModel.
Tokens.X509CertificateStoreTokenResolver, System.IdentityModel,
Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<certificatevalidation certificatevalidationmode="PeerOrChainTrust"
trustedstorelocation="LocalMachine" revocationmode="NoCheck">
<issuernameregistry type="System.IdentityModel.Tokens.
ValidatingIssuerNameRegistry, System.IdentityModel.Tokens.ValidatingIssuerNameRegistry,
Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35">
<authority name="http://identityserver.v2.xyz.com/trust">
<keys>
<add thumbprint="C2B2219F3CAC53658E796C0402360D90AEFA08FC">
</keys>
<validissuers>
<add name="http://identityserver.v2.xyz.com/trust">
</validissuers>
</authority>
</issuernameregistry>
</securitytokenhandlerconfiguration>
</securitytokenhandlers>
<claimsauthorizationmanager
type="IdentityServer.Demo.Common.Security.CustomAuthorizationManager,
IdentityServer.Demo.Common">
</identityconfiguration>
</system.identitymodel>
<system.identitymodel.services>
<federationconfiguration>
<cookiehandler mode="Default" requiressl="false">
<wsfederation realm="http://www.xyz.com/yourapp"
issuer="https://sts.xyz.com/issue/wsfed"
passiveredirectenabled="false" requirehttps="true">
</federationconfiguration>
</system.identitymodel.services>
In Global.asax (Application_Start
), we need to add a messageHandler
so that each HTTP call is intercepted before getting processed. This handler will check the HTTP Authorization header and decrypts it and populates the current user principal with all the claims.
protected void Application_Start()
{
GlobalConfiguration.Configuration.MessageHandlers.Add(new TokenValidationHandler());
AreaRegistration.RegisterAllAreas();
WebApiConfig.Register(GlobalConfiguration.Configuration);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
}
How to Check for Claims
I would prefer to use the ClaimsAuthorizeAttribute
available in Thinktecture.IdentityModal
(available on Nuget). Then it will be as simple as:
[ClaimsAuthorize(IdentityServer.Demo.Common.Security.Claims.ClaimTypes.Manager)]
public string Get(int id)
{
return "value";
}
You can also check for claims in code. Check the Get
method in ValuesController
. I have also provided some extension methods to help in checking the claims even easier.
When Your Session Cookie Becomes Too Big
Once your application becomes complex, so are the number of claims to handle. By default, all the claims are stored as part of the session cookie and browsers like Safari impose a restriction on the size of the cookie. So one fine day, when you add few more claims to the application, you will start getting serialization errors. That's because only partial cookie will be sent back to the server and server does not know what to do with it. So the solution for this problem is to create the security token in "Reference" mode. What it means is to store the token on the server and just store a reference session id as the cookie. See the image below. The cookie size is just few bytes:
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public ActionResult Login(LoginModel model, string returnUrl)
{
if (ModelState.IsValid)
{
var cp = GetClaimsFromIdentityServer(model.UserName, model.Password);
if (cp != null)
{
var token = new SessionSecurityToken(cp) {
IsReferenceMode = true
};
FederatedAuthentication.WSFederationAuthenticationModule.
SetPrincipalAndWriteSessionToken(token, true);
return RedirectToLocal(returnUrl);
}
}
ModelState.AddModelError
("", "The user name or password provided is incorrect.");
return View(model);
}
SessionToken Caching
The Reference mode will work as long as you are on a single server instance scenario but it will not work when you have a web farm scenario because by default the cached tokens are stored in server memory. The solution to this problem is to cache the tokens in a custom data store. Again Thinktecture.IdentityModel
is our friend here. All we need to do is to implement a simple interface and couple of lines of code added to Global.asax. I have provided implementations for caching the tokens in SQL Server/MongoDB/AppFabric. So you can choose whichever you want. In Global.asax, you need to add the below lines of code in Init
method:
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
WebApiConfig.Register(GlobalConfiguration.Configuration);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
AuthConfig.RegisterAuth();
AntiForgeryConfig.UniqueClaimTypeIdentifier =
ClaimTypes.Name;
PassiveSessionConfiguration.ConfigureSessionCache(new MongoTokenCacheRepository());
}
public override void Init()
{
PassiveModuleConfiguration.CacheSessionsOnServer();
PassiveModuleConfiguration.SuppressLoginRedirectsForApiCalls();
base.Init();
}
Common Errors
Here are some common errors you will come across while doing this exercise and solutions for the same. WIF10201: No valid key mapping found for securityToken: 'System.IdentityModel.Tokens.X509SecurityToken' and issuer: 'http://identityserver.v2.xyz.com/trust'.
Just make sure you have used the correct thumbprint for your certificate in issuerNameRegistry
entry in web.config:
<issuernameregistry type="System.IdentityModel.Tokens.ValidatingIssuerNameRegistry,
System.IdentityModel.Tokens.ValidatingIssuerNameRegistry, Version=2.0.0.0,
Culture=neutral, PublicKeyToken=31bf3856ad364e35">
<authority name="http://identityserver.v2.xyz.com/trust">
<keys>
<add thumbprint="C2B2219F3CAC53658E796C0402360D90AEFA08FC">
</keys>
<validissuers>
<add name="http://identityserver.v2.xyz.com/trust">
</validissuers>
</authority>
</issuernameregistry>
ID4243: Could not create a SecurityToken. A token was not found in the token cache and no cookie was found in the context.
I have seen this error mostly during development where I keep stopping/starting the development web server. What it is saying is that there is a session cookie found but nothing available on the server token cache corresponding to that cookie. You can just delete all the cookies for the domain and you should be good to go.
Points of Interest
I have kept all the common code in a separate project named IdenityServer.Demo.Common
and I have written comments wherever I felt necessary. I have gathered all this information by going through many blogs and MSDN documentation and I have given references to most of them below. If I miss any one, that's totally unintentional and I am glad to add the reference if you let me know. Please let me know your comments and feedback.
Nuget Packages You Need
- Thinktecture.IdentityServer.Core (For Extending Identity Server)
Thinktecture.IdentityModel
- System.IdentityModel.Tokens.Jwt
- System.IdentityModel.Tokens.ValidatingIssuerNameRegistry
- ServerAppFabric.Client (If you want to use AppFabric for caching)
- RestSharp
- Newtonsoft.Json
- mongocsharpdriver (If you want to use MongoDB for caching)
References
History
- Initial version - 11/13/2013