Friday, February 4, 2011

ADFS, SAML and RelayState

I have been working with ADFS for a customer for som time now, and as a part of their pilot we are going to enable trust with a SaaS application that suports SAML WebSSO Post profile. The customer requires deep linking to conten from their intranet. The SaaS solution handles this by reading the RelayState parameter in the SAML POST.
Microsoft did not implement support for this part of the POST profile....
In this forum thread Collins gives an outline of the solution, but no actual code examples. As we needed this feature I had to write this piece of code (not the prettiest code ever writen, but it works in my case).
So, to the solution:
(As I said, it is not going to be pretty)
code is shown in italic

Code to add a cookie:
in "IdpInitiatedSignOn.aspx.cs" add the following line in the using section:

using System.Web;

And at the beginning of the method "Page_Init" add the following lines at the beginngin of the method:

string value = Context.Request.QueryString["RelayState"];
if(String.IsNullOrEmpty(value))
value=" ";
HttpCookie = cookie= new HttpCookie("RelayState", value);
Context.Response.Cookies.Add(cookie);

(It might be a good idea to enable error messages in web.config (a small typo might cause crashes)

Create a class library project in Visual Studio 2008, with a strong name.

add a class called: "RelayStateModule"

public class RelayStateModule:System.Web.IHttpModule
{
#region IHttpModule Members

public void Dispose()
{
_Context = null;
}
private System.Web.HttpApplication _Context;
private RelayStateFilter MyFilter;
public void Init(System.Web.HttpApplication context)
{
_Context = context;
_Context.AuthorizeRequest += new EventHandler(_Context_AuthorizeRequest);

}



void _Context_AuthorizeRequest(object sender, EventArgs e)
{
if (_Context.Request.Cookies.AllKeys.Contains("RelayState"))
{
var value = _Context.Request.Cookies["RelayState"].Value;
MyFilter = new RelayStateFilter(_Context.Response.Filter, value);
_Context.Response.Filter = MyFilter;
}
}

#endregion
}

Add a class called: "RelayStateFilter"

public class RelayStateFilter : Stream
{

private Stream ParentFilter;
private StreamWriter streamWriter;
private string _RelayState;
public RelayStateFilter(Stream filter,string relayState)
{
_RelayState = relayState;
ParentFilter = filter;
streamWriter = new StreamWriter(ParentFilter);
}
public override void Write(byte[] buffer, int offset, int count)
{

MemoryStream ms = new MemoryStream(buffer, offset, count, false);
StreamReader sr = new StreamReader(ms, System.Text.Encoding.UTF8);

string s;
bool isSAMLResponse = false;
StringBuilder sb = new StringBuilder();
int i = 0;
int lineNumberToInsertRS = -1;
while ((s = sr.ReadLine()) != null)
{
i++;
if (s.Contains("SAMLResponse"))
{
isSAMLResponse = true;
}
if (isSAMLResponse && s.Contains("submit"))
{
lineNumberToInsertRS = i;
var rsIncluded = InsertRSIntoResponseString(s, _RelayState);

sb.AppendLine(rsIncluded);
streamWriter.WriteLine(rsIncluded);
}
else
{
sb.AppendLine(s);
streamWriter.WriteLine(s);
}
}
sb.AppendLine("number of lines="+i.ToString());
//For debugging purposes, remove from production code.
if (isSAMLResponse)
{
using (var fs = File.Open(@"c:\temp\lastToken.txt", FileMode.Create))
{
//fs.w
var sr2 = new StreamWriter(fs);

sr2.Write(sb.ToString());
sr2.Close();
}
}
streamWriter.Flush();

}

private string InsertRSIntoResponseString(string s, string _RelayState)
{
//Finding location of Submit action
var loc = s.IndexOf("<noscript>");
return s.Insert(loc,String.Format("<input type=\"hidden\" name=\"RelayState\" value=\>"{0}\" /", _RelayState)); //
}
#region Rest of Stream's function - our stream is write-only

public override int Read(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}

public override bool CanRead
{ get { return false; } }

public override bool CanSeek
{ get { return false; } }

public override bool CanWrite
{ get { return true; } }

public override long Length
{ get { throw new NotSupportedException(); } }

public override long Position
{
get { throw new NotSupportedException(); }
set { throw new NotSupportedException(); }
}

public override void Flush()
{
ParentFilter.Flush();
}

public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}

public override void SetLength(long value)
{
throw new NotSupportedException();
}

#endregion
}

Compile and get the full name of your new assembly.

Create a new folder in the adfs\ls directory called "bin" and place your assembly there.
Then you need to register your assembly in web.config:

And at last you register the module in IIS Manager under the ls web site.
There you create a managed module called RelayStateFilter
And under type you have to refer your RelayStateModule
{your namespace}.RelayStateModule

hopefully you will have your RelayState in the SAML message to your RP.





8 comments:

Unknown said...

Sorry about the formating. I used the old editor....

Mr. Taco said...

Can this be compiled with the free Microsoft Express tools? Will this end up with a .dll extension? I'd love more details!

Unknown said...

Yes, you can compile this with VS express. remember that you need to sign the assembly. An .net assembly ends up with the .dll extention.

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

{modules>
{add name="RelayStateFilter" type="Pragma.ADFS.SAML.IdPInitiatedFilter.RelayStateModule" preCondition="managedHandler" />
{/modules>

Ryan said...
This comment has been removed by the author.
Ryan said...

Thanks for posting this! There's a couple of things to mention that I had to change/add to get this to work:

- When creating the VS Class Library project for .NET 2.0, I had to modify a couple of lines to get it to build:
1. In the _Context_AuthorizeRequest() method, I had to change the if statement to read as follows: ((IList)_Context.Request.Cookies.AllKeys).Contains("RelayState")
2. In the InsertRSIntoResponseString() method, you have a little typo in the "value=" portion of the injected html.

- In the Page_Init() method in the IdpInitiatedSignOn.aspx.cs file:
1. For some reason the if(String.IsNullOrEmpty(value)) value=" "; line did not work with Google apps. I replaced value with "token", but I believe this could have been any arbitrary text in my case.

- In the web.config, be sure to also add an assembly reference in the "assemblies" section to your compiled, signed .dll: add assembly="whatever.SSO.RelayStateModule, Version=1.0.0.0, Culture=neutral, PublicKeyToken=......"

Randy - Oxford Computer Group said...

I can compile and configure the module and I am able to see the relaystate formfield in the debug file but I am not seeing it in the html document that is returned to the browser. Instead I see two instances of the html body tags (open and close). The first is complete but without the RelayState formfield that is present in the debug text file. The second instance of the html body is truncated at about 80% of the SAMLResponse field.
Any ideas on what to look for or change?