Maintaining Route Information During SPA (Single-Page Application) Authentication In Lucee CFML
NOTE: While some parts of this post are generally applicable, much of this post is a byproduct of working with a system that has been around for a decade and used to have to support old browsers like IE6. Your mileage may vary.
The InVision platform is composed of a series of SPAs - Single-Page Applications - that are rendered with AngularJS on the front-end and powered by Lucee CFML on the back-end. Within each SPA, the current route is stored in the URL fragment (aka, the hash), which allowed client-side routing to work in older browsers such as IE6. The biggest challenge presented by fragment-based routing is that the fragment is never sent to the server during navigation. This makes it hard to maintain proper routing during external login events such as Single Sign-On (SSO). To get around this, I've started to use URL query-string parameters when defining routes in the transactional emails sent out from our Lucee CFML application.
View this code in my SPA Hash project on GitHub.
In the Angular world, there are two types of routing: "Hash mode" and "HTML5 mode". Hash mode defines the client-side route using the URL fragment (or hash). An example of this type of routing might look like this:
/my/spa.cfm#!/path/to/view
Where the /path/to/view
portion is the client-side route.
The HTML5 mode defines the client-side route as part of the overall page resource. So, the same route in HTML5 mode might look like this:
/my/spa.cfm/path/to/view
The benefit of the Hash mode routing is that is incurs significantly less technical complexity: You don't have to worry about History support (only relevant for older browsers); you don't have to worry about rendering arbitrary URLs on the server; and, you don't have to worry about relative paths to external files such a CSS and JavaScript includes.
The primary drawback of the Hash mode routing is that the browser never sends the URL fragment to the server. During the SPA (Single-Page Application) experience, this constraints isn't an issue at all. However, it does pose significant issues for deep linking.
Consider the following deep linking use-cases:
- A user bookmarks a view within the SPA.
- A user copy-pastes a SPA URL to another team member.
- A transactional email contains a link to a view within the SPA.
In each one of these use-cases, a user may try to access a deep link within the SPA prior to authentication. The application infrastructure must therefore intercept this request, bounce the user over to the login workflow, and then redirect the user back to the originally-requested URL. This is challenging enough when your application owns the entire login workflow. But, the moment you have to forward the user to an external login workflow (such as Single Sign-On or social media Sign-On), things get a lot more hairy.
Even though the browser maintains the URL fragment during location
-based redirects, an external login workflow won't know about this. And, after the user authenticates using the external system, the external system won't include the URL fragment when it sends the user back to your application experience. This likely means that your user will get dumped into the "root" of your SPA instead of landing on the deep link as they had originally intended.
I can't flip a switch and move us from "Hash mode" routing to "HTML5 mode" routing without a lot of things breaking. And, I don't have control over URLs that people choose to bookmark within the application. But, I can at least control the URLs that are generated within our transactional emails. And, as of late, I've been passing the "fragment" around as the URL query-string parameter, spaRoute
. So, instead of generating URLs like:
https://example.com/index.cfm#/path/to/view
... I'm generating URLs like:
https://example.com/index.cfm?spaRoute=/path/to/view
This converts the /path/to/view
route from a front-end-only value into a server-side value, which offers me more control over how it gets propagated. Of course, since my Angular application is still using Hash mode routing, this server-side value must eventually be converted into a client-side value for proper view rendering. And, it must try to play nicely with existing Hash mode redirects.
To see this in action, I've created a small demo with a Login page and SPA page. The underlying ColdFusion application has session-management enabled. And, if the user tries to access the SPA page without being logged-in, the user is bounced over to the Login page with a redirectTo
query-string parameter that tells the authentication workflow how to redirect the post-authentication user.
This whole demo has to work with and aggregate several values:
spaRoute
- The query-string parameter that defines the SPA route being provided in external URLs (such as those in a transactional email).redirectTo
- The query-string parameter that tells the authentication workflow where to send the user after authenticating (allowing the authentication workflow to be more flexible in how it is consumed).window.location.hash
- The URL fragment that may or may not be present when the user hits the authentication workflow.
First, let's look at the Login page. The application never links to the login page directly. Instead, it deep-links to views within the application and then relies on the security mechanisms to redirect the user to the Login if (and only if) necessary. As such, the Login page doesn't have to worry about the URL query-string value for spaRoute
; but, it does have to worry about the URL fragment and the redirectTo
URL. In this case, when it sees a URL fragment, it tries to convert it to a spaRoute
parameter.
<cfscript>
// Setup request parameter defaults (the union of URL and FORM scopes).
param name="request.spaRoute" type="string" default="";
param name="request.redirectTo" type="string" default="";
param name="request.submitted" type="boolean" default="false";
// NOTE: For the sake of simplicity, submitting the login form in this demo is good-
// enough for authentication - we're here to focus on the URL handling, not on how to
// authenticate users.
if ( request.submitted ) {
session.isLoggedIn = true;
// Once the user is logged-in, we are either going to redirect them to the
// default SPA experience; or, we're going to redirect them to the provided URL.
// In both cases, we need to append the "spaRoute" to the destination URL.
// --
// Redirect to a specific page after authentication.
if ( request.redirectTo.len() ) {
// CAUTION: In a production setting, you MAY have to take more precaution
// around how you redirect the user after (or as part of) authentication. In
// some cases, not sanitizing the redirection-URL can lead to a REFLECTED XSS
// (Cross-Site Scripting) attack (especially if you need to forward the user
// to a Single Sign-On identity provider that, in some way, echoes its "relay
// state" on the login page). For the sake of this demo, I am more-or-less
// trusting the redirectTo value.
nextUrl = new DynamicUrl()
.parseUrl( request.redirectTo )
.addUrlParamIfPopulated( "spaRoute", request.spaRoute )
.toUrl()
;
// Redirect to the default SPA experience after authentication.
} else {
nextUrl = new DynamicUrl( "./spa.cfm" )
.addUrlParamIfPopulated( "spaRoute", request.spaRoute )
.toUrl()
;
}
location( url = nextUrl, addToken = false );
}
</cfscript>
<cfoutput>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>
Log Into the Single-Page Application (SPA)
</title>
</head>
<body>
<h1>
Log Into the Single-Page Application (SPA)
</h1>
<form method="post" action="#encodeForHtmlAttribute( cgi.script_name )#">
<input type="hidden" name="submitted" value="true" />
<input type="hidden" name="redirectTo" value="#encodeForHtmlAttribute( request.redirectTo )#" />
<!--- Hook to convert URL fragment into URL search parameter. --->
<input type="hidden" name="spaRoute" value="#encodeForHtmlAttribute( request.spaRoute )#" />
<button type="submit">
Login
</button>
</form>
<hr />
<p>
<a href="./ingress.cfm">Back to demo URLs</a> →
</p>
<script type="text/javascript">
// If the main SPA experience redirected the user to the login form, the URL
// fragment was likely kept in tact (since a browser redirect doesn't remove
// it implicitly). However, when the user submits the login form, we'll lose
// any fragment by default. As such, if a fragment exists, let's convert it
// to a "spaHash" parameter and store it in the FORM so that it will get
// submitted along with the login.
document
.querySelector( "input[ name = 'spaRoute' ]" )
.value = window.location.hash.slice( 1 ) // Strip off leading pound.
;
</script>
</body>
</html>
</cfoutput>
As you can see, when the Login page renders, it takes the location.hash
value and stores it in a hidden form field. This converts the client-side only value into a value that will be submitted to the server during the Login form submission. And, once the user submits the Login form (which, for the sake of simplicity, authenticates them without ceremony), we merge our new spaRoute
value into the redirectTo
URL and then send the user back to where they came from prior to authentication.
The SPA (Single-Page Application) page then has two actions it must perform:
If the user it not currently logged-in, it must redirect the user to the Login page and include a
redirectTo
URL that tells the authentication workflow where to send the user, post-authentication.If the user is logged-in and a
spaRoute
for deep-linking is present, it must redirect the user to the proper view; and - for the sake of cleanliness - remove thespaRoute
value from the current URL (ie, move it from the main resource path into the URL fragment).
Note that if the spaRoute
query-string parameter is present and the user is not logged-in, the spaRoute
value will be propagated through to the Login page via the redirectTo
value.
<cfscript>
// The SPA experience is gated by the current session. If the user is not yet
// authenticated, we need to bounce them out to the login page.
if ( ! session.isLoggedIn ) {
// After the user logs-in, we want to redirect them to this page. As such, we
// have to pass the CURRENT URL to the login page as a parameter (redirectTo).
redirectTo = new DynamicUrl( cgi.script_name )
.addUrlParams( url )
.toUrl()
;
// NOTE: If the current URL has a FRAGMENT on it, that FRAGMENT will be
// maintained through this redirect (even though it is not sent to the server).
// The login page will then grab the fragment and convert it into a "spaRoute"
// URL parameter for safer passage.
location(
url = "./index.cfm?redirectTo=#encodeForUrl( redirectTo )#",
addToken = false
);
}
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// The SPA (Single-Page Application) works by using client-side routing that is - in
// this example - driven by the URL fragment (ie, not using HTML5-mode routing).
// Since URL fragments are notoriously hard to pass-around (since they are never sent
// to the server), we'll send a URL-based "spaRoute" to be passed-around instead
// (which will get sent to the server). If this parameter exists, we're going to
// convert it to the current SPA fragment as part of the initial SPA rendering.
if ( url.keyExists( "spaRoute" ) ) {
// Pull the spaRoute out of the current URL.
nextLocation = new DynamicUrl( cgi.script_name )
.addUrlParams( url )
.deleteUrlParam( "spaRoute" )
// Move the spaRoute parameter into the next URL fragment.
.addFragment( url.spaRoute )
.toUrl()
;
location( url = nextLocation, addToken = false );
}
</cfscript>
<cfoutput>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>
Main SPA (Single-Page App) Experience
</title>
</head>
<body>
<h1>
Main SPA (Single-Page App) Experience
</h1>
<p>
Your current route:
<code class="route" style="background: yellow ;"></code>
</p>
<p>
<a href="./logout.cfm">Logout</a>
</p>
<script type="text/javascript">
// Output the current spa-route fragment in the view.
document
.querySelector( ".route" )
.textContent = window.location.hash
;
</script>
</body>
</html>
</cfoutput>
Now, to test the redirect handling, I've created an "ingress" page that provides two styles of links: one with an embedded Hash (such as one that might be bookmarked or copy-pasted); and, one with a spaRoute
query-string parameter. In both cases, we're attempting to deep-link to the /path/to/thing
view within the Single-Page Application:
<cfscript>
// NOTE: In order to test the URL redirection, I want to make sure that our target
// URL contains nested encoding. This way, I can see if our URL management is being
// overly clinical in its sanitization practices (and not allowing what would
// otherwise be a totally valid target URL).
banner = encodeForUrl( "S&P" );
</cfscript>
<cfoutput>
<h1>
Ways A URL Can Be Sent
</h1>
<p>
Copy-Pasted From Co-Worker (using URL fragment):
<a href="./spa.cfm?showBanner=#banner###/path/to/thing">Goto the Thing</a> →
</p>
<p>
Transactional Email (using spaRoute URL parameter):
<a href="./spa.cfm?showBanner=#banner#&spaRoute=/path/to/thing">Goto the Thing</a> →
</p>
</cfoutput>
Now, if we click-through both of these deep-links, we get the following experience:
As you can see, in both cases - whether we had a URL fragment or a spaRoute
query-string parameter - we are able to maintain the deep-linking through the authentication workflow.
In both of the ColdFusion files above, I used a ColdFusion component - DynamicUrl.cfc
- to help build and manipulate URLs. This ColdFusion component is rather straightforward in that it just maintains the three aspects of the URL (resource, query-string, and fragment); and then collapses those values down into a single string:
component
output = false
hint = "I provide methods for building-up and then serializing a dynamic URL."
{
/**
* I initialize the dynamic URL with the given path and query-string.
*/
public void function init(
string scriptName = "",
string queryString = ""
) {
variables.scriptName = arguments.scriptName;
variables.urlParams = parseQueryString( queryString );
variables.fragment = "";
}
// ---
// PUBLIC METHODS.
// ---
/**
* I add the given fragment to the dynamic URL.
*/
public any function addFragment( required string fragment ) {
variables.fragment = arguments.fragment;
return( this );
}
/**
* I add the given URL parameter to the dynamic URL.
*/
public any function addUrlParam(
required string name,
required string value
) {
urlParams[ name ] = value;
return( this );
}
/**
* I add the given URL parameter to the dynamic URL if the parameter is not-empty.
*/
public any function addUrlParamIfPopulated(
required string name,
required string value
) {
if ( value.len() ) {
urlParams[ name ] = value;
}
return( this );
}
/**
* I add the given URL parameters struct to the dynamic URL.
*/
public any function addUrlParams( required struct newParams ) {
urlParams.append( newParams );
return( this );
}
/**
* I remove the given URL parameter from the dynamic URL.
*/
public any function deleteUrlParam( required string name ) {
urlParams.delete( name );
return( this );
}
/**
* I parse the given URL string and use it construct the dynamic URL components.
*/
public any function parseUrl( required string newUrl ) {
var baseUrl = newUrl.listFirst( "##" );
variables.fragment = newUrl.listRest( "##" );
variables.scriptName = baseUrl.listFirst( "?" );
variables.urlParams = parseQueryString( baseUrl.listRest( "?" ) );
return( this );
}
/**
* I serialize the dynamic URL into a composite string.
*/
public string function toUrl() {
var queryStringPairs = [];
loop
key = "local.key"
value = "local.value"
struct = urlParams
{
queryStringPairs.append( encodeForUrl( key ) & "=" & encodeForUrl( value ) );
}
var baseUrl = scriptName;
if ( queryStringPairs.len() ) {
baseUrl &= ( "?" & queryStringPairs.toList( "&" ) );
}
if ( fragment.len() ) {
baseUrl &= ( "##" & fragment );
}
return( baseUrl );
}
// ---
// PRIVATE METHODS.
// ---
/**
* I parse the given query-string value into a collection of key-value pairs.
*/
private struct function parseQueryString( required string queryString ) {
var params = [:];
if ( ! queryString.len() ) {
return( params );
}
for ( var pair in queryString.listToArray( "&" ) ) {
var encodedKey = pair.listFirst( "=" );
var encodedValue = pair.listRest( "=" );
var decodedKey = canonicalize( encodedKey, true, true );
// CAUTION: We need to be more relaxed with the VALUE of the URL parameter
// since it may contain nested encodings, especially if the parameter is
// pointing to another full URL (that may also contain encoded values).
var decodedValue = urlDecode( encodedValue );
params[ decodedKey ] = decodedValue;
}
return( params );
}
}
Much of the complexity that we encounter here is due to the fact that our older SPAs use "Hash mode" routing for historical reasons. If you use "HTML5 mode" routing in your client-side applications, things do get easier. However, even with HTML5 mode routing, you still have to worry about maintaining URLs across the authentication workflow. As such, some of this post should apply to everyone.
Epilogue on Security and Redirects
For the sake of simplicity, I'm inherently trusting the redirects in this demo. However, in a production setting, you may have to take further precautions before you apply a URL-provided redirect. A malicious actor could create a URL that would redirect an unsuspecting user to a malicious site; or, even an internal page that produces a malicious or destructive action.
Want to use code from this post? Check out the license.
Reader Comments