Search code examples
coldfusioncoldfusion-2021

ColdFusion 2021 - How to handle SAML/SSO with multiple applications on same server


We have a server with about a dozen small applications each in their own subfolder of the server (//URL/app1, //URL/app2, etc).

I've got the basic SSO authentication round trip working. I set up my account with my IDP and have the response set to go to a common landing page (ACS URL). Since the landing page is currently shared with all the apps, it is in a separate folder distinct from the apps (//URL/sso/acsLandingPage.cfm)

I'm now working on my first app. I can detect the user is not logged in so I do a initSAMLAuthRequest(idp, sp, relayState: "CALLING_PAGE_URL") and that goes out, authenticates, then returns to the landing page.

But how do I redirect back to my target application and tell it the user is authenticated?

If I just do a <cflocation url="CALLING_PAGE_URL" /> the original app doesn't know about the SAML request.

Is there a function that I can call in the original app that will tell if the current browser/user has an open session?

Do I need to set up separate SP for each application so rather than one common landing page each app would have its own landing page so it can set session variables to pass back to the main application? (the IDP treats our apps as "one server", I can get separate keys if that is the best way to deal with this).

My current working idea for the ACS landing page is to parse the relayState URL to find out which application started the init request and then do something like this:

ACSLandingPage.cfm

<cfset response = processSAMLResponse(idp, sp) />
<cfif find(response.relaystate, 'app1')>
   <cfapplication name="app1" sessionmanagement="true" />
<cfelseif find(response.relaystate, 'app2')>
   <cfapplication name="app2" sessionmanagement="true" />
</cfif>

<cfset session.authenticated_username = response.nameid />
<cflocation url="#response.relaystate#" />

Not terribly ideal, but I think it might work.

I was hoping I was just overlooking something simple and really appreciate any help I can get.

Edit: My above idea of using <cfapplication in the ACSLandingPage is not working because the <cfapplication keeps trying to assign it to a new session so that when I redirect back to the original app, it thinks it is in a different session so does not have access to the original session.authenticated-username.


Solution

  • Ok, here's how I ended up solving this problem. Probably not the "correct" solution, but it works for me.

    The full code solution would be way too long and complicated and rely on too many local calls that would not make sense, so I'm trying to get this down to just some code snippets that will make sense to show how my solution works.

    In each application, the Application.cfc looks a bit like this. Each app has a name set to the path of the Application.cfc. We do this because we often will run "training instances" of the codebase on the same server that point to an alternate DB schema so users can play around without corrupting production data.

    component {
        this.name               = hash(getCurrentTemplatePath());
        ...
    

    In the application's onRequestStart function it has something a bit like this:

        cfparam(session.is_authenticated, false);
        cfparam(session.auth_username, '');
        cfparam(application._auth_struct, {});  // will be important later
    
        // part 1
        // there will be code in this block later in the description
    
        // part 2
        if (NOT session.is_authenticated OR session.auth_username EQ '') {
            var returnURL = '#getPageContext().getRequest().getScheme()#://#cgi.server_name#/#cgi.http_url#';   // points back to this calling page
            // start the call
            InitSAMLAuthRequest({
                'idp'       : 'IDP_NAME',
                'sp'        : 'SP_NAME',
                'relayState': returnURL
            });
        }
    
        // log them in
        if (session.is_authenticated AND session.auth_username NEQ '' AND NOT isUserLoggedIn()) {
            ... do cflogin stuff here ...
        }
    
        // throw problems if we are not logged in by this point
        if (NOT isUserLoggedIn()) {
            ... if we don't have a logged in user by this point do error handling and redirect them somewhere safe ...
        }
    

    This initiates the SAML connection to our ID Provider. The provider does its stuff and returns the user to the file 'https://myserver/sso/ProcessSAMLResponse.cfm'.

    processSAMLResponse uses the returnURL set in relayState to determine which application initiated the request so it can get a path to the app's Application.cfc.

    <cfset response = ProcessSAMLResponse(idpname:"IDP_NAME", spname:"SP_NAME") />
    <cfset returnURL = response.RELAYSTATE />
    
    <cfif findNoCase("/app1", returnURL)>
        <cfset appPath = "PHYSICAL_PATH_TO_APP1s_APPLICATION.CFC" />
    <cfelseif findNoCase("/app2", returnURL)>
        <cfset appPath = "PHYSICAL_PATH_TO_APP2s_APPLICATION.CFC" />
    <cfelseif findNoCase("/app3", returnURL)>
        <cfset appPath = "PHYSICAL_PATH_TO_APP3s_APPLICATION.CFC" />
    ...
    </cfif>
    
    <!--- initiate application --->
    <cfapplication name="#hash(appPath)#" sessionmanagement="true"></cfapplication>
    
    <!--- create a token (little more than a random string and a bit prettier than a UUID) --->
    <cfset auth_token = hash(response.NAMEID & dateTimeFormat(now(), 'YYYYmmddHHnnssL'))/>
    
    <cfset application._auth_struct[auth_token] = {
        "nameid": lcase(response.NAMEID),
        "expires": dateAdd('n', 5, now())
    } />
    
    <!--- append token (can also be done with a ?: if you are inclined) --->
    <cfif NOT find("?", returnURL)>
        <cfset returnURL &= "?auth_token=" & encodeForURL(auth_token) />
    <cfelse>
        <cfset returnURL &= "&auth_token=" & encodeForURL(auth_token) />
    </cfif>
    
    <!--- return to the calling page --->
    <cflocation url="#returnURL#" addToken="No"/>
    

    This throws it back to the application. So we go back into the application's onRequestStart to fill in that part 1 block from above:

        cfparam(session.is_authenticated, false);
        cfparam(session.auth_username, '');
    
        // part 1
        // look for an auth token
        if (NOT session.is_authenticated AND session.auth_username EQ '' AND structKeyExists(URL, 'auth_token')) {
            var auth_token = URL.auth_token;
    
            // see if it exists in our auth struct (and has all fields)
            if (    structKeyExists(application, "_auth_struct")
                AND structKeyExists(application._auth_struct, auth_token)
                AND isStruct(application._auth_struct[auth_token])
                AND structKeyExists(application._auth_struct[auth_token], 'nameid')
                AND structKeyExists(application._auth_struct[auth_token], 'expires')) {
    
                // only load if not expired
                if (application._auth_struct[auth_token].expires GT now()) {
                    session.is_authenticated = true;
                    session.auth_username = application._auth_struct[auth_token].nameid;
                }
                // remove token from struct to prevent replays
                structDelete(application._auth_struct, auth_token);
    
            } // token in auth struct?
    
            // remove expired tokens
            application._auth_struct = structFilter(application._auth_struct, function(key, value) {
                return value.expires GT now();
            });
        }   // auth_token?
    
        // part 2
        // .... from earlier
    

    So that's how I solved the problem of multiple apps trying to use a single IDP/SP combination.

    Important caveats:

    1. This is all done on an intranet server, so my security is much more lax than it would be on a public facing server. (in particular, using an application variable to store the auth-tokens could be vulnerable to a massive DDOS type attack that would flood new sessions and fill available memory).
    • A subset of 1 - these apps get a few hundred users a day across all apps, if you have a site that gets thousands of hits a day, storing the tokens in application like I do may not be memory efficient enough for you.
    1. My IDP is very constrained. It would be much nicer if I could just create distinct SP settings for each app and have the return calls go directly back to the calling app.

    2. I skipped a few checks and error handling to keep the sample simple. You should do lots more tests on the values, especially to make sure the nameID is a valid user before the actual cflogin call.

    3. Before calling initSAMLAuthRequest, you may want to add a session counter to prevent an infinite loop of authentication calls if something goes wrong (learned that the hard way).