I have a backbone application where I am using dualStorage and also implementing my own sync for Backbone. I have implemented my own sync because in my API it requires an authentication token be sent in the headers of every request. If this authentication token is not present or not valid then the API returns a 401 error.
My application has two tabs and when you click either or it switches the route from /#guestlist to /#ticketlist and vice versa. This issue ONLY happens when switching tabs and not when going to ANY other routes in the application. This is what is striking me very odd about this issue, only these two requests fail and ALL sync operations should be overridden.
The issue I am having only seems to exist in Safari and is not present in Chrome or Firefox, however since this will be run mostly on an iPad I cannot just ignore the issue.
Here is the problem at hand
1.) Login to the system, everything works great UI is populated from API data 2.) Click Ticket List tab and the system logs you out. This is because the API returns a 401 as the Authentication token is not present in the request (again only in safari)
[Error] Failed to load resource: the server responded with a status of 401 (Unauthorized) (ticketlist, line 0)
Below is my code for the Backbone.sync.
/*
* Store a version of Backbone.sync to call from the
* modified version we create
*/
var _nativeSync = Backbone.sync;
Backbone.sync = function (method, model, options) {
/*
* The jQuery `ajax` method includes a 'headers' option
* which lets you set any headers you like
*/
if(CheckinApp.getSession().isAuthenticated() !== false) {
/*
* Set the 'Authorization' header and get the access
* token from the `auth` module
*/
options.headers = {
'Authorization': 'Token ' + CheckinApp.getSession().getAuthorizationToken()
}
}
/*
* Call the stored original Backbone.sync method with
* extra headers argument added
*/
_nativeSync(method, model, options);
};
My only concern is that maybe this is getting a conflict from the use of dualStorage as I know that also overrides the Backbone.sync method. In order to get this to work I had to include my sync AFTER the dualStorage as can be seen below.
<script type="text/javascript" src="js/vendor/backbone.dualstorage.min.js"></script>
<script type="text/javascript" src="js/plugins/backbone.sync.js"></script>
I have also dumped the headers for the request on the API side and can see that the Authorization Token is missing for this particular request when being made from Safari but not in the same request made by chrome or firefox.
[Tue Apr 07 14:49:08.677473 2015] [:error] [pid 16743] [client 71.181.125.154:64016] <pre>Array\n(\n
[Host] => jcrawford.heytix.com\n
[User-Agent] => Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) AppleWebKit/537.78.2 (KHTML, like Gecko) Version/6.1.6 Safari/537.78.2\n
[Accept] => application/json, text/javascript, */*; q=0.01\n [
Referer] => http://jcrawford.heytix.com/guestlist/\n
[X-Requested-With] => XMLHttpRequest\n
[Authorization] => Token 951ba59c833a80e4ddaf72ee6b3d9143\n
[Accept-Language] => en-us\n [Accept-Encoding] => gzip, deflate\n
[Cookie] => 'removed from output'
[Connection] => keep-alive\n)\n</pre>, referer: http://jcrawford.heytix.com/guestlist/
[Tue Apr 07 14:49:12.027279 2015] [:error] [pid 16743] [client 71.181.125.154:64016] <pre>Array\n(\n
[Host] => jcrawford.heytix.com\n
[User-Agent] => Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) AppleWebKit/537.78.2 (KHTML, like Gecko) Version/6.1.6 Safari/537.78.2\n
[Accept] => application/json, text/javascript, */*; q=0.01\n
[Referer] => http://jcrawford.heytix.com/guestlist/\n
[Accept-Encoding] => gzip, deflate\n
[X-Requested-With] => XMLHttpRequest\n
[Accept-Language] => en-us\n
[Cookie] => 'removed from output'
[Connection] => keep-alive\n)\n</pre>, referer: http://jcrawford.heytix.com/guestlist/
[Tue Apr 07 14:49:12.027565 2015] [:error] [pid 16743] [client 71.181.125.154:64016] HTTP 401 (GET /api/events/13044/guestlist), referer: http://jcrawford.heytix.com/guestlist/
And this is what I get with chrome and or firefox.
[Tue Apr 07 14:57:38.686859 2015] [:error] [pid 17630] [client 71.181.125.154:65109] <pre>Array\n(
[Host] => jcrawford.heytix.com
[Connection] => keep-alive
[Cache-Control] => max-age=0
[Accept] => application/json, text/javascript, */*; q=0.01
[X-Requested-With] => XMLHttpRequest
[User-Agent] => Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.104 Safari/537.36
[Authorization] => Token 951ba59c833a80e4ddaf72ee6b3d9143
[Referer] => http://jcrawford.heytix.com/guestlist/
[Accept-Encoding] => gzip, deflate, sdch
[Accept-Language] => en-US,en;q=0.8
[Cookie] => 'removed from output'
)</pre>, referer: http://jcrawford.heytix.com/guestlist/
[Tue Apr 07 14:57:44.001465 2015] [:error] [pid 17492] [client 71.181.125.154:65106] <pre>Array\n(\n
[Host] => jcrawford.heytix.com\n
[Connection] => keep-alive\n
[Accept] => application/json, text/javascript, */*; q=0.01\n
[X-Requested-With] => XMLHttpRequest\n
[User-Agent] => Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.104 Safari/537.36\n
[Authorization] => Token 951ba59c833a80e4ddaf72ee6b3d9143\n
[Referer] => http://jcrawford.heytix.com/guestlist/\n
[Accept-Encoding] => gzip, deflate, sdch\n
[Accept-Language] => en-US,en;q=0.8\n
[Cookie] => 'removed from output'
)\n</pre>, referer: http://jcrawford.heytix.com/guestlist/
As you can see from the logging above the Authorization token is passed through with Firefox/Chrome BUT NOT with Safari. I have added logging to the sync method and it states in the console that the user is Authenticated prior to querying the API and then redirecting to the login page.
[Log] sync called, isAuthenticated: true (backbone.sync.js, line 12)
[Error] Failed to load resource: the server responded with a status of 401 (Unauthorized) (ticketlist, line 0)
I am not seeing any other errors or anything in the Safari developer tools/console, etc. The App makes the request to the API, gets a 401 (as expected without a token) and then logs the user out of the Backbone App and redirects to the login page. This behavior is expected when no token is present the question is why would this not be passing the token through for these particular routes? It is ONLY these routes that is causing the issue, all other routes seem to work just fine in the UI.
I am also going to provide my router so that you can see what is going on, keep in mind I am using events to do the actual routing for the most part so if you need to see any other code please let me know.
CheckinApp.Routers.Default = Backbone.Router.extend({
view: null,
public_routes: ['login'],
routes:{
"":"eventlist",
"login": "login",
"guestlist": "guestlist",
"ticketlist":"ticketlist",
"managerslist":"managerslist",
"events": "eventlist",
"organize(/)(:action)": "displayOrganize",
"eventreport(/)(:event_id)": "eventreport",
"venuereport(/)(:venue_name)": "venuereport"
},
initialize:function (options) {
this.view = options.view;
Backbone.history.start();
},
guestlist:function () {
CheckinApp.getVent().trigger('main:renderListView', {title: 'Guest List', type: 'ticket', tab_hash: '#guestlist'});
},
ticketlist:function () {
CheckinApp.getVent().trigger('main:renderListView', {title: 'Ticket List', type: 'ticket', tab_hash: '#ticketlist'});
},
managerslist:function () {
CheckinApp.getVent().trigger('main:renderListView', {title: 'Managers List', type: 'ticket', tab_hash: '#managerslist'});
},
eventlist: function() {
var vent = CheckinApp.getVent();
vent.trigger('main:renderListView', {title: 'Todays Events', type: 'event'});
vent.trigger('tabs:remove');
},
eventreport: function() {
var collection = new CheckinApp.Collections.EventReport({"event_id": 13044});
var view = new CheckinApp.Views.EventReport({collection: collection});
view.render();
},
venuereport: function(venue_name) {
var collection = new CheckinApp.Collections.VenueReport([], {"venue_name": 'borgata'});
var view = new CheckinApp.Views.VenueReport({collection: collection});
var modal = new Backbone.BootstrapModal({
content: view,
title: ' ',
animate: true
});
modal.open();
//view.render();
},
login: function() {
var view = new CheckinApp.Views.Login({});
view.render();
},
before: function (route, params) {
if($.cookie('CheckinApp') && CheckinApp.getSession().isAuthenticated() == false) {
CheckinApp.setSessionFromCookie(JSON.parse($.cookie('CheckinApp')));
}
var hasAccess = CheckinApp.getSession().isAuthenticated(); // If cookie exists they are logged in..
if (!hasAccess) {
this.navigate('login', true);
} else {
if(route == 'login') {
this.navigate('', true);
return false;
}
}
if((_.contains(this.public_routes, route) === false)) {
return hasAccess; //return true if you want to proceed to routes else return false
}
},
after: function(route, params) {
if(route == 'logout') return false;
else {
CheckinApp.updateCookie();
return true;
}
}
});
Finally here is the code that tells jQuery to listen for a 401 and logout the user if it occurs.
$.ajaxSetup({
statusCode: {
401: function () {
CheckinApp.clearSession();
Backbone.history.navigate('#login', true);
}
}
});
I also took this one step further and added a bunch of console.log statements to the before route and came up with this. It would appear that maybe something is being done with these particular routes causing the authentication to be lost?
[Log] sync : isAuthenticated = true (backbone.sync.js, line 12)
[Log] sync: url = http://jcrawford.heytix.com/guestlist/checkin/api/events/13044/guestlist/ (backbone.sync.js, line 13)
[Error] Failed to load resource: the server responded with a status of 401 (Unauthorized) (guestlist, line 0)
[Log] before : isAuthenticated: false (default.js, line 60)
[Log] cookie: undefined (default.js, line 61)
[Log] before : hasAccess = false (default.js, line 66)
[Log] before : hasAccess = false, going to login page (default.js, line 68)
[Log] before : going to route login (default.js, line 76)
As you can see just before the line that makes the AJAX request for the data it says Authenticated then the request fails and it says not authenticated.
Here is a bit more logging this time from my sync method
sync : isAuthenticated = true (backbone.sync.js, line 12)
sync: url = http://jcrawford.heytix.com/guestlist/checkin/api/events/13044/guestlist/ (backbone.sync.js, line 13)
User is Authenticated (backbone.sync.js, line 15)
options: {"parse":true,"headers":{"Authorization":"Token 951ba59c833a80e4ddaf72ee6b3d9143"}} (backbone.sync.js, line 24)
As you can see above the options are being set on the header but for some reason when using Safari Backbone is NOT sending these headers with the sync request.
Per a few suggestions (and the one below) I have attempted to modify my $.ajaxSetup for jQuery but I receive the exact same results as I am currently encountering.
$.ajaxSetup({
headers: function() {
var token = '';
if(CheckinApp) {
var session = CheckinApp.getSession();
if(session) {
token = CheckinApp.getSession().getAuthorizationToken();
}
}
return {
"Authorization": "Token " + token
};
},
statusCode: {
401: function () {
CheckinApp.clearSession();
Backbone.history.navigate('#login', true);
}
}
});
Any assistance would be greatly appreciated.
This issue was happening SOLELY because my Tickets Collection URL contained a trailing /
With the trailing / in place Safari would send a pre-flight request to the URL with the trailing slash and receive a 302 Found. It would then make the request to the URI WITHOUT the trailing slash and receive the 401 as for the second request not Authorization token was passed to the API.
I am not quite sure if this is an issue with Backbone or jQuery but whichever one it is seems to not like trailing slashes as removing it solved the Safari issue.