UNPKG

j-toker

Version:

Simple, secure token authentication for jQuery.

1,360 lines (1,055 loc) 35.8 kB
/* * * * * Copyright (c) 2015 Lynn Dylan Hurley * Licensed under the WTFPL license. */ (function (factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define([ 'jquery', 'jquery-deparam', 'pubsub-js', 'jquery.cookie' ], factory); } else if (typeof exports === 'object') { // Node/CommonJS module.exports = factory( require('jquery'), require('jquery-deparam'), require('pubsub-js'), require('jquery.cookie') ); } else { // Browser globals factory(window.jQuery, window.deparam, window.PubSub); } }(function ($, deparam, PubSub) { var root = Function('return this')(); // jshint ignore:line // singleton baby if (root.auth) { return root.auth; } // use for IE detection var nav = root.navigator; // cookie/localStorage value keys var INITIAL_CONFIG_KEY = 'default', SAVED_CONFIG_KEY = 'currentConfigName', SAVED_CREDS_KEY = 'authHeaders', FIRST_TIME_LOGIN = 'firstTimeLogin', MUST_RESET_PASSWORD = 'mustResetPassword'; // broadcast message event name constants (use constants to avoid typos) var VALIDATION_SUCCESS = 'auth.validation.success', VALIDATION_ERROR = 'auth.validation.error', EMAIL_REGISTRATION_SUCCESS = 'auth.emailRegistration.success', EMAIL_REGISTRATION_ERROR = 'auth.emailRegistration.error', PASSWORD_RESET_REQUEST_SUCCESS = 'auth.passwordResetRequest.success', PASSWORD_RESET_REQUEST_ERROR = 'auth.passwordResetRequest.error', EMAIL_CONFIRMATION_SUCCESS = 'auth.emailConfirmation.success', EMAIL_CONFIRMATION_ERROR = 'auth.emailConfirmation.error', PASSWORD_RESET_CONFIRM_SUCCESS = 'auth.passwordResetConfirm.success', PASSWORD_RESET_CONFIRM_ERROR = 'auth.passwordResetConfirm.error', EMAIL_SIGN_IN_SUCCESS = 'auth.emailSignIn.success', EMAIL_SIGN_IN_ERROR = 'auth.emailSignIn.error', OAUTH_SIGN_IN_SUCCESS = 'auth.oAuthSignIn.success', OAUTH_SIGN_IN_ERROR = 'auth.oAuthSignIn.error', SIGN_IN_SUCCESS = 'auth.signIn.success', SIGN_IN_ERROR = 'auth.signIn.error', SIGN_OUT_SUCCESS = 'auth.signOut.success', SIGN_OUT_ERROR = 'auth.signOut.error', ACCOUNT_UPDATE_SUCCESS = 'auth.accountUpdate.success', ACCOUNT_UPDATE_ERROR = 'auth.accountUpdate.error', DESTROY_ACCOUNT_SUCCESS = 'auth.destroyAccount.success', DESTROY_ACCOUNT_ERROR = 'auth.destroyAccount.error', PASSWORD_UPDATE_SUCCESS = 'auth.passwordUpdate.success', PASSWORD_UPDATE_ERROR = 'auth.passwordUpdate.error'; var Auth = function () { // set flag so we know when plugin has been configured. this.configured = false; // create promise for configuration + verification this.configDfd = null; // configs hash allows for multiple configurations this.configs = {}; // default config will be first named config or "default" this.defaultConfigKey = null; // flagged when users return from email confirmation this.firstTimeLogin = false; // flagged when users return from password change confirmation this.mustResetPassword = false; // save reference to user this.user = {}; // oAuth promise is kept while visiting provider this.oAuthDfd = null; // timer is used to poll external auth window while authenticating via OAuth this.oAuthTimer = null; // base config from which other configs are extended this.configBase = { apiUrl: '/api', signOutPath: '/auth/sign_out', emailSignInPath: '/auth/sign_in', emailRegistrationPath: '/auth', accountUpdatePath: '/auth', accountDeletePath: '/auth', passwordResetPath: '/auth/password', passwordUpdatePath: '/auth/password', tokenValidationPath: '/auth/validate_token', proxyIf: function() { return false; }, proxyUrl: '/proxy', forceHardRedirect: false, storage: 'cookies', cookieExpiry: 14, cookiePath: '/', initialCredentials: null, passwordResetSuccessUrl: function() { return root.location.href; }, confirmationSuccessUrl: function() { return root.location.href; }, tokenFormat: { "access-token": "{{ access-token }}", "token-type": "Bearer", client: "{{ client }}", expiry: "{{ expiry }}", uid: "{{ uid }}" }, parseExpiry: function(headers){ // convert from ruby time (seconds) to js time (millis) return (parseInt(headers['expiry'], 10) * 1000) || null; }, handleLoginResponse: function(resp) { return resp.data; }, handleAccountUpdateResponse: function(resp) { return resp.data; }, handleTokenValidationResponse: function(resp) { return resp.data; }, authProviderPaths: { github: '/auth/github', facebook: '/auth/facebook', google: '/auth/google_oauth2' } }; }; // mostly for testing. reset all config values Auth.prototype.reset = function() { // clean up session without relying on `getConfig` this.destroySession(); this.configs = {}; this.defaultConfigKey = null; this.configured = false; this.configDfd = null; this.mustResetPassword = false; this.firstTimeLogin = false; this.oAuthDfd = null; this.willRedirect = false; if (this.oAuthTimer) { clearTimeout(this.oAuthTimer); this.oAuthTimer = null; } // clear user object for (var key in this.user) { delete this.user[key]; } // remove event listeners $(document).unbind('ajaxComplete', this.updateAuthCredentials); if (root.removeEventListener) { root.removeEventListener('message', this.handlePostMessage); } // remove global ajax "interceptors" $.ajaxSetup({beforeSend: undefined}); }; Auth.prototype.invalidateTokens = function() { // clear user object, but don't destroy object in case of bindings for (var key in this.user) { delete this.user[key]; } // clear auth session data this.deleteData(SAVED_CONFIG_KEY); this.deleteData(SAVED_CREDS_KEY); }; // throw clear errors when dependencies are not met Auth.prototype.checkDependencies = function() { var errors = [], warnings = []; if (!$) { throw 'jToker: jQuery not found. This module depends on jQuery.'; } if (!root.localStorage && !$.cookie) { errors.push( 'This browser does not support localStorage. You must install '+ 'jquery-cookie to use jToker with this browser.' ); } if (!deparam) { errors.push('Dependency not met: jquery-deparam.'); } if (!PubSub) { warnings.push( 'jquery.ba-tinypubsub.js not found. No auth events will be broadcast.' ); } if (errors.length) { var errMessage = errors.join(' '); throw 'jToker: Please resolve the following errors: ' + errMessage; } if (warnings.length && console && console.warn) { var warnMessage = warnings.join(' '); console.warn('jToker: Warning: ' + warnMessage); } }; // need a way to destroy the current session without relying on `getConfig`. // otherwise we get into infinite loop territory. Auth.prototype.destroySession = function() { var sessionKeys = [ SAVED_CREDS_KEY, SAVED_CONFIG_KEY ]; for (var key in sessionKeys) { key = sessionKeys[key]; // kill all local storage keys if (root.localStorage) { root.localStorage.removeItem(key); } if ($.cookie) { // each config may have different cookiePath settings for (var config in this.configs) { var cookiePath = this.configs[config].cookiePath; $.removeCookie(key, { path: cookiePath }); } // remove from base path in case config is not specified $.removeCookie(key, { path: "/" }); } } }; Auth.prototype.configure = function(opts, reset) { // destroy all session data. useful for testing if (reset) { this.reset(); } if (this.configured) { return this.configDfd; } // set flag so configure isn't called again (unless reset) this.configured = true; // normalize opts into object object if (!opts) { opts = {}; } // normalize so opts is always an array of objects if (opts.constructor !== Array) { // single config will always be called 'default' unless set // by previous session this.defaultConfigKey = INITIAL_CONFIG_KEY; // config should look like {default: {...}} var defaultConfig = {}; defaultConfig[this.defaultConfigKey] = opts; // opts should look like [{default: {...}}] opts = [defaultConfig]; } // iterate over config items, extend each from defaults for (var i = 0; i < opts.length; i++) { var configName = getFirstObjectKey(opts[i]); // set first set as default config if (!this.defaultConfigKey) { this.defaultConfigKey = configName; } // save config to `configs` hash this.configs[configName] = $.extend( {}, this.configBase, opts[i][configName] ); } // ensure that setup requirements have been met this.checkDependencies(); // TODO: add config option for these bindings if (true) { // update auth creds after each request to the API $(document).ajaxComplete(root.auth.updateAuthCredentials); // intercept requests to the API, append auth headers $.ajaxSetup({beforeSend: root.auth.appendAuthHeaders}); } // IE8 won't have this feature if (root.addEventListener) { root.addEventListener("message", this.handlePostMessage, false); } // pull creds from search bar if available this.processSearchParams(); // don't validate the token if we're just going to redirect anyway. // otherwise the page won't have time to process the response header and // the token may expire before the redirected page can validate. if (this.willRedirect) { return false; } // don't validate with the server if the credentials were provided. this is // a case where the validation happened on the server and is being used to // initialize the client. else if (this.getConfig().initialCredentials) { // skip initial headers check (i.e. check was already done server-side) var c = this.getConfig(); this.persistData(SAVED_CREDS_KEY, c.initialCredentials.headers); this.persistData(MUST_RESET_PASSWORD, c.initialCredentials.mustResetPassword); this.persistData(FIRST_TIME_LOGIN, c.initialCredentials.firstTimeLogin); this.setCurrentUser(c.initialCredentials.user); return new $.Deferred().resolve(c.initialCredentials.user); } // otherwise check with server if any existing tokens are found else { // validate token if set this.configDfd = this.validateToken({config: this.getCurrentConfigName()}); return this.configDfd; } }; Auth.prototype.getApiUrl = function() { var config = this.getConfig(); return (config.proxyIf()) ? config.proxyUrl : config.apiUrl; }; // interpolate values of tokenFormat hash with ctx, return new hash Auth.prototype.buildAuthHeaders = function(ctx) { var headers = {}, fmt = this.getConfig().tokenFormat; for (var key in fmt) { headers[key] = tmpl(fmt[key], ctx); } return headers; }; Auth.prototype.setCurrentUser = function(user) { // clear user object of any existing attributes for (var key in this.user) { delete this.user[key]; } // save user data, preserve bindings to original user object $.extend(this.user, user); this.user.signedIn = true; this.user.configName = this.getCurrentConfigName(); return this.user; }; Auth.prototype.handlePostMessage = function(ev) { var stopListening = false; if (ev.data.message === 'deliverCredentials') { delete ev.data.message; var initialHeaders = root.auth.normalizeTokenKeys(ev.data), authHeaders = root.auth.buildAuthHeaders(initialHeaders), user = root.auth.setCurrentUser(ev.data); root.auth.persistData(SAVED_CREDS_KEY, authHeaders); root.auth.resolvePromise(OAUTH_SIGN_IN_SUCCESS, root.auth.oAuthDfd, user); root.auth.broadcastEvent(SIGN_IN_SUCCESS, user); root.auth.broadcastEvent(VALIDATION_SUCCESS, user); stopListening = true; } if (ev.data.message === 'authFailure') { root.auth.rejectPromise( OAUTH_SIGN_IN_ERROR, root.auth.oAuthDfd, ev.data, 'OAuth authentication failed.' ); root.auth.broadcastEvent(SIGN_IN_ERROR, ev.data); stopListening = true; } if (stopListening) { clearTimeout(root.auth.oAuthTimer); root.auth.oAuthTimer = null; } }; // compensate for poor naming decisions made early on // TODO: fix API so this isn't necessary Auth.prototype.normalizeTokenKeys = function(params) { // normalize keys if (params.token) { params['access-token'] = params.token; delete params.token; } if (params.auth_token) { params['access-token'] = params.auth_token; delete params.auth_token; } if (params.client_id) { params.client = params.client_id; delete params.client_id; } if (params.config) { this.persistData( SAVED_CONFIG_KEY, params.config, params.config ); delete params.config; } return params; }; Auth.prototype.processSearchParams = function() { var searchParams = this.getQs(), newHeaders = null; searchParams = this.normalizeTokenKeys(searchParams); // only bother with this if minimum search params are present if (searchParams['access-token'] && searchParams.uid) { newHeaders = this.buildAuthHeaders(searchParams); // save all token headers to session this.persistData(SAVED_CREDS_KEY, newHeaders); // check if user is returning from password reset link if (searchParams.reset_password) { this.persistData(MUST_RESET_PASSWORD, true); } // check if user is returning from confirmation email if (searchParams.account_confirmation_success) { this.persistData(FIRST_TIME_LOGIN, true); } // TODO: set uri flag on devise_token_auth for OAuth confirmation // when using hard page redirects. // set qs without auth keys/values var newLocation = this.getLocationWithoutParams([ 'access-token', 'token', 'auth_token', 'config', 'client', 'client_id', 'expiry', 'uid', 'reset_password', 'account_confirmation_success' ]); this.willRedirect = true; this.setLocation(newLocation); } return newHeaders; }; // this method is tricky. we want to reconstruct the current URL with the // following conditions: // 1. search contains none of the supplied keys // 2. anchor search (i.e. `#/?key=val`) contains none of the supplied keys // 3. all of the keys NOT supplied are presevered in their original form // 4. url protocol, host, and path are preserved Auth.prototype.getLocationWithoutParams = function(keys) { // strip all values from both actual and anchor search params var newSearch = $.param(this.stripKeys(this.getSearchQs(), keys)), newAnchorQs = $.param(this.stripKeys(this.getAnchorQs(), keys)), newAnchor = root.location.hash.split('?')[0]; if (newSearch) { newSearch = "?" + newSearch; } if (newAnchorQs) { newAnchor += "?" + newAnchorQs; } if (newAnchor && !newAnchor.match(/^#/)) { newAnchor = "#/" + newAnchor; } // reconstruct location with stripped auth keys var newLocation = root.location.protocol + '//'+ root.location.host+ root.location.pathname+ newSearch+ newAnchor; return newLocation; }; Auth.prototype.stripKeys = function(obj, keys) { for (var q in keys) { delete obj[keys[q]]; } return obj; }; // abstract publish method, only use if pubsub exists. // TODO: allow broadcast method to be configured Auth.prototype.broadcastEvent = function(msg, data) { if (PubSub.publish) { PubSub.publish(msg, data); } }; // always resolve after 0 timeout to ensure that ajaxComplete callback // has run before promise is resolved Auth.prototype.resolvePromise = function(evMsg, dfd, data) { var self = this, finished = $.Deferred(); setTimeout(function() { self.broadcastEvent(evMsg, data); dfd.resolve(data); finished.resolve(); }, 0); return finished.promise(); }; Auth.prototype.rejectPromise = function(evMsg, dfd, data, reason) { var self = this; // jQuery has a strange way of returning error responses... data = $.parseJSON((data && data.responseText) || '{}'); // always reject after 0 timeout to ensure that ajaxComplete callback // has run before promise is rejected setTimeout(function() { self.broadcastEvent(evMsg, data); dfd.reject({ reason: reason, data: data }); }, 0); return dfd; }; // TODO: document Auth.prototype.validateToken = function(opts) { if (!opts) { opts = {}; } if (!opts.config) { opts.config = this.getCurrentConfigName(); } // if this check is already in progress, return existing promise if (this.configDfd) { return this.configDfd; } var dfd = $.Deferred(); // no creds, reject promise without making API call if (!this.retrieveData(SAVED_CREDS_KEY)) { // clear any saved session data this.invalidateTokens(); // reject promise, broadcast event this.rejectPromise( VALIDATION_ERROR, dfd, {}, 'Cannot validate token; no token found.' ); } else { var config = this.getConfig(opts.config), url = this.getApiUrl() + config.tokenValidationPath; // found saved creds, verify with API $.ajax({ url: url, context: this, success: function(resp) { var user = config.handleTokenValidationResponse(resp); this.setCurrentUser(user); if (this.retrieveData(FIRST_TIME_LOGIN)) { this.broadcastEvent(EMAIL_CONFIRMATION_SUCCESS, resp); this.persistData(FIRST_TIME_LOGIN, false); this.firstTimeLogin = true; } if (this.retrieveData(MUST_RESET_PASSWORD)) { this.broadcastEvent(PASSWORD_RESET_CONFIRM_SUCCESS, resp); this.persistData(MUST_RESET_PASSWORD, false); this.mustResetPassword = true; } this.resolvePromise(VALIDATION_SUCCESS, dfd, this.user); }, error: function(resp) { // clear any saved session data this.invalidateTokens(); if (this.retrieveData(FIRST_TIME_LOGIN)) { this.broadcastEvent(EMAIL_CONFIRMATION_ERROR, resp); this.persistData(FIRST_TIME_LOGIN, false); } if (this.retrieveData(MUST_RESET_PASSWORD)) { this.broadcastEvent(PASSWORD_RESET_CONFIRM_ERROR, resp); this.persistData(MUST_RESET_PASSWORD, false); } this.rejectPromise( VALIDATION_ERROR, dfd, resp, 'Cannot validate token; token rejected by server.' ); } }); } return dfd.promise(); }; // TODO: document Auth.prototype.emailSignUp = function(opts) { // normalize opts if (!opts) { opts = {}; } var config = this.getConfig(opts.config), url = this.getApiUrl() + config.emailRegistrationPath, dfd = $.Deferred(); opts.config_name = opts.config; delete opts.config; opts.confirm_success_url = config.confirmationSuccessUrl(); $.ajax({ url: url, context: this, method: 'POST', data: opts, success: function(resp) { this.resolvePromise(EMAIL_REGISTRATION_SUCCESS, dfd, resp); }, error: function(resp) { this.rejectPromise( EMAIL_REGISTRATION_ERROR, dfd, resp, 'Failed to submit email registration.' ); } }); return dfd.promise(); }; Auth.prototype.emailSignIn = function(opts) { // normalize opts if (!opts) { opts = {}; } var config = this.getConfig(opts.config), url = this.getApiUrl() + config.emailSignInPath, dfd = $.Deferred(); // don't send config name to API delete opts.config; $.ajax({ url: url, context: this, method: 'POST', data: opts, success: function(resp) { // return user attrs as directed by config var user = config.handleLoginResponse(resp); // save user data, preserve bindings to original user object this.setCurrentUser(user); this.resolvePromise(EMAIL_SIGN_IN_SUCCESS, dfd, resp); this.broadcastEvent(SIGN_IN_SUCCESS, user); this.broadcastEvent(VALIDATION_SUCCESS, this.user); }, error: function(resp) { this.rejectPromise( EMAIL_SIGN_IN_ERROR, dfd, resp, 'Invalid credentials.' ); this.broadcastEvent(SIGN_IN_ERROR, resp); } }); return dfd.promise(); }; // ping auth window to see if user has completed authentication. // this method will be recursively called until: // 1. user completes authentication // 2. user fails authentication // 3. auth window is closed Auth.prototype.listenForCredentials = function(popup) { var self = this; if (popup.closed) { self.rejectPromise( OAUTH_SIGN_IN_ERROR, self.oAuthDfd, null, 'OAuth window was closed bofore registration was completed.' ); } else { popup.postMessage('requestCredentials', '*'); self.oAuthTimer = setTimeout(function() { self.listenForCredentials(popup); }, 500); } }; Auth.prototype.openAuthWindow = function(url) { if (this.getConfig().forceHardRedirect || root.isIE()) { // redirect to external auth provider. credentials should be // provided in location search hash upon return this.setLocation(url); } else { // open popup to external auth provider var popup = this.createPopup(url); // listen for postMessage response this.listenForCredentials(popup); } }; Auth.prototype.buildOAuthUrl = function(configName, params, providerPath) { var oAuthUrl = this.getConfig().apiUrl + providerPath + '?auth_origin_url='+encodeURIComponent(root.location.href) + '&config_name='+encodeURIComponent(configName || this.getCurrentConfigName()) + "&omniauth_window_type=newWindow"; if (params) { for(var key in params) { oAuthUrl += '&'; oAuthUrl += encodeURIComponent(key); oAuthUrl += '='; oAuthUrl += encodeURIComponent(params[key]); } } return oAuthUrl; }; Auth.prototype.oAuthSignIn = function(opts) { // normalize opts if (!opts) { opts = {}; } if (!opts.provider) { throw 'jToker: provider param undefined for `oAuthSignIn` method.'; } var config = this.getConfig(opts.config), providerPath = config.authProviderPaths[opts.provider], oAuthUrl = this.buildOAuthUrl(opts.config, opts.params, providerPath); if (!providerPath) { throw 'jToker: providerPath not found for provider: '+opts.provider; } // save oAuth promise until response is received this.oAuthDfd = $.Deferred(); // open link to provider auth screen this.openAuthWindow(oAuthUrl); return this.oAuthDfd.promise(); }; Auth.prototype.signOut = function(opts) { if (!opts) { opts = {}; } var config = this.getConfig(opts.config), signOutUrl = this.getApiUrl() + config.signOutPath, dfd = $.Deferred(); $.ajax({ url: signOutUrl, context: this, method: 'DELETE', success: function(resp) { this.resolvePromise(SIGN_OUT_SUCCESS, dfd, resp); }, error: function(resp) { this.rejectPromise( SIGN_OUT_ERROR, dfd, resp, 'Failed to sign out.' ); }, complete: function() { this.invalidateTokens(); } }); return dfd.promise(); }; Auth.prototype.updateAccount = function(opts) { if (!opts) { opts = {}; } var config = this.getConfig(opts.config), url = this.getApiUrl() + config.accountUpdatePath, dfd = $.Deferred(); delete opts.config; $.ajax({ url: url, context: this, method: 'PUT', data: opts, success: function(resp) { var user = config.handleAccountUpdateResponse(resp); this.setCurrentUser(user); this.resolvePromise(ACCOUNT_UPDATE_SUCCESS, dfd, resp); }, error: function(resp) { this.rejectPromise( ACCOUNT_UPDATE_ERROR, dfd, resp, 'Failed to update user account' ); } }); return dfd.promise(); }; Auth.prototype.destroyAccount = function(opts) { if (!opts) { opts = {}; } var config = this.getConfig(opts.config), url = this.getApiUrl() + config.accountDeletePath, dfd = $.Deferred(); $.ajax({ url: url, context: this, method: 'DELETE', success: function(resp) { this.invalidateTokens(); this.resolvePromise(DESTROY_ACCOUNT_SUCCESS, dfd, resp); }, error: function(resp) { this.rejectPromise( DESTROY_ACCOUNT_ERROR, dfd, resp, 'Failed to destroy user account' ); } }); return dfd.promise(); }; // TODO: implement re-confirmable on devise_token_auth //Auth.prototype.resendConfirmation = function(email) {}; Auth.prototype.requestPasswordReset = function(opts) { // normalize opts if (!opts) { opts = {}; } if (opts.email === undefined) { throw "jToker: email param undefined for `requestPasswordReset` method."; } var config = this.getConfig(opts.config), url = this.getApiUrl() + config.passwordResetPath, dfd = $.Deferred(); opts.config_name = opts.config; delete opts.config; opts.redirect_url = config.passwordResetSuccessUrl(); $.ajax({ url: url, context: this, method: 'POST', data: opts, success: function(resp) { this.resolvePromise(PASSWORD_RESET_REQUEST_SUCCESS, dfd, resp); }, error: function(resp) { this.rejectPromise( PASSWORD_RESET_REQUEST_ERROR, dfd, resp, 'Failed to submit email registration.' ); } }); return dfd.promise(); }; Auth.prototype.updatePassword = function(opts) { if (!opts) { opts = {}; } var config = this.getConfig(opts.config), url = this.getApiUrl() + config.passwordUpdatePath, dfd = $.Deferred(); delete opts.config; $.ajax({ url: url, context: this, method: 'PUT', data: opts, success: function(resp) { this.resolvePromise(PASSWORD_UPDATE_SUCCESS, dfd, resp); }, error: function(resp) { this.rejectPromise( PASSWORD_UPDATE_ERROR, dfd, resp, 'Failed to update password.' ); } }); return dfd.promise(); }; // abstract storing of session data Auth.prototype.persistData = function(key, val, config) { val = JSON.stringify(val); switch (this.getConfig(config).storage) { case 'localStorage': root.localStorage.setItem(key, val); break; default: $.cookie(key, val, { expires: this.getConfig(config).cookieExpiry, path: this.getConfig(config).cookiePath }); break; } }; // abstract reading of session data Auth.prototype.retrieveData = function(key) { var val = null; switch (this.getConfig().storage) { case 'localStorage': val = root.localStorage.getItem(key); break; default: val = $.cookie(key); break; } // if value is a simple string, the parser will fail. in that case, simply // unescape the quotes and return the string. try { // return parsed json response return $.parseJSON(val); } catch (err) { // unescape quotes return unescapeQuotes(val); } }; // this method cannot rely on `retrieveData` because `retrieveData` relies // on `getConfig` and we need to get the config name before `getConfig` can // be called. TL;DR prevent infinite loop by checking all forms of storage // and returning the first config name found Auth.prototype.getCurrentConfigName = function() { var configName = null; if (this.getQs().config) { configName = this.getQs().config; } if ($.cookie && !configName) { configName = $.cookie(SAVED_CONFIG_KEY); } if (root.localStorage && !configName) { configName = root.localStorage.getItem(SAVED_CONFIG_KEY); } configName = configName || this.defaultConfigKey || INITIAL_CONFIG_KEY; return unescapeQuotes(configName); }; // abstract deletion of session data Auth.prototype.deleteData = function(key) { switch (this.getConfig().storage) { case 'cookies': $.removeCookie(key, { path: this.getConfig().cookiePath }); break; default: root.localStorage.removeItem(key); break; } }; // return the current config. config will take the following precedence: // 1. config by name saved in cookie / localstorage (current auth) // 2. first available configuration // 2. default config Auth.prototype.getConfig = function(key) { // configure if not configured if (!this.configured) { throw 'jToker: `configure` must be run before using this plugin.'; } // fall back to default unless config key is passed key = key || this.getCurrentConfigName(); return this.configs[key]; }; // send auth credentials with all requests to the API Auth.prototype.appendAuthHeaders = function(xhr, settings) { // fetch current auth headers from storage var currentHeaders = root.auth.retrieveData(SAVED_CREDS_KEY); // check config apiUrl matches the current request url if (isApiRequest(settings.url) && currentHeaders) { // bust IE cache xhr.setRequestHeader( 'If-Modified-Since', 'Mon, 26 Jul 1997 05:00:00 GMT' ); // set header for each key in `tokenFormat` config for (var key in root.auth.getConfig().tokenFormat) { xhr.setRequestHeader(key, currentHeaders[key]); } } }; // update auth credentials after request is made to the API Auth.prototype.updateAuthCredentials = function(ev, xhr, settings) { // check config apiUrl matches the current response url if (isApiRequest(settings.url)) { // set header for each key in `tokenFormat` config var newHeaders = {}; // set flag to ensure that we don't accidentally nuke the headers // if the response tokens aren't sent back from the API var blankHeaders = true; // set header key + val for each key in `tokenFormat` config for (var key in root.auth.getConfig().tokenFormat) { newHeaders[key] = xhr.getResponseHeader(key); if (newHeaders[key]) { blankHeaders = false; } } // persist headers for next request if (!blankHeaders) { root.auth.persistData(SAVED_CREDS_KEY, newHeaders); } } }; // stub for mock overrides Auth.prototype.getRawSearch = function() { return root.location.search; }; // stub for mock overrides Auth.prototype.getRawAnchor = function() { return root.location.hash; }; Auth.prototype.setRawAnchor = function(a) { root.location.hash = a; }; Auth.prototype.getAnchorSearch = function() { var arr = this.getRawAnchor().split('?'); return (arr.length > 1) ? arr[1] : null; }; // stub for mock overrides Auth.prototype.setRawSearch = function(s) { root.location.search = s; }; // stub for mock overrides Auth.prototype.setSearchQs = function(params) { this.setRawSearch($.param(params)); return this.getSearchQs(); }; Auth.prototype.setAnchorQs = function(params) { this.setAnchorSearch($.param(params)); return this.getAnchorQs(); }; // stub for mock overrides Auth.prototype.setLocation = function(url) { root.location.replace(url); }; // stub for mock overrides Auth.prototype.createPopup = function(url) { return root.open(url); }; Auth.prototype.getSearchQs = function() { var qs = this.getRawSearch().replace('?', ''), qsObj = (qs) ? deparam(qs) : {}; return qsObj; }; Auth.prototype.getAnchorQs = function() { var anchorQs = this.getAnchorSearch(), anchorQsObj = (anchorQs) ? deparam(anchorQs) : {}; return anchorQsObj; }; // stub for mock overrides Auth.prototype.getQs = function() { return $.extend(this.getSearchQs(), this.getAnchorQs()); }; // private util methods var getFirstObjectKey = function(obj) { for (var key in obj) { return key; } }; var unescapeQuotes = function(val) { return val && val.replace(/("|')/g, ''); }; var isApiRequest = function(url) { return (url.match(root.auth.getApiUrl())); }; // simple string templating. stolen from: // http://stackoverflow.com/questions/14879866/javascript-templating-function-replace-string-and-dont-take-care-of-whitespace var tmpl = function(str, obj) { var replacer = function(wholeMatch, key) { return obj[key] === undefined ? wholeMatch : obj[key]; }, regexp = new RegExp('{{\\s*([a-z0-9-_]+)\\s*}}',"ig"); for(var beforeReplace = ""; beforeReplace !== str; str = (beforeReplace = str).replace(regexp, replacer)){ } return str; }; // check if IE < 10 root.isOldIE = function() { var oldIE = false, ua = nav.userAgent.toLowerCase(); if (ua && ua.indexOf('msie') !== -1) { var version = parseInt(ua.split('msie')[1]); if (version < 10) { oldIE = true; } } return oldIE; }; // check if using IE root.isIE = function() { var ieLTE10 = root.isOldIE(), ie11 = !!nav.userAgent.match(/Trident.*rv\:11\./); return (ieLTE10 || ie11); }; // export service root.auth = $.auth = new Auth(); return root.auth; }));