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.