Foliotek Developer Blog

Custom Multi-Federation in ASP.NET

We’ve recently had a client request that we implement a ADFS solution for their organization.   On the face of it, it seems like a simple enough task – ADFS/WS-Federation is built into the framework (through a WIF install).  Foliotek could just become a relying party for their ADFS server.

However, there were a few of issues:

  1. By default, ASP.NET only allows you to define a single federation per application.  We run a multi-client web application, and this would mean we’d have to spin off a separate web farm for the application that was specific to the client.  This isn’t necessarily a dealbreaker, but it wasn’t ideal.
  2. The federation takes the place of any other form of authentication.  Our app allows forms-based logins too – and even for this client, their students may want to continue using the application after graduation.  If so, they need to be able to access the forms login as well.
  3. Related to #2 – we needed to map the client’s claims (and student identity) to their correct Foliotek user primary key.  It would be a much simpler change if we could transition the claim into a “normal” sign in so that we didn’t have to adapt the entire app to deal with a new kind of key.
  4. Hosting our own ADFS server, while not prohibitive, wasn’t cost effective or easily scalable

After scanning lots of online documentation on ADFS, WS-Federation, and SAML – my first useful discovery was the open source .NET project thinktecture IdentityServer.  This project was invaluable as a testing tool – you can easily set up an Issuer to test your relying party with any machine that can run .NET.  This allowed me to set up my first trust relationship by only changing some of our app’s web.config settings, an validation class, and the Microsoft WIF library.  This was mostly pulled from the IdentityServer RP sample code.

<?xml version="1.0"?>  
<configuration>  
  <configSections>
    <section name="microsoft.identityModel" type="Microsoft.IdentityModel.Configuration.MicrosoftIdentityModelSection, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
  </configSections>
  <microsoft.identityModel>
    <service saveBootstrapTokens="true">
      <audienceUris>
        <!-- add your relying party url(s) -->
        <add value="https://roadie/rp/" />
      </audienceUris>
      <applicationService>
        <claimTypeRequired>
          <!-- custom claims allowed below. These are some identity server examples -->
          <!--Following are the claims offered by STS 'http://identityserver.thinktecture.com/trust/initial'. Add or uncomment claims that you require by your application and then update the federation metadata of this application.-->
          <claimType type="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" optional="true" />
          <claimType type="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" optional="true" />
          <!--<claimType type="http://identityserver.thinktecture.com/claims/profileclaims/webpage" optional="true" />-->
          <!--<claimType type="http://identityserver.thinktecture.com/claims/profileclaims/twittername" optional="true" />-->
        </claimTypeRequired>
      </applicationService>
      <issuerNameRegistry type="Microsoft.IdentityModel.Tokens.ConfigurationBasedIssuerNameRegistry, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35">
        <trustedIssuers>
          <!--replace with your real issuer thumbprint(s) - found in the services MMC (delete whitespace) -->
          <add thumbprint="586A267558EA22012A68A9774426EB3FF9995AC2" name="Thinktecture.IdentityServer" />
        </trustedIssuers>
      </issuerNameRegistry>
      <federatedAuthentication>
          <!-- point at your installed issuer and relying party - this will replaced for the custom code later -->
          <wsFederation passiveRedirectEnabled="true" issuer="https://roadie/idsrv/issue/wsfed" realm="https://roadie/rp/" />
        </federatedAuthentication>
    </service>
    <system.web>
      <compilation debug="true" targetFramework="4.0">
        <assemblies>
          <add assembly="Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
        </assemblies>
      </compilation>
      <authentication mode="None" />
      <httpRuntime requestValidationType="WSFedRequestValidator" />
    </system.web>
    <location path="localapppathtofederate">
      <system.web>
        <authorization>
          <deny users="?"/>
        </authorization>
      </system.web>
    </location>
    <system.webServer>
      <modules runAllManagedModulesForAllRequests="true">
        <add name="SessionAuthenticationModule" type="Microsoft.IdentityModel.Web.SessionAuthenticationModule, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
        <add name="WSFederationAuthenticationModule" type="Microsoft.IdentityModel.Web.WSFederationAuthenticationModule, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/>
      </modules>
    </system.webServer>
  </microsoft.identityModel>
</configuration>  

The provided validator code:

//----------------------------------------------------------------------------- 
// 
// THIS CODE AND INFORMATION IS PROVIDED 'AS IS' WITHOUT WARRANTY OF 
// ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO 
// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A 
// PARTICULAR PURPOSE. 
// 
// Copyright (c) Microsoft Corporation. All rights reserved. 
// 
// 
//----------------------------------------------------------------------------- 
using System;  
using System.Web;  
using System.Web.Util;  
using Microsoft.IdentityModel.Protocols.WSFederation;  
public class WSFedRequestValidator : RequestValidator  
{
    protected override bool IsValidRequestString(HttpContext context, string value, RequestValidationSource requestValidationSource, string collectionKey, out int validationFailureIndex)
    {
        validationFailureIndex = 0;
        if (requestValidationSource == RequestValidationSource.Form && collectionKey.Equals(WSFederationConstants.Parameters.Result, StringComparison.Ordinal))
        {
            SignInResponseMessage message = WSFederationMessage.CreateFromFormPost(context.Request) as SignInResponseMessage;
            if (message != null)
            {
                return true;
            }
        }
        return base.IsValidRequestString(context, value, requestValidationSource, collectionKey, out validationFailureIndex);
    }
}

And some code to write out the claims that are issued:

using Microsoft.IdentityModel.Claims;

var id = (IClaimsIdentity)User.Identity;  
Response.Write("Auth Type: " + id.AuthenticationType + "<br />");  
Response.Write("Name: " + id.Name + "<br />");  
Response.Write("NameClaimType: " + id.NameClaimType + "<br /><br />");  
Response.Write("Claims:<hr />");  
foreach (var claim in id.Claims)  
{
    Response.Write("Type: " + claim.ClaimType + "<br />");
    Response.Write("Value: " + claim.Value + "<br />");
    Response.Write("Issuer: " + claim.Issuer + "<br />");
    Response.Write("OriginalIssuer: " + claim.OriginalIssuer + "<br />");
    Response.Write("<br />");
}

With that done, I quickly realized the problems I would have identifying with multiple organizations in the same app in the future.  It allowed me to put different issuer settings inside a local web.config or a ‘location’ parent tag without any errrors – but it wouldn’t listen to any of these settings.

I searched around some, and thankfully found this post that led me in the right direction.  I had to adapt it somewhat in order to handle switching between different issuers in the same browser (an unlikely production event, but makes testing easier).  This way I could see it working with two different issuers just by changing the URL.

Code:

using System;  
using System.Collections.Generic;  
using System.Linq;  
using System.Text;  
using Microsoft.IdentityModel.Claims;  
using System.Web;  
using System.Text.RegularExpressions;

public class FederatedLoginHandler : IHttpHandler  
{
    public bool IsReusable
    {
        get { return false; }
    }
    public void ProcessRequest(HttpContext context)
    {
        if (context.User == null || context.User.Identity == null || !context.User.Identity.IsAuthenticated || !(context.User is IClaimsPrincipal))
        {
            if (context.Request.HttpMethod == "GET")
            {
                ProcessClientLogin(context);
            }
            else if (context.Request.HttpMethod == "POST")
            {
                if (context.Request.Cookies["Endpoint"].Value != null)
                {
                    context.Response.Redirect(context.Request.Cookies["Endpoint"].Value);
                }
            }
            else {
                context.Response.Write("The requested HTTP method (" + context.Request.HttpMethod + ") is not supported by this redirector page. This page supports HTTP GET and POST. Please contact your administrator.");
            }
        }
        else
        {
            // get the issuer based on the url 
            Regex path = new Regex(@".*\/([^.?#]*)(\.|\?|\#)?.*");
            string url = context.Request.RawUrl;
            if (url.EndsWith("/")) url = url.Substring(0, url.Length - 1);
            string clientName = path.Match(url).Groups[1].Value;
            if (clientName == "default")
                clientName = context.Request.RequestContext.RouteData.Values["org"] + "";

            // todo: get issuer , issuer id and claims names from a db or configuration file based on the client 
            int clientID = 50;
            string clientissuer = "http://identityserver.thinktecture.com/trust/initial2";
            if (clientName.ToLower() == "client2")
                clientissuer = "http://identityserver.thinktecture.com/trust/initial";
            string externalidclaim = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier";
            var id = (IClaimsIdentity)context.User.Identity;
            var idclaim = id.Claims.FirstOrDefault(c => c.ClaimType == externalidclaim);
            if (idclaim == null || idclaim.Issuer != clientissuer)
            {
                // changed client - need to clear current claims here 
                // claims are just based on a couple of encrypted cookies. Need to expire to clear them. 
                if (context.Request.Cookies["FedAuth"] != null)
                {
                    HttpCookie myCookie = new HttpCookie("FedAuth");
                    myCookie.Expires = DateTime.Now.AddDays(-1d);
                    myCookie.Path = "/" + VirtualPathUtility.MakeRelative("/", "~/");

                    // cookie is at app path 
                    context.Response.Cookies.Add(myCookie);
                }
                if (context.Request.Cookies["FedAuth1"] != null)
                {
                    HttpCookie myCookie = new HttpCookie("FedAuth1");
                    myCookie.Expires = DateTime.Now.AddDays(-1d);
                    myCookie.Path = "/" + VirtualPathUtility.MakeRelative("/", "~/");

                    // cookie is at app path 
                    context.Response.Cookies.Add(myCookie);
                }
                ProcessClientLogin(context);
            }
            else
            {
                try
                {
                    // use the idclaim.Value and clientID to determine what user to SSO, and redirect to app 
                    // testing - write out claim info 
                    context.Response.Write("Client: " + clientName + "<br />");
                    context.Response.Write("Auth Type: " + id.AuthenticationType + "<br />");
                    context.Response.Write("Name: " + id.Name + "<br />");
                    context.Response.Write("NameClaimType: " + id.NameClaimType + "<br /><br />");
                    context.Response.Write("Claims:<hr />");
                    foreach (var claim in id.Claims)
                    {
                        context.Response.Write("Type: " + claim.ClaimType + "<br />");
                        context.Response.Write("Value: " + claim.Value + "<br />");
                        context.Response.Write("Issuer: " + claim.Issuer + "<br />");
                        context.Response.Write("OriginalIssuer: " + claim.OriginalIssuer + "<br />");
                        context.Response.Write("<br />");
                    }
                }
                catch (Exception exc)
                {
                    context.Response.Write("SSO Exception: " + exc.Message + "<br />");
                }
            }
        }
    }
    private void ProcessClientLogin(HttpContext context)
    {
        Regex path = new Regex(@".*\/([^.?#]*)(\.|\?|\#)?.*");
        string url = context.Request.RawUrl;
        if (url.EndsWith("/"))
            url = url.Substring(0, url.Length - 1);
        string clientName = path.Match(url).Groups[1].Value;
        if (clientName == "default")
            clientName = context.Request.RequestContext.RouteData.Values["org"] + "";

        // todo: get issuer url from db or config based on client here
        string issuerurl = "https://roadie/idsrv/issue/wsfed";
        if (clientName.ToLower() == "client2")
            issuerurl = "https://roadie2/idsrv/issue/wsfed";

        // adapted from http://social.technet.microsoft.com/wiki/contents/articles/ad-fs-2-0-how-to-utilize-a-single-relying-party-trust-for-multiple-web-applications-that-share-the-same-identifier.aspx
        string realm = context.Request.Url.GetLeftPart(UriPartial.Authority) + context.Request.RawUrl;
        context.Response.Cookies["Endpoint"].Value = realm;
        string datetime = DateTime.Now.ToUniversalTime().ToString("s") + "Z";

        // perpare and redirect to issuer with required info to set up federation cookies
        context.Response.Redirect(issuerurl + "?wa=" + context.Server.UrlEncode("wsignin1.0") + "&wtrealm=" + context.Server.UrlEncode(realm) + "&wctx=" + context.Server.UrlEncode("rm=0&id=passive&ru=" + context.Server.UrlEncode(context.Request.RawUrl)) + "&wct=" + context.Server.UrlEncode(datetime));
    }
}

After adding this code, you can remove the “deny users” config, and you can change “federatedAuthenticated” section to include dummy urls (they are replaced by the code above).

This solution is the best of both worlds – we can allow as many issuers we like – each with their own destination url(s) in the application.  But, we can depend on WIF to handle the messy work of authenticating the claims based on the correct certificate thumbprints.

One final note: this way does have a drawback. Namely, users will not be able to manually change their URL back and forth from https://identityserver.whatever.com and https://www.application.com – since at the root of your application, you won’t know which identity server to federate with the first time they hit https://www.application.com. If you hosted your own IdentityServer or ADFS server in the middle – then you could go the standard route instead and have your identity server trust the various issuers, and have it be the only issuer to pass those claims on to your application. I think that is a valid solution too, it just has an extra moving part to deal with.

Another way to work around this issue would be to map your application to a wildcard DNS entry like *.application.com – and use the subdomain to track which issuer to federate with. If set up correctly, a user would be sent to https://clienta.application.com instead of https://www.application.com/clienta for federation – which means that you could also handle any user request like https://clienta.application.com/some/bookmarked/page and federate it as well.