Adventures in Single-Sign-On: Cross Domain Script Request
Consider a scenario where a user authenticates with ADFS (or equivalent identity provider (IdP)) when accessing a domain such as https://www.domain.com (A) and then, from this page, a request is made to https://api.other-domain.com/app.js (B) to download a set of application scripts that would then interact with a set of REST based web services in the B domain. We’d like to have SSO so that claims provided to A are available to B and that the application scripts downloaded can then subsequently make requests with an authentication cookie.
Roughly speaking, the scenario looks like this:
It was straightfoward enough to set up the authentication with ADFS using WIF 4.5 for each of A and B following the MSDN “How To“; I had each of the applications separately working with the same ADFS instance, but the cross domain script request from A to B at step 5 for the script file generated an HTTP redirect sequence (302) that resulted in an XHTML form from ADFS with Javascript that attempts to execute an HTTP POST for the last leg of the authentication. This was good news because it meant that ADFS recognized the user session and tried to issue another token for the user in the other domain without requiring a login.
However, this obviously posed a problem as, even though it appeared as if it were working, the request for the script could not succeed because of the text/html response from ADFS.
Here’s what https://www.domain.com/default.aspx looks like in this case:
1 2 3 4 5 6 |
<html> <body> ... <script type="text/javascript" src="https://api.other-domain.com/app.js"></script> </body> </html> |
This obviously fails because the HTML content returned from the redirect to ADFS cannot be consumed.
I scratched my head for a bit and dug into the documentation for ADFS, trawled online discussion boards, and tinkered with various configurations trying to figure this out with no luck. Many examples online that discuss this scenario when making a web service call from the backend of one application to another using bearer tokens or WIF ActAs delegation, but these were ultimately not suited for what I wanted to accomplish as I didn’t want to have to write out any tokens into the page (for example, adding a URL parameter to the app.js request), make a backend request for the resource, or use a proxy.
(I suspect that using the HTTP GET binding for SAML would work, but for the life of me, I can’t figure out how to set this up on ADFS…)
In a flash of insight, it occurred to me that if I used a hidden iframe to load another page in B, I would then have a cookie in session to make the request for the app.js!
Here’s the what the page looks like on the page in A:
1 2 3 4 5 6 7 8 9 10 11 |
<script type="text/javascript"> function loadOtherStuff() { var script = document.createElement('script'); script.setAttribute('type', 'text/javascript'); script.setAttribute('src', 'https://api.other-domain.com/appscript.js'); document.body.appendChild(script); } </script> <iframe src="https://api.other-domain.com" style="display: none" onload="javascript:loadOtherStuff()"></iframe> |
Using the iframe, the HTTP 302 redirect is allowed to complete and ADFS is able to set the authentication cookie without requiring a separate sign on since it’s using the same IdP, certificate, and issuer thumbprint. Once the cookie is set for the domain, then subsequent browser requests in the parent document to the B domain will carry along the cookie!
The request for appscript.js is intercepted by an IHttpHandler and authentication can be performed to check for the user claims before returning any content. This then allows us to stream back the client-side application scripts and templates via AMD through a single entry point (e.g. appscript.js?app=App1 or a redirect to establish a root path depending on how you choose to organize your files).
Any XHR requests made subsequently still require proper configuration of CORS on the calling side:
1 2 3 4 5 6 7 8 9 |
$.ajax({ url: 'https://api.other-domain.com/api/Echo', type: 'GET', crossDomain: true, xhrFields: { withCredentials: true }, success: function(result){ window.alert('HERE'); console.log('RETRIEVED'); console.log(result); } }); |
And on the service side:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
<!--// Needed to allow cross domain request. configuration/system.webServer/httpProtocol //--> <httpProtocol> <customHeaders> <add name="Access-Control-Allow-Origin" value="https://www.domain.com" /> <add name="Access-Control-Allow-Credentials" value="true" /> <add name="Access-Control-Allow-Headers" value="accept,content-type,cookie" /> <add name="Access-Control-Allow-Methods" value="POST,GET,OPTIONS" /> </customHeaders> </httpProtocol> <!--// Allow CORS pre-flight configuration/system.webServer/security //--> <security> <requestFiltering allowDoubleEscaping="true"> <verbs> <add verb="OPTIONS" allowed="true" /> </verbs> </requestFiltering> </security> <!--// Handle CORS pre-flight request configuration/system.webServer/modules //--> <add name="CorsOptionsModule" type="WifApiSample1.CorsOptionsModule" /> |
The options handler module is a simple class that responds to OPTION requests and also dynamically adds a header to the response:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
/// <summary> /// <c>HttpModule</c> to support CORS. /// </summary> public class CorsOptionsModule : IHttpModule { #region IHttpModule Members public void Dispose() { //clean-up code here. } public void Init(HttpApplication context) { context.BeginRequest += HandleRequest; context.EndRequest += HandleEndRequest; } private void HandleEndRequest(object sender, EventArgs e) { string origin = HttpContext.Current.Request.Headers["Origin"]; if (string.IsNullOrEmpty(origin)) { return; } if (HttpContext.Current.Request.HttpMethod == "POST" && HttpContext.Current.Request.Url.OriginalString.IndexOf(".svc") < 0) { HttpContext.Current.Response.AddHeader("Access-Control-Allow-Origin", origin); } } private void HandleRequest(object sender, EventArgs e) { if (HttpContext.Current.Request.HttpMethod == "OPTIONS") { HttpContext.Current.Response.End(); } } #endregion } |
The end result is that single-sign-on is established across two domains for browser to REST API calls using simple HTML-based trickery (only tested in FF!).