Using CSOM with SAML Authentication (ADFS)

Topics: ADFS 3.0, SharePoint 2013, claims authentication, on-premise, Azure, CSOM, SAML

Description

Recently, I was tasked with making CSOM work with these SAML-enabled web applications and host-named site collections.  So I went to the great Google and Bing parts bins, found some things that I could build upon, and got to work.

My first iteration or attempt at making CSOM work with SAML was straightforward.  I found a couple of articles that described how to use a WebClient to pop the ADFS login dialog, which then set the security token that the CSOM ClientContext used.  Works great and it is not complicated.

Problem

But at about 3 a.m. one morning, I woke up with a thought: what if I wanted to do the same thing without prompting anybody, such as for running CSOM code as a scheduled task?  So I spent some evenings on the couch researching this, gathering useful “parts” from folks online, and writing additional code.  And Fiddler and Postman were my good friends, too.

The end result shown below is a functional console application that authenticates to ADFS, obtains a security token, extracts and repackages the FedAuth cookie, and uses the FedAuth cookie for the SharePoint CSOM ClientContext to do work against a SharePoint list.

In order to make this work, an intimate understanding of the ADFS configuration is needed.

Solution

Credit and thanks for certain sections of code go to the posts identified in the source code comments and bolded.  No wheels were reinvented in the making of this solution, only custom code.  Please visit those links highlighted in the source code below before examining this code.

Note that this code has a dependency on these NuGet packages, one of which delivers TokenHelper.cs and SharePointContext.cs.

<packages>
 <package id="AppForSharePointOnlineWebToolkit" version="3.1.2" targetFramework="net45" />
 <package id="Microsoft.AspNet.WebApi.Client" version="5.2.3" targetFramework="net45" />
 <package id="Microsoft.AspNet.WebApi.Core" version="5.2.3" targetFramework="net45" />
 <package id="Newtonsoft.Json" version="6.0.4" targetFramework="net45" />
</packages>
using System;
using System.IdentityModel.Protocols.WSTrust;
using System.IdentityModel.Tokens;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.ServiceModel;
using System.ServiceModel.Security;
using System.Text;
using System.Web;
using System.Xml;
using SP = Microsoft.SharePoint.Client;

namespace MyConsoleApp
{
 class Program
 {
 private static string adfsEndpoint = "https://somesite.xxxx.loc/_trust/"; // Endpoint for site from where we get the token 
 private static string adfsRealm = "urn:somesite.xxxx.loc:somesite"; // ADFS realm that contains the endpoint above
 private static CookieContainer fedAuth = new CookieContainer();
 private static string webUrl = "https://somesite.xxxx.loc/finance";
 private static string fedAuthRequestCtx = HttpUtility.UrlEncode(string.Format("{0}/_layouts/15/Authenticate.aspx?Source=%2ffinance", webUrl));

 static void Main(string[] args)
 {
 // BEGIN adapted for C# from https://blogs.msdn.microsoft.com/besidethepoint/2012/10/17/request-adfs-security-token-with-powershell/

 string adfsBaseUri = "https://sts.xxxx.loc"; // your ADFS farm URL
 string adfsTrustEndpoint = "usernamemixed"; // OOTB ADFS, do not change
 string adfsTrustPath = "adfs/services/trust/13"; // OOTB ADFS, do not change
 string domain = "EUROPE"; // domain of AD account; should be in config file or propmted from the command line
 string pwd = "Quercus9%%"; // password of domain account; should be a secure string from config file, but you get the idea
 string userName = "jdoe@xxxx.loc:"; // UPN of domain account in AD

 EndpointAddress ep = new EndpointAddress(adfsBaseUri + "/" + adfsTrustPath + "/" + adfsTrustEndpoint);
 WS2007HttpBinding binding = new WS2007HttpBinding(SecurityMode.TransportWithMessageCredential);
 binding.Security.Message.EstablishSecurityContext = false;
 binding.Security.Message.ClientCredentialType = MessageCredentialType.UserName;
 binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.None;

 WSTrustChannelFactory wstcf = new WSTrustChannelFactory(binding, ep);
 wstcf.TrustVersion = TrustVersion.WSTrust13;
 NetworkCredential cred = new NetworkCredential(userName, pwd, domain);
 wstcf.Credentials.Windows.ClientCredential = cred;
 wstcf.Credentials.UserName.UserName = cred.UserName;
 wstcf.Credentials.UserName.Password = cred.Password;
 var channel = wstcf.CreateChannel();

 string[] tokenType = { "urn:oasis:names:tc:SAML:1.0:assertion", "urn:oasis:names:tc:SAML:2.0:assertion" };
 RequestSecurityToken rst = new RequestSecurityToken();
 rst.RequestType = RequestTypes.Issue;
 rst.AppliesTo = new EndpointReference(adfsRealm);
 rst.KeyType = KeyTypes.Bearer;
 rst.TokenType = tokenType[0]; // Use the first one because that is what SharePoint itself uses (as observed in Fiddler).

 RequestSecurityTokenResponse rstr = new RequestSecurityTokenResponse();
 var cbk = new System.Net.Security.RemoteCertificateValidationCallback(ValidateRemoteCertificate);
 ServicePointManager.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => { return true; };
 SecurityToken token = null;
 try
 {
 token = channel.Issue(rst, out rstr);
 }
 finally
 {
 ServicePointManager.ServerCertificateValidationCallback = cbk;
 }

 // END adapted for C# from https://blogs.msdn.microsoft.com/besidethepoint/2012/10/17/request-adfs-security-token-with-powershell/

 // BEGIN adapted from https://github.com/OfficeDev/PnP-Sites-Core/blob/master/Core/SAML%20authentication.md

 Cookie fedAuthCookie = TransformTokenToFedAuth(((GenericXmlSecurityToken)token).TokenXml.OuterXml);
 fedAuth.Add(fedAuthCookie);

 // END adapted from https://github.com/OfficeDev/PnP-Sites-Core/blob/master/Core/SAML%20authentication.md

 // PERFORM CSOM OPS HERE:

 string listName = "Some Existing List";
 using (SP.ClientContext ctx = new SP.ClientContext(webUrl))
 {
 ctx.ExecutingWebRequest += ctx_ExecutingWebRequest;
 SP.CamlQuery query1 = new SP.CamlQuery();
 query1.ViewXml =
 "<View>" +
 "<Query>" +
 "<OrderBy>" +
 "<FieldRef Name='Title' Ascending='FALSE'/>" +
 "</OrderBy>" +
 "</Query>" +
 "<RowLimit>10</RowLimit>" +
 "</View>";
 SP.List list = ctx.Web.Lists.GetByTitle(listName);
 SP.ListItemCollection items = list.GetItems(query1);
 ctx.Load(items);
 ctx.ExecuteQuery();
 for (int i = 0; i < items.Count; i++)
 {
 SP.ListItem item = items[i];
 ctx.Load(item);
 ctx.ExecuteQuery();
 }
 }
 }

 static void ctx_ExecutingWebRequest(object sender, SP.WebRequestEventArgs e)
 {
 e.WebRequestExecutor.WebRequest.CookieContainer = fedAuth;
 }

 private static Cookie TransformTokenToFedAuth(string samlToken)
 {
 // Adapted from https://github.com/OfficeDev/PnP-Sites-Core/blob/master/Core/OfficeDevPnP.Core/IdentityModel/TokenProviders/ADFS/BaseProvider.cs

 samlToken = WrapInSoapMessage(samlToken, adfsRealm);
 string stringData = String.Format("wa=wsignin1.0&wctx={0}&wresult={1}", fedAuthRequestCtx, HttpUtility.UrlEncode(samlToken));
 HttpWebRequest req = HttpWebRequest.Create(adfsEndpoint) as HttpWebRequest;
 req.Method = "POST";
 req.ContentType = "application/x-www-form-urlencoded";
 req.CookieContainer = new CookieContainer();
 req.AllowAutoRedirect = false;
 Stream newStream = req.GetRequestStream();

 byte[] data = Encoding.UTF8.GetBytes(stringData);
 newStream.Write(data, 0, data.Length);
 newStream.Close();
 HttpWebResponse resp = req.GetResponse() as HttpWebResponse;
 var encoding = ASCIIEncoding.ASCII;
 string responseText = "";
 using (var reader = new System.IO.StreamReader(resp.GetResponseStream(), encoding))
 {
 responseText = reader.ReadToEnd();
 }
 return resp.Cookies["FedAuth"];
 }

 [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "System.Xml.XmlDocument.CreateTextNode(System.String)")]
 private static string WrapInSoapMessage(string stsResponse, string relyingPartyIdentifier)
 {
 // This method code is unchanged in its entirety from https://github.com/OfficeDev/PnP-Sites-Core/blob/master/Core/OfficeDevPnP.Core/IdentityModel/TokenProviders/ADFS/BaseProvider.cs.
 // I added in two places some inline commented code, which deals with using SAML 2.0 XML schema instead of 1.x. But SharePoint doesn't use 2.0. 

 XmlDocument samlAssertion = new XmlDocument();
 samlAssertion.PreserveWhitespace = true;
 samlAssertion.LoadXml(stsResponse);

 //Select the book node with the matching attribute value.
 String notBefore = /*samlAssertion.DocumentElement["Conditions"]*/samlAssertion.DocumentElement.FirstChild.Attributes["NotBefore"].Value;
 String notOnOrAfter = /*samlAssertion.DocumentElement["Conditions"]*/samlAssertion.DocumentElement.FirstChild.Attributes["NotOnOrAfter"].Value;

 XmlDocument soapMessage = new XmlDocument();
 XmlElement soapEnvelope = soapMessage.CreateElement("t", "RequestSecurityTokenResponse", "http://schemas.xmlsoap.org/ws/2005/02/trust");
 soapMessage.AppendChild(soapEnvelope);
 XmlElement lifeTime = soapMessage.CreateElement("t", "Lifetime", soapMessage.DocumentElement.NamespaceURI);
 soapEnvelope.AppendChild(lifeTime);
 XmlElement created = soapMessage.CreateElement("wsu", "Created", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd");
 XmlText createdValue = soapMessage.CreateTextNode(notBefore);
 created.AppendChild(createdValue);
 lifeTime.AppendChild(created);
 XmlElement expires = soapMessage.CreateElement("wsu", "Expires", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd");
 XmlText expiresValue = soapMessage.CreateTextNode(notOnOrAfter);
 expires.AppendChild(expiresValue);
 lifeTime.AppendChild(expires);
 XmlElement appliesTo = soapMessage.CreateElement("wsp", "AppliesTo", "http://schemas.xmlsoap.org/ws/2004/09/policy");
 soapEnvelope.AppendChild(appliesTo);
 XmlElement endPointReference = soapMessage.CreateElement("wsa", "EndpointReference", "http://www.w3.org/2005/08/addressing");
 appliesTo.AppendChild(endPointReference);
 XmlElement address = soapMessage.CreateElement("wsa", "Address", endPointReference.NamespaceURI);
 XmlText addressValue = soapMessage.CreateTextNode(relyingPartyIdentifier);
 address.AppendChild(addressValue);
 endPointReference.AppendChild(address);
 XmlElement requestedSecurityToken = soapMessage.CreateElement("t", "RequestedSecurityToken", soapMessage.DocumentElement.NamespaceURI);
 XmlNode samlToken = soapMessage.ImportNode(samlAssertion.DocumentElement, true);
 requestedSecurityToken.AppendChild(samlToken);
 soapEnvelope.AppendChild(requestedSecurityToken);
 XmlElement tokenType = soapMessage.CreateElement("t", "TokenType", soapMessage.DocumentElement.NamespaceURI);
 XmlText tokenTypeValue = soapMessage.CreateTextNode("urn:oasis:names:tc:SAML:1.0:assertion");
 tokenType.AppendChild(tokenTypeValue);
 soapEnvelope.AppendChild(tokenType);
 XmlElement requestType = soapMessage.CreateElement("t", "RequestType", soapMessage.DocumentElement.NamespaceURI);
 XmlText requestTypeValue = soapMessage.CreateTextNode("http://schemas.xmlsoap.org/ws/2005/02/trust/Issue");
 requestType.AppendChild(requestTypeValue);
 soapEnvelope.AppendChild(requestType);
 XmlElement keyType = soapMessage.CreateElement("t", "KeyType", soapMessage.DocumentElement.NamespaceURI);
 XmlText keyTypeValue = soapMessage.CreateTextNode("http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey");
 keyType.AppendChild(keyTypeValue);
 soapEnvelope.AppendChild(keyType);

 return soapMessage.OuterXml;
 }

 private static bool ValidateRemoteCertificate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors policyErrors)
 {
 // It may be insightful to examine the parameters here, while debugging, although the function only returns a value of true regardless of those parameters.
 return true;
 }
 }
}