about - tech blog -
wildlink.com

Wildlink's Technology Blog
An occasionally updated list of informative articles.
Pulled from our internal wiki.
ColdFusion - Solving multiple apps using the same SAML provider
2022-02-18 Edited: 2024-02-26

Keywords: ColdFusion, ColdFusion 2021

Problem

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

We set up the account with the 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/ProcessLogin.cfm)

In the app, we can detect that the user is not logged in so and do a initSAMLAuthRequest(idp, sp, relayState: "CALLING_PAGE_URL"). The SAML request goes out, authenticates, then returns to the landing page as expected.

The problem is how to redirect back to the target application and tell it the user is authenticated.

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

For obvious security reasons, we could not just place the authentication information in the URL, even encoded/encrypted.

We also had consistent problems with session variables. If we attempted to set session variables in processLogin.cfm then doing the cflocation back to the calling application, the user would end up with a different session so would not have access to the set variables.


Solution

To solve this problem, we started by making sure each application had a consistently generated name in Application.cfc.

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

We do this because we often will run "training instances" of the codebase on the same server that point to alternate DB schemas so users can play around without corrupting production data. By setting the name to the path, it ensures training/demo instances do not collide with the main instance.

To initiate the SAML request, 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
    });
}

// part 3
// 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 //URL/sso/processLogin.cfm.

processLogin 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 (these are hard coded for each app so it does take a bit of maintenance, but minor).

It then saves the SAML session's NAMEID field (provided by our IDP) in an application variable (named _auth_struct) keyed by a random token, then returns to the calling page (from relaystate) and passes the token.

<cfset response = ProcessSAMLResponse(idpname:"IDP_NAME", spname:"SP_NAME") />
<cfset returnURL = response.RELAYSTATE />

<cfif findNoCase("/app1", returnURL)>
    <cfset appPath = "\path\relative\to\webroot\for\app1" />
<cfelseif findNoCase("/app2", returnURL)>
    <cfset appPath = "\path\relative\to\webroot\for\app3" />
<cfelseif findNoCase("/app3", returnURL)>
    <cfset appPath = "\path\relative\to\webroot\for\app3" />
...
</cfif>

<!--- initiate application --->
<cfset target_path = lcase(expandPath("/") & appPath & "\Application.cfc") />
<cfapplication name="#hash(target_path)#" 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"/>

NOTE: In the real implementation there are more trys and locks and data safety.

This throws it back to the application.

The application can use the URL token to retrieve the authentication information from the application scope.

Going 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
// If the token loaded correctly, session.auth_username will be set so it will continue on to part 3 and actually log the user in

Important caveats

  1. This is all done on an intranet server, so 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).
  1. Our IDP is very constrained (and our team does not have direct access to it). It would be much nicer if we could just create distinct SP settings for each app and have the return calls go directly back to the calling app.

  2. The sample 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 (we learned that the hard way).

Back to the Tech Blog
Blog engine: 1.4.0