IT:AD:OIOSAML:HowTo:Understand How the Lib Works
Summary
This is an investigation of the SignOn process.
Notes
Incoming Requests
All incoming requests from a UserAgent pass through the stack of registered HttpModules.
The instructions that acompany their DemoWeb indicate the installation of the FormsAuthentication module, wired to redirect to an url.
<configuration>
...
<system.web>
...
<sessionState timeout="2"></sessionState>
<authentication mode="Forms">
<forms
name="DemoServiceProvider"
cookieless="UseCookies"
loginUrl="/demo/login.ashx"
timeout="1"/>
</authentication>
</system.web>
</configuration>
Note:
- Authentication mode is set to Forms (rather than None or other) in order to activate the FormsAuthenticationModule.
- The
formsloginUrlis not pointing to a webpage as is customary, but instead to a handler (see below). - The
HttpHandleris wired to handle two Application events –OnAuthenticationandOnRequestEnd. - As discussed on HttpHandlers -- Determine Execution Order HttpHandlers are processed in the order they are registered. The framework's
web.configindicates that incoming requests will be performed by theFormsAuthenticationModuleprior to the UrlAuthorisationModule:
<httpModules>
<add name="OutputCache" type="System.Web.Caching.OutputCacheModule" />
<add name="Session" type="System.Web.SessionState.SessionStateModule" />
<add name="WindowsAuthentication" type="System.Web.Security.WindowsAuthenticationModule" />
...
<add name="FormsAuthentication" type="System.Web.Security.FormsAuthenticationModule" />
...
<add name="UrlAuthorization" type="System.Web.Security.UrlAuthorizationModule" />
...
</httpModules>
The FormsAuthenticationModule is wired to handle two events:
public void Init(HttpApplication app) {
...
app.AuthenticateRequest += new EventHandler(this.OnEnter);
app.EndRequest += new EventHandler(this.OnLeave);
}
OnAuthenticate Event Handler
Therefore all incoming requests – Authenticated and UnAuthenticated – requests pass through the FormsAuthentication.OnAuthenticate(e) handler on the way in.
Note:The AuthenticateRequest event signals that the configured authentication mechanism has authenticated the current request. Subscribing to the AuthenticateRequest event ensures that the request will be authenticated before processing the attached module or event handle.The EndRequest event occurs as the last event in the HTTP pipeline chain of execution when ASP.NET responds to a request. In this event, you can compress or encrypt the response.
private void OnAuthenticate(FormsAuthenticationEventArgs e)
{
HttpCookie cookie = (HttpCookie) null;
if (this._eventHandler != null)
this._eventHandler((object) this, e);
//VERY IMPORTANT:
//If *any* earlier Module has already created a User at this point,
//do NOT change, and get out early:
if (e.Context.User != null) {return;}
// Pseudo code steps:
// =============
// Get `FormsAuthenticationTicket` from Cookie (using FormsAuthentication.FormsCookieName):
// If null/Expired exit;
// Renew ticket (adjust sliding expiration)
// Create new FormsIdentity from ticket
// Create GenericPrincipal from FormsIdentity
// set e.Context.User
// create new ticket, encrypt, remove existing, reattach
}
}
In other words – if returning user (has ticket), create a Context.User.
Otherwise, do nothing (no Context.User)
So it's not here that the Authorisation is handled. It's later.
UrlAuthorisationModule
As all requests pass through all modules, it goes on to the next modules.
From the list above, this means that we sooner or later end up being processed by the UrlAuthorisationModule.
In ASP.NET, authorisation of access to pages and folders is handled via the UrlAuthorisationModule.
How it works is that the request goes through the FormsAuthenticationModule, and does not get stopped (it just either sets an HttpContext.User or not). It then gets to be processed by the UrlAuthenticationModule – which looks at the request url, and depending on the Location permissions, lets it go on to the next modules (sooner or later ending up at the Page rendering Module), or throw a 401.
The 401 is what interests us.
OnEndRequest
As the request is being aborted, we sooner or later end back up at the EndRequest event handler of the FormsAuthenticationModule.
Which checks the Response.StatusCode, and if it's not a 401 does nothing.
But if it is a 401, it redirects it to the FormHandlers response url.
IN our case, whereas it's usually redirecting to a Login.aspx page, here it's redirecting to the ashx:
<configuration>
<system.web>
...
<authentication mode="Forms">
<forms cookieless="UseCookies" loginUrl="/demo/login.ashx" name="DemoServiceProvider" timeout="1"/>
</authentication>
...
</configuration>
The login.ashx
The login ashx is just an iis page hosting the framework's handler:
<%@ WebHandler Language=“C#” Class=“dk.nita.saml20.protocol.Saml20SignonHandler, dk.nita.saml20” %>
The SignOnHandler
The handler is complex in that it handles at least the following:
- the initial redirect from the FormsAuthenticationModule.EndRequest event handler.
- which in turn 302's the browser off to the SSO
- processes the response from the SSO.
public class Saml20SignonHandler : Saml20AbstractEndpointHandler
{
public const string ExpectedInResponseToSessionKey = "ExpectedInResponseTo";
private readonly X509Certificate2 _certificate;
public Saml20SignonHandler()
{
//Get the Certificate from FederationConfig.
//Get the RedirectUrl from the Saml20FederationConfig.SignOnEndpoint.RedirectUrl
}
//Handle request
protected override void Handle(HttpContext context)
{
//If SOAPAction we use other method
//In this case:
//Look at Request Params and determine action.
//If contains SAMLart goto HandleArtifact
//If contains SAMLResponse, this is the response from SSO, goto HandleResponse
//if contains 'r' and using common domain...we're not...
//Otherwise, if it's got nothing in it yet...it's a redirect from the FormsAuthenticationModule...and goto SendRequest method
}
private void SendRequest(HttpContext context)
{
//Look for the ReturnUrl that the FormsAuthenticationModule would have stuck on
//Save it in HttpContext["RedirectUrl"]
//302 the browser off to the IdP endpoint
IDPEndPoint idpEndpoint = this.RetrieveIDP(context);
if (idpEndpoint == null)
{
new SelectSaml20IDP().ProcessRequest(context);
}
else
{
Saml20AuthnRequest @default = Saml20AuthnRequest.GetDefault();
this.TransferClient(idpEndpoint, @default, context);
}
}
internal static XmlElement GetAssertion(XmlElement el, out bool isEncrypted)
{
XmlNodeList elementsByTagName1 = el.GetElementsByTagName("EncryptedAssertion", "urn:oasis:names:tc:SAML:2.0:assertion");
if (elementsByTagName1.Count == 1)
{
isEncrypted = true;
return (XmlElement) elementsByTagName1[0];
}
else
{
XmlNodeList elementsByTagName2 = el.GetElementsByTagName("Assertion", "urn:oasis:names:tc:SAML:2.0:assertion");
if (elementsByTagName2.Count == 1)
{
isEncrypted = false;
return (XmlElement) elementsByTagName2[0];
}
else
{
isEncrypted = false;
return (XmlElement) null;
}
}
}
private void HandleResponse(HttpContext context)
{
Encoding utF8 = Encoding.UTF8;
XmlDocument decodedSamlResponse = Saml20SignonHandler.GetDecodedSamlResponse(context, utF8);
AuditLogging.logEntry(Direction.IN, Operation.LOGIN, "Received SAMLResponse: " + decodedSamlResponse.OuterXml);
try
{
XmlAttribute xmlAttribute = decodedSamlResponse.DocumentElement.Attributes["InResponseTo"];
if (xmlAttribute == null)
throw new Saml20Exception("Received a response message that did not contain an InResponseTo attribute");
string inResponseTo = xmlAttribute.Value;
Saml20SignonHandler.CheckReplayAttack(context, inResponseTo);
Status statusElement = this.GetStatusElement(decodedSamlResponse);
if (statusElement.StatusCode.Value != "urn:oasis:names:tc:SAML:2.0:status:Success")
{
if (statusElement.StatusCode.Value == "urn:oasis:names:tc:SAML:2.0:status:NoPassive")
((AbstractEndpointHandler) this).HandleError(context, "IdP responded with statuscode NoPassive. A user cannot be signed in with the IsPassiveFlag set when the user does not have a session with the IdP.");
this.HandleError(context, statusElement);
}
else
{
bool isEncrypted;
XmlElement xmlElement = Saml20SignonHandler.GetAssertion(decodedSamlResponse.DocumentElement, out isEncrypted);
if (isEncrypted)
xmlElement = Saml20SignonHandler.GetDecryptedAssertion(xmlElement).Assertion.DocumentElement;
IDPEndPoint idpEndPoint = this.RetrieveIDPConfiguration(this.GetIssuer(xmlElement));
if (!string.IsNullOrEmpty(idpEndPoint.ResponseEncoding))
{
Encoding encoding;
try
{
encoding = Encoding.GetEncoding(idpEndPoint.ResponseEncoding);
}
catch (ArgumentException ex)
{
((AbstractEndpointHandler) this).HandleError(context, (Exception) ex);
return;
}
if (encoding.CodePage != utF8.CodePage)
xmlElement = Saml20SignonHandler.GetAssertion(Saml20SignonHandler.GetDecodedSamlResponse(context, encoding).DocumentElement, out isEncrypted);
}
this.HandleAssertion(context, xmlElement);
}
}
catch (Exception ex)
{
((AbstractEndpointHandler) this).HandleError(context, ex);
}
}
protected virtual void PreHandleAssertion(HttpContext context, XmlElement elem, IDPEndPoint endpoint)
{
dk.nita.saml20.Utils.Trace.TraceMethodCalled(this.GetType(), "PreHandleAssertion");
if (endpoint != null && endpoint.SLOEndpoint != null && !string.IsNullOrEmpty(endpoint.SLOEndpoint.IdpTokenAccessor))
{
ISaml20IdpTokenAccessor idpTokenAccessor = Activator.CreateInstance(Type.GetType(endpoint.SLOEndpoint.IdpTokenAccessor, false)) as ISaml20IdpTokenAccessor;
if (idpTokenAccessor != null)
idpTokenAccessor.ReadToken(elem);
}
dk.nita.saml20.Utils.Trace.TraceMethodDone(this.GetType(), "PreHandleAssertion");
}
private void HandleAssertion(HttpContext context, XmlElement elem)
{
dk.nita.saml20.Utils.Trace.TraceMethodCalled(this.GetType(), "HandleAssertion");
IDPEndPoint idpEndPoint = this.RetrieveIDPConfiguration(this.GetIssuer(elem));
AuditLogging.IdpId = idpEndPoint.Id;
this.PreHandleAssertion(context, elem, idpEndPoint);
bool quirksMode = false;
if (idpEndPoint != null)
quirksMode = idpEndPoint.QuirksMode;
Saml20Assertion assertion = new Saml20Assertion(elem, (IEnumerable<AsymmetricAlgorithm>) null, quirksMode);
if (idpEndPoint == null || idpEndPoint.metadata == null)
{
AuditLogging.logEntry(Direction.IN, Operation.AUTHNREQUEST_POST, "Unknown login IDP, assertion: " + (object) elem);
((AbstractEndpointHandler) this).HandleError(context, Resources.UnknownLoginIDP);
}
else if (!idpEndPoint.OmitAssertionSignatureCheck && !assertion.CheckSignature(Saml20SignonHandler.GetTrustedSigners((ICollection<KeyDescriptor>) idpEndPoint.metadata.GetKeys(KeyTypes.signing), idpEndPoint)))
{
AuditLogging.logEntry(Direction.IN, Operation.AUTHNREQUEST_POST, "Invalid signature, assertion: " + (object) elem);
((AbstractEndpointHandler) this).HandleError(context, Resources.SignatureInvalid);
}
else if (assertion.IsExpired())
{
AuditLogging.logEntry(Direction.IN, Operation.AUTHNREQUEST_POST, "Assertion expired, assertion: " + elem.OuterXml);
((AbstractEndpointHandler) this).HandleError(context, Resources.AssertionExpired);
}
else
{
this.CheckConditions(context, assertion);
AuditLogging.AssertionId = assertion.Id;
AuditLogging.logEntry(Direction.IN, Operation.AUTHNREQUEST_POST, "Assertion validated succesfully");
************************************************************
this.DoLogin(context, assertion);
************************************************************
}
}
private void DoLogin(HttpContext context, Saml20Assertion assertion)
{
context.Session["LoginIDPId"] = context.Session["TempIDPId"];
context.Session["IDPSessionID"] = (object) assertion.SessionIndex;
context.Session["IDPNameIdFormat"] = (object) assertion.Subject.Format;
context.Session["IDPNameId"] = (object) assertion.Subject.Value;
if (dk.nita.saml20.Utils.Trace.ShouldTrace(TraceEventType.Information))
dk.nita.saml20.Utils.Trace.TraceData(TraceEventType.Information, new string[1]
{
string.Format(Tracing.Login, (object) assertion.Subject.Value, (object) assertion.SessionIndex, (object) assertion.Subject.Format)
});
if (assertion.GetSubjectConfirmationData() != null && assertion.GetSubjectConfirmationData().InResponseTo != null)
{
string inResponseTo = assertion.GetSubjectConfirmationData().InResponseTo;
}
string str = "(unknown)";
foreach (SamlAttribute samlAttribute in assertion.Attributes)
{
if (samlAttribute.Name == "dk:gov:saml:attribute:AssuranceLevel" && samlAttribute.AttributeValue != null && samlAttribute.AttributeValue.Length > 0)
str = samlAttribute.AttributeValue[0];
}
AuditLogging.logEntry(Direction.IN, Operation.LOGIN, string.Format("Subject: {0} NameIDFormat: {1} Level of authentication: {2} Session timeout in minutes: {3}", (object) assertion.Subject.Value, (object) assertion.Subject.Format, (object) str, (object) HttpContext.Current.Session.Timeout));
***************************************************
foreach (IAction action in Actions.GetActions())
{
dk.nita.saml20.Utils.Trace.TraceMethodCalled(action.GetType(), "LoginAction()");
action.LoginAction((AbstractEndpointHandler) this, context, assertion);
dk.nita.saml20.Utils.Trace.TraceMethodDone(action.GetType(), "LoginAction()");
}
***************************************************
}
private void TransferClient(IDPEndPoint idpEndpoint, Saml20AuthnRequest request, HttpContext context)
{
AuditLogging.AssertionId = request.ID;
AuditLogging.IdpId = idpEndpoint.Id;
context.Session["TempIDPId"] = (object) idpEndpoint.Id;
IDPEndPointElement idpEndPointElement = Saml20AbstractEndpointHandler.DetermineEndpointConfiguration(SAMLBinding.REDIRECT, idpEndpoint.SSOEndpoint, idpEndpoint.metadata.SSOEndpoints());
request.Destination = idpEndPointElement.Url;
if (idpEndpoint.ForceAuthn)
request.ForceAuthn = new bool?(true);
object obj1 = context.Session["IDPIsPassive"];
if (obj1 != null && (bool) obj1)
{
request.IsPassive = new bool?(true);
context.Session["IDPIsPassive"] = (object) null;
}
if (idpEndpoint.IsPassive)
request.IsPassive = new bool?(true);
object obj2 = context.Session["IDPForceAuthn"];
if (obj2 != null && (bool) obj2)
{
request.ForceAuthn = new bool?(true);
context.Session["IDPForceAuthn"] = (object) null;
}
if (idpEndpoint.SSOEndpoint != null && !string.IsNullOrEmpty(idpEndpoint.SSOEndpoint.ForceProtocolBinding))
request.ProtocolBinding = idpEndpoint.SSOEndpoint.ForceProtocolBinding;
context.Session.Add("ExpectedInResponseTo", (object) request.ID);
if (idpEndPointElement.Binding == SAMLBinding.REDIRECT)
{
dk.nita.saml20.Utils.Trace.TraceData(TraceEventType.Information, new string[1]
{
string.Format(Tracing.SendAuthnRequest, (object) "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", (object) idpEndpoint.Id)
});
HttpRedirectBindingBuilder redirectBindingBuilder = new HttpRedirectBindingBuilder();
redirectBindingBuilder.signingKey = this._certificate.PrivateKey;
redirectBindingBuilder.Request = request.GetXml().OuterXml;
string url = request.Destination + "?" + redirectBindingBuilder.ToQuery();
AuditLogging.logEntry(Direction.OUT, Operation.AUTHNREQUEST_REDIRECT, "Redirecting user to IdP for authentication", redirectBindingBuilder.Request);
context.Response.Redirect(url, true);
}
else if (idpEndPointElement.Binding == SAMLBinding.POST)
{
dk.nita.saml20.Utils.Trace.TraceData(TraceEventType.Information, new string[1]
{
string.Format(Tracing.SendAuthnRequest, (object) "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", (object) idpEndpoint.Id)
});
HttpPostBindingBuilder postBindingBuilder = new HttpPostBindingBuilder(idpEndPointElement);
if (string.IsNullOrEmpty(request.ProtocolBinding))
request.ProtocolBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
XmlDocument xml = request.GetXml();
XmlSignatureUtils.SignDocument(xml, request.ID);
postBindingBuilder.Request = xml.OuterXml;
AuditLogging.logEntry(Direction.OUT, Operation.AUTHNREQUEST_POST);
postBindingBuilder.GetPage().ProcessRequest(context);
}
else
{
if (idpEndPointElement.Binding == SAMLBinding.ARTIFACT)
{
dk.nita.saml20.Utils.Trace.TraceData(TraceEventType.Information, new string[1]
{
string.Format(Tracing.SendAuthnRequest, (object) "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact", (object) idpEndpoint.Id)
});
HttpArtifactBindingBuilder artifactBindingBuilder = new HttpArtifactBindingBuilder(context);
if (string.IsNullOrEmpty(request.ProtocolBinding))
request.ProtocolBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact";
AuditLogging.logEntry(Direction.OUT, Operation.AUTHNREQUEST_REDIRECT_ARTIFACT);
artifactBindingBuilder.RedirectFromLogin(idpEndPointElement, request);
}
((AbstractEndpointHandler) this).HandleError(context, Resources.BindingError);
}
}
}
}
Actions
According to the documentation, the DoLogin is going to loop over Actions.
According to the section 9.13 of the documentation, the default Actions are:
<Actions> <add name="SetSamlPrincipal" type="dk.nita.saml20.Actions.SamlPrincipalAction, dk.nita.saml20 " /> <add name="Redirect" type="dk.nita.saml20.Actions.RedirectAction, dk.nita.saml20" /> </Actions>
The SetSamlPrincipal is the one to look at..and replace.
public class SamlPrincipalAction : IAction
{
...
public void LoginAction(AbstractEndpointHandler handler, HttpContext context, Saml20Assertion assertion)
{
Saml20SignonHandler saml20SignonHandler = (Saml20SignonHandler) handler;
//********************************************************
//Make a principal...that's ok
IPrincipal principal = Saml20Identity.InitSaml20Identity(assertion, saml20SignonHandler.RetrieveIDPConfiguration((string) context.Session["TempIDPId"]));
//OUCH! That's using a Session object...TOTALLY NOT ACCEPTABLE.
Saml20PrincipalCache.AddPrincipal(principal);
//Interesting...got to look this up to understand exactly what's going on here.
FormsAuthentication.SetAuthCookie(principal.Identity.Name, false);
//********************************************************
}
public void LogoutAction(AbstractEndpointHandler handler, HttpContext context, bool IdPInitiated)
{
FormsAuthentication.SignOut();
Saml20PrincipalCache.Clear();
}
}
}
The reason this is bad is that when it's caching the Identity it's doing it Session – which is of no use in a load balanced environment:
internal class Saml20PrincipalCache
{
internal static void AddPrincipal(IPrincipal principal)
{
HttpContext.Current.Session[typeof (Saml20Identity).FullName] = (object) principal;
}
internal static IPrincipal GetPrincipal()
{
return (IPrincipal) (HttpContext.Current.Session[typeof (Saml20Identity).FullName] as GenericPrincipal);
}
internal static void Clear()
{
HttpContext.Current.Session.Remove(typeof (Saml20Identity).FullName);
}
}
So..create a new Action:
public class SamlPrincipalAction : IAction
{
...
public void LoginAction(AbstractEndpointHandler handler, HttpContext context, Saml20Assertion assertion)
{
Saml20SignonHandler saml20SignonHandler = (Saml20SignonHandler) handler;
//********************************************************
//Make a principal...that's ok
IPrincipal principal = Saml20Identity.InitSaml20Identity(assertion, saml20SignonHandler.RetrieveIDPConfiguration((string) context.Session["TempIDPId"]));
var service = ServiceLocator<ISomeService>();
service.ConvertOIOSamlIdentityToAppIdentity.
Embed OIOSamlIdentity as Child Property of AppIdentity
PersistAppIdentityInDb as Base64
//Done here
Saml20PrincipalCache.AddPrincipal(principal);
//Interesting...got to look this up to understand exactly what's going on here.
FormsAuthentication.SetAuthCookie(principal.Identity.Name, false);
//********************************************************
}
public void LogoutAction(AbstractEndpointHandler handler, HttpContext context, bool IdPInitiated)
{
FormsAuthentication.SignOut();
//etc...get our AppIdentity out of cache,
//get SamlIdentity out of our identity
//clear its session enabled states...
//done.
}
}
}
Last question…who's getting the identity OUT of the SamlIdentityCache?
OMG. The following is primitive. And unsatisfactory. It means that the current library demo is using two identities – it's leaving the FormsIdentity on the thread. And expecting users to check against this method.
internal static IPrincipal GetPrincipal()
{
return (IPrincipal) (HttpContext.Current.Session[typeof (Saml20Identity).FullName] as GenericPrincipal);
}
Shocked.
But have an Idea…although its getting late.