Monday, September 12, 2011

The quest for customizing ADFS sign-in web pages, part 6 – a WSTrustFeb2005 Endpoint on the Custom STS

A quick summary – we are working on a custom STS which the ADFS will federate with. In our previous entry we have created a trust relationship between the ADFS and our custom STS so users are able to see the HomeRealmDiscovery page in the ADFS and pick up a correct identity provider – the ADFS itself or the custom STS.

In our very last entry we are going to provide a WSTrustFeb2005 endpoint on our custom STS so that WCF signin requests could be created directly from the ADFS to the custom STS without the HRD page. In fact, users will see the ADFS login page with username/password text fields and we will modify the logic behind the page so that the pair username/password will be first validated against our custom STS and then against the Active Directory without user being aware of that. This will complete our quest for customizing ADFS sign-in web pages.

A WSTrustFeb2005 Endpoint

Go to our custom STS project, and create a *.svc service. Since I like to follow conventions, I created it under /services/trust/2005/UserName.svc (since we are going to perform username/password validation).

The service itself is rather straightforward as it uses a built-in factory class. It means that the *.svc doesn’t need any code behind and the sole content of the *.svc file is:

<%@ ServiceHost Language="C#" Debug="true" 
    Factory="Microsoft.IdentityModel.Protocols.WSTrust.WSTrustServiceHostFactory" 
    Service="The.Custom.STS.Code.CustomSecurityTokenServiceConfiguration"
 %>

Note that the Service attribute points to the security token service configuration class which we have created long, long ago somewhere at the begininng of our quest, when we have built a custom STS. In other words, the active WCF service uses the same STS infrastructure as the passive LoginForm/Default pages.

But we are not done yet. Go to the web.config file and create correct WCF sections:

<system.serviceModel>
    <services>
        <service name="Microsoft.IdentityModel.Protocols.WSTrust.WSTrustServiceContract"
                 behaviorConfiguration="ServiceBehavior">
            <endpoint address="Sts" binding="ws2007HttpBinding"
                      contract="Microsoft.IdentityModel.Protocols.WSTrust.IWSTrustFeb2005SyncContract"
                      bindingConfiguration="wsTrustFeb2005Configuration" />
            <host>
                <baseAddresses>
                    <add baseAddress="https://customsts.yourdomain.com/services/trust/2005/UserName.svc"/>
                </baseAddresses>
            </host>
            <endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange" />
        </service>
    </services>
    <bindings>
        <ws2007HttpBinding>
            <binding name="wsTrustFeb2005Configuration">
                <security mode="TransportWithMessageCredential">
                    <message clientCredentialType="UserName" establishSecurityContext="false"/>
                </security>
            </binding>
        </ws2007HttpBinding>
    </bindings>
    <behaviors>
        <serviceBehaviors>
            <behavior name="ServiceBehavior">
                <serviceMetadata httpGetEnabled="true" />
                <serviceDebug includeExceptionDetailInFaults="false" />
            </behavior>
        </serviceBehaviors>
    </behaviors>
    <serviceHostingEnvironment multipleSiteBindingsEnabled="true" />
</system.serviceModel>

Note that we are using a built-in contract interface (IWSTrustFeb2005SyncContract) and we create an explicit ws2007HttpBinding binding with security mode set to TransportWithMessageCredential and clientCredentialType set to UserName. This is the only correct combination and what’s important is that it works only when the service is exposed on the secure channel (SSL).

This is enough to test the service, just point your browser to the https://customsts/services/trust/2005/UserName.svc and check if it displays correct information page.

A custom token handler

By default the service we have just built will try to accept tokens for Windows users. However, we’d like to have a custom validation logic so for example we could validate users agains the SQL database.

For this, we need a custom token handler which could examine SAML request tokens, extract usernames/passwords from there and create claims accordingly.

This is surprizingly straightforward:

public class CustomUserNameSecurityTokenHandler : UserNameSecurityTokenHandler
 {
     public override bool CanValidateToken
     {
         get
         {
             return true;
         }
     }
 
     public override ClaimsIdentityCollection ValidateToken( 
         System.IdentityModel.Tokens.SecurityToken token )
     {
         UserNameSecurityToken userNameToken = token as UserNameSecurityToken;
         if ( userNameToken == null )
             throw new ArgumentException( "The security token is not a valid username token." );
 
         if ( userNameToken.Password == userNameToken.UserName.Length.ToString() )
         {
             IClaimsIdentity identity = new ClaimsIdentity();
 
             identity.Claims.Add( new Claim( ClaimTypes.Name, userNameToken.UserName ) );
             identity.Claims.Add( new Claim( ClaimTypes.Role, "CustomTokenHandlerRole1" ) );
             identity.Claims.Add( new Claim( ClaimTypes.Role, "CustomTokenHandlerRole2" ) );
 
             return new ClaimsIdentityCollection( new IClaimsIdentity[] { identity } );
         }
 
         throw new InvalidOperationException( "Username/password is incorrect in STS." );
     }
 }

Note that my simple token handler just checks whether the password matches the length of the username (so foo/3 or foobar/6 are valid credentials) but this is the exact place you can plug any validation logic.

We only need to inform WIF to use this custom token handler instead of the default one. We do this in web.config in a proper section:

<microsoft.identityModel>
    <service>
        <securityTokenHandlers>
            <remove type="Microsoft.IdentityModel.Tokens.WindowsUserNameSecurityTokenHandler, 
                          Microsoft.IdentityModel, 
                          Version=3.5.0.0, Culture=neutral, 
                          PublishKeyToken=31BF3856AD364E35"/>
            <add type="The.Custom.STS.CustomUserNameSecurityTokenHandler" />
        </securityTokenHandlers>
    </service>
</microsoft.identityModel>

This new token handler will not interfere with the passive authentication scenario, where your browser asks for the Default.aspx page of the STS. Instead, it is only used during the active authentication scenario, where WCF requests are handled by the STS.

Passing through claims

The custom token handler we have created will now works correctly, however our WCF service still points to our CustomSecurityTokenService class where we create claims manually (the GetOutputClaimsIdentity method).

Because our custom token handler now creates its own claims, we have to modify the STS logic so that WCF claims could be passed through.

protected override IClaimsIdentity GetOutputClaimsIdentity( 
     IClaimsPrincipal principal, 
     RequestSecurityToken request, Scope scope )
 {
     if ( null == principal )
     {
         throw new ArgumentNullException( "principal" );
     }
 
     ClaimsIdentity outputIdentity = new ClaimsIdentity();
 
     // Name
     outputIdentity.Claims.Add( new Claim( ClaimTypes.Name, principal.Identity.Name ) );
 
     // Roles (should be dynamic)
     outputIdentity.Claims.Add( new Claim( ClaimTypes.Role, "Role1" ) );
     outputIdentity.Claims.Add( new Claim( ClaimTypes.Role, "Role2" ) );
 
     // Pass through any existing claims (here: passed from the custom token handler)
     foreach ( var claim in principal.Identities[0].Claims )
         outputIdentity.Claims.Add( new Claim( claim.ClaimType, claim.Value ) );
 
     return outputIdentity;
 }
Modifying the ADFS login page

The ADFS login page can now be modified to use our WCF service. Go to ADFS installation, find the FormsSignIn.aspx.cs file and modify it to:

protected void SubmitButton_Click( object sender, EventArgs e )
 {
     try
     {
         SignInWithTokenFromOtherSTS( UsernameTextBox.Text, PasswordTextBox.Text );
     }
     catch 
     {     
         try
         {
             SignIn( UsernameTextBox.Text, PasswordTextBox.Text );
         }
         catch ( AuthenticationFailedException ex )
         {
             HandleError( ex.Message );
         }
     }
 }
 
 private void SignInWithTokenFromOtherSTS( string UserName, string Password )
 {
         const string OtherSTSAddress = 
            "https://customsts.yourdomain.com/services/trust/2005/UserName.svc/Sts";
         const string YourStsAddress  = 
            "http://fs.adfs.pl/adfs/services/trust";
 
         EndpointAddress endpointAddress = new EndpointAddress( OtherSTSAddress );
         UserNameWSTrustBinding binding = 
            new UserNameWSTrustBinding( SecurityMode.TransportWithMessageCredential );
 
         WSTrustChannelFactory factory = new WSTrustChannelFactory( binding, endpointAddress );
         factory.Credentials.UserName.UserName = UserName;
         factory.Credentials.UserName.Password = Password;
         factory.TrustVersion = System.ServiceModel.Security.TrustVersion.WSTrustFeb2005;
 
         WSTrustChannel channel = (WSTrustChannel)factory.CreateChannel();
 
         RequestSecurityToken rst = new RequestSecurityToken(
             WSTrustFeb2005Constants.RequestTypes.Issue,
             WSTrustFeb2005Constants.KeyTypes.Bearer );
         rst.AppliesTo = new EndpointAddress( YourStsAddress );
 
         SecurityToken token = channel.Issue( rst );
 
         SignIn( token );
 }

Note that instead of using the built-in SignIn( string UserName, string Password ) method (which would check the credentials against the active directory) we are providing a custom sign in method which creates a WCF request to the WSTrustFeb2005 service in behalf of http://fs.adfs/adfs/services/trust (which is the ID of the ADFS, not it’s address! If you pass the address, the ADFS will refuse to accept the token because the token would not be issued for the ADFS).

When we get the token back from the service, we pass it to another built-in method which this time accepts the token instead of the username/password pair (SignIn( token )). Note that this would not work without the explicit trust relationship in the ADFS to our custom STS we have created in the previous blog entry. This time the ADFS would complain that the claims provider is not trusted.

Also note that this is the most difficult step in our tutorial as we are finally putting all blocks together. My advice is to test the SignInWithTokenFromOtherSTS method separately, from within a custom console application and when it finally works (you get the token and not the exception from the WCF service) you can move on to testing this code from within the ADFS.

I’ve spent at least a day at this point, still getting exceptions from the WCF service and when it finally started to work (I got tokens from the WCF service) making it work with the ADFS was easy.

Modifying the ADFS HRD page

The only remaining issue is that because of our trust relationship between the ADFS and our custom STS, the ADFS still shows the HomeRealmDiscovery page! This is rather unfortunate because we’d like it to show the default page using our enhanced logic. And the passive login using the loginpage of our custom STS should be now disabled.

To do so, open the HomeRealmDiscovery.aspx.cs in the ADFS installation and modify the Page_Init so that it immediately picks the home realm:

public partial class HomeRealmDiscovery : 
   Microsoft.IdentityServer.Web.UI.HomeRealmDiscoveryPage
{
    protected void Page_Init( object sender, EventArgs e )
    {
        PassiveIdentityProvidersDropDownList.DataSource = base.ClaimsProviders;
        PassiveIdentityProvidersDropDownList.DataBind();
        
        // Added line. Pick up the home realm immediately 
        // (first one from the list which is the ADFS)
        SelectHomeRealm( PassiveIdentityProvidersDropDownList.SelectedItem.Value );
    }    
 
    protected void PassiveSignInButton_Click( object sender, EventArgs e )
    {
        SelectHomeRealm( PassiveIdentityProvidersDropDownList.SelectedItem.Value );
    }
}

Enjoy

Our quest is complete. The default ADFS login page can now accept any validation logic, you can for example validate users against a database or accept the email/password pair against the AD.

If you need the complete source code of this tutorial, please contact me directly.

9 comments:

Martin Bélanger said...

With this solution, the ADFS SSO doesn't work.

Anonymous said...

When deploying my custom STS to a server(works on localhost), I get the following whent trying to access username.svc: "Could not find a base address that matches scheme http for the endpoint with binding MetadataExchangeHttpBinding. Registered base address schemes are [https]." If I change endpoint binding to mexHttpsBinding and httpGetEnabled="true" for service metadata, then it works. It seems I cannot use http when base is set to https, but why does this work on localhost(hosted in IIS)? There is no binding differencies on IIS

Anonymous said...

Correction for previous comment: In service metadata GETs has to be disabled: httpGetEnabled="false"

rdf said...

Can't get SignInWithTokenFromOtherSTS() work(debugging in localhost). I get SecurityNegotiationException on channel.Issue(rst). Exception message: "Could not establish secure channel for SSL/TLS with authority 'xyz'". When I use Fiddler to decyrpt HTTPS this exception goes away. So does this has something to do with SSL certificates?

Anonymous said...

Fantastic post! Thank you, Wiktor.

Martin, to fix the SAML SSO protocol cases, you need to assert an AuthenticationInstant in the RSTR/Issue SAML token:

outputIdentity.Claims.Add(new Claim(ClaimTypes.AuthenticationMethod, "http://{your method}"));

outputIdentity.Claims.Add(new Claim(ClaimTypes.AuthenticationInstant, XmlConvert.ToString(DateTime.Now, XmlDateTimeSerializationMode.Utc)));

Regards,
Hanuman Haeseler

Unknown said...

Wonderful post, thanks.

Got everything working except that I'm having some issues with ADFS 1.0 Web Agent sites throwing an exception complaining about missing Authentication Statement when doing an active sign-in via the SignIn() function. Passive sign-in works on same website and I can also see that Authentication Statement section in the SAML request is missing with active sign-in. So I was wondering if anybody else got this working with ADFS 1.0 web agent sites?

Unknown said...
This comment has been removed by the author.
Unknown said...
This comment has been removed by the author.
Suryanarayanan Prakash said...

Its an excellent post. Can you please mail me the codes. My email id is surya20p@gmail.com

It is an excellent article.