(function (exports) { 'use strict'; var TherapySession; var Oauth3 = (exports.OAUTH3 || require('./oauth3')); // // Pure convenience / utility funcs // function createSession() { return { logins: [], accounts: [] }; } function removeItem(array, item) { var i = array.indexOf(item); if (-1 !== i) { array.splice(i, 1); } } var TLogins = {}; var TAccounts = {}; var InternalApi; var api; function create(opts) { var myInstance = {}; var conf = { session: createSession() , sessionKey: opts.namespace + '.' + opts.sessionKey // 'session' , cache: opts.cache , config: opts.config , usernameMinLength: opts.usernameMinLength , secretMinLength: opts.secretMinLength }; Object.keys(TherapySession.api).forEach(function (key) { myInstance[key] = function () { var args = Array.prototype.slice.call(arguments); args.unshift(conf); return TherapySession.api[key].apply(null, args); }; }); myInstance.getId = TherapySession.getId; myInstance.openAuthorizationDialog = function () { // TODO guarantee that this happens assignment happens before initialization? return (opts.invokeLogin || opts.config.invokeLogin).apply(null, arguments); }; myInstance.usernameMinLength = opts.usernameMinLength; myInstance.secretMinLength = opts.secretMinLength; myInstance.api = api; myInstance._conf = conf; return myInstance; } // TODO track and compare granted scopes locally function save(conf, updates) { // TODO make sure session.logins[0] is most recent api.updateSession(conf, updates.login, updates.accounts); // TODO should this be done by the LocalApiStorage? // TODO how to have different accounts selected in different tabs? localStorage.setItem(conf.sessionKey, JSON.stringify(conf.session)); return Oauth3.PromiseA.resolve(conf.session); } function restore(conf) { // Being very careful not to trigger a false onLogin or onLogout via $watch var storedSession; if (conf.session.token) { return api.sanityCheckAccounts(conf); // return Oauth3.PromiseA.resolve(conf.session); } storedSession = JSON.parse(localStorage.getItem(conf.sessionKey) || null) || createSession(); if (storedSession.token) { conf.session = storedSession; return api.sanityCheckAccounts(conf); //return Oauth3.PromiseA.resolve(conf.session); } else { return Oauth3.PromiseA.reject(new Error("No Session")); } } function destroy(conf) { conf.session = createSession(); localStorage.removeItem(conf.sessionKey); return conf.cache.destroy(conf).then(function (session) { return session; }); } function accounts(conf, login) { return Oauth3.request({ url: conf.config.apiBaseUri + conf.config.apiPrefix + '/accounts' , method: 'GET' , headers: { 'Authorization': 'Bearer ' + login.token } }).then(function (resp) { var accounts = resp.data && (resp.data.accounts || resp.data.result || resp.data.results) || resp.data || { error: { message: "Unknown Error when retrieving accounts" } } ; if (accounts.error) { console.error("[ERROR] couldn't fetch accounts", accounts); return Oauth3.PromiseA.reject(new Error("Could not verify login:" + accounts.error.message)); } if (!Array.isArray(accounts)) { console.error("[Uknown ERROR] couldn't fetch accounts, no proper error", accounts); // TODO destroy(conf); return Oauth3.PromiseA.reject(new Error("could not verify login")); // destroy(conf); } return accounts; }); } // TODO move to LocalApiLogin? function testLoginAccounts(conf, login) { // TODO cache this also, but with a shorter shelf life? return TherapySession.api.accounts(conf, login).then(function (accounts) { return { login: login, accounts: accounts }; }, function (err) { console.error("[Error] couldn't get accounts (might not be linked)"); console.warn(err); return { login: login, accounts: [] }; }); } function logout(conf) { console.log('DEBUG logout', conf); return Oauth3.logout(conf.config.providerUri, {}).then(function () { console.log('DEBUG Oauth3.logout'); return destroy(conf); }, function () { return destroy(conf); }); } function backgroundLogin(conf, opts) { opts = opts || {}; opts.background = true; return TherapySession.api.login(conf, opts); } function login(conf, opts) { console.log('##### DEBUG TherapySession'); console.log(conf); console.log(opts); // this should work first party and third party var promise; var providerUri = (opts && opts.providerUri) || conf.config.providerUri; opts = opts || {}; //opts.redirectUri = conf.config.appUri + '/oauth3.html'; // TODO note that this must be called on a click event // otherwise the browser will block the popup function forceLogin() { opts.appId = opts.appId || conf.config.appId; opts.clientUri = opts.clientUri || conf.config.clientUri; opts.clientAgreeTos = opts.clientAgreeTos || conf.config.clientAgreeTos; var username = opts.username; // TODO why is login modifying the opts? return Oauth3.login(providerUri, opts).then(function (params) { return TLogins.getLoginFromTokenParams(conf, providerUri, username, params).then(function (login) { return testLoginAccounts(conf, login).then(function (updates) { return save(conf, updates); }); }); }); } if (!opts.force) { promise = restore(conf, opts.scope); } else { promise = Oauth3.PromiseA.reject(); } // TODO check for scope in session return promise.then(function (session) { if (!session.appScopedId || opts && opts.force) { return forceLogin(); } var promise = Oauth3.PromiseA.resolve(); // TODO check expirey session.logins.forEach(function (login) { promise = promise.then(function () { return testLoginAccounts(conf, login).then(function (updates) { return save(conf, updates); }); }); }); return promise; }, forceLogin).then(function (session) { // testLoginAccounts().then(save); return session; }); } function requireSession(conf, opts) { var promise = Oauth3.PromiseA.resolve(opts); // TODO create middleware stack return promise.then(function () { return TLogins.requireLogin(conf, opts); }).then(function () { return TAccounts.requireAccount(conf, opts); }); // .then(selectAccount).then(verifyAccount) } function onLogin(conf, _scope, fn) { // This is better than using a promise.notify // because the watches will unwatch when the controller is destroyed _scope.__stsessionshared__ = conf; _scope.$watch('__stsessionshared__.session', function (newValue, oldValue) { if (newValue.accountId && oldValue.accountId !== newValue.accountId) { fn(conf.session); } }, true); } function onLogout(conf, _scope, fn) { _scope.__stsessionshared__ = conf; _scope.$watch('__stsessionshared__.session', function (newValue, oldValue) { if (!newValue.accountId && oldValue.accountId) { fn(null); } }, true); } function getToken(conf, accountId) { var session = conf.session; var logins = []; var login; accountId = TAccounts.getId(accountId) || accountId; // search logins first because we know we're actually // logged in with said login, y'know? session.logins.forEach(function (login) { login.accounts.forEach(function (account) { if (TAccounts.getId(account) === accountId) { logins.push(login); } }); }); login = logins.sort(function (a, b) { // b - a // most recent first return (new Date(b.expiresAt).value || 0) - (new Date(a.expiresAt).value || 0); })[0]; return login && login.token; } // this should be done at every login // even an existing login may gain new accounts function addAccountsToSession(conf, login, accounts) { var now = Date.now(); login.accounts = accounts.map(function (account) { account.addedAt = account.addedAt || now; return { id: TAccounts.getId(account) , addedAt: now }; }); accounts.forEach(function (newAccount) { if (!conf.session.accounts.some(function (other, i) { if (TAccounts.getId(other) === TAccounts.getId(newAccount)) { conf.session.accounts[i] = newAccount; return true; } })) { conf.session.accounts.push(newAccount); } }); conf.session.accounts.sort(function (a, b) { return b.addedAt - a.addedAt; }); } // this should be done on login and logout // an old login may have lost or gained accounts function pruneAccountsFromSession(conf) { var session = conf.session; var accounts = session.accounts.slice(0); // remember, you can't modify an array while it's in-loop // well, you can... but it would be bad! accounts.forEach(function (account) { if (!session.logins.some(function (login) { return login.accounts.some(function (a) { return TAccounts.getId(a) === TAccounts.getId(account); }); })) { removeItem(session.accounts, account); } }); } function refreshCurrentAccount(conf) { var session = conf.session; // select a default session if (1 === session.accounts.length) { session.accountId = TAccounts.getId(session.accounts[0]); session.id = session.accountId; session.appScopedId = session.accountId; session.token = session.accountId && api.getToken(conf, session.accountId) || null; session.userVerifiedAt = session.accounts[0].userVerifiedAt; return; } if (!session.logins.some(function (account) { if (session.accountId === TAccounts.getId(account)) { session.accountId = TAccounts.getId(account); session.id = session.accountId; session.appScopedId = session.accountId; session.token = session.accountId && api.getToken(conf, session.accountId) || null; session.userVerifiedAt = account.userVerifiedAt; } })) { session.accountId = null; session.id = null; session.appScopedId = null; session.token = null; session.userVerifiedAt = null; } } function updateSession(conf, login, accounts) { var session = conf.session; login.addedAt = login.addedAt || Date.now(); // sanity check login if (0 === accounts.length) { login.selectedAccountId = null; } else if (1 === accounts.length) { login.selectedAccountId = TAccounts.getId(accounts[0]); } else if (accounts.length >= 1) { login.selectedAccountId = null; } else { throw new Error("[SANITY CHECK FAILED] bad account length'"); } api.addAccountsToSession(conf, login, accounts); // update login if it exists // (or add it if it doesn't) if (!session.logins.some(function (other, i) { if ((login.loginId && other.loginId === login.loginId) || (other.token === login.token)) { session.logins[i] = login; return true; } })) { session.logins.push(login); } api.pruneAccountsFromSession(conf); api.refreshCurrentAccount(conf); session.logins.sort(function (a, b) { return b.addedAt - a.addedAt; }); } function sanityCheckAccounts(conf) { var promise; var session = conf.session; // XXX this is just a bugfix for previously deployed code // it probably only affects about 10 users and can be deleted // at some point in the future (or left as a sanity check) if (session.accounts.every(function (account) { if (account.appScopedId) { return true; } })) { return Oauth3.PromiseA.resolve(session); } promise = Oauth3.PromiseA.resolve(); session.logins.forEach(function (login) { promise = promise.then(function () { return testLoginAccounts(conf, login).then(function (updates) { return save(conf, updates); }); }); }); return promise.then(function (session) { return session; }, function () { // this is just bad news... return conf.cache.destroy(conf).then(function () { window.alert("Sorry, but an error occurred which can only be fixed by logging you out" + " and refreshing the page.\n\nThis will happen automatically.\n\nIf you get this" + " message even after the page refreshes, please contact support@betopool.com." ); window.location.reload(); return Oauth3.PromiseA.reject(new Error("A session error occured. You must log out and log back in.")); }); }); } // TODO is this more logins or accounts or session? session? function handleOrphanLogins(conf) { var promise; var session = conf.session; promise = Oauth3.PromiseA.resolve(); if (session.logins.some(function (login) { return !login.accounts.length; })) { if (session.accounts.length > 1) { throw new Error("[Not Implemented] can't yet attach new social logins when more than one local account is in the session." + " Please logout and sign back in with your Local Account only. Then attach the other login."); } session.logins.forEach(function (login) { if (!login.accounts.length) { promise = promise.then(function () { return TAccounts.attachLoginToAccount(conf, session.accounts[0], login); }); } }); } return promise.then(function () { return session; }); } TLogins.getLoginFromTokenParams = function (conf, providerUri, username, params) { var err; var accessToken; var refreshToken; var expiresAt; var match; var data; var login; var now = Date.now(); if (!params) { err = new Error("[Developer Error] No params were passed to the token parser"); err.code = 'E_DEV_ERROR'; err.uri = 'https://oauth3.org/docs/errors/#E_DEV_ERROR'; return Oauth3.PromiseA.reject(err); } console.log('[DEBUG] params', params); accessToken = (params.oauth3_token || params.oauth3Token || params.jwt || params.access_token || params.accessToken || params.token); refreshToken = (params.oauth3Refresh || params.oauth3_refresh || params.jwt_refresh || params.refresh_token || params.refreshToken); if (!accessToken) { if (!(params.error || params.error_description)) { err = new Error("[Server Error] The server did not grant access nor give an error message"); err.code = "E_SERVER_ERROR"; err.uri = params.error_uri || ''; return Oauth3.PromiseA.reject(err); } err = new Error(params.error_description || ": invalid username or secret"); err.code = params.error || "_access_denied"; err.uri = params.error_uri || ''; return Oauth3.PromiseA.reject(err); } // JWT <>.<>.<> // pass yada.yada.yada // fail yada yada.yada // fail y?da.yada.yada match = accessToken.match(/^[A-Za-z0-9+=_\/\-]+\.([A-Za-z0-9+=_\/\-]+)\.[A-Za-z0-9+=_\/\-]+$/); if (match) { try { data = JSON.parse(atob(match[1])); } catch(e) { data = {}; } } else { data = {}; } // TODO support fewer expiry methods expiresAt = [ params.expires_at, params.expiresAt, params.expires_in, params.expiresIn, params.expires, data.exp ].map(function (exp) { exp = parseInt(exp, 10) || 0; var year = 365 * 24 * 60 * 60 * 1000; var min = now - (1 * year); var max = now + (2 * year); // date of expiration, already in ms if (exp > min && exp < max) { return exp; } // date of expiration in seconds if (exp > (min / 1000) && exp < (max / 1000)) { return exp * 1000; } // time remaining in seconds if (exp > 1 && exp < (2 * year)) { return now + (exp * 1000); } }).filter(function (exp) { return exp; })[0] || (Date.now() + 1 * 60 * 60 * 1000); // TODO drop prefixes everywhere providerUri = providerUri.replace(/^(https?:\/\/)?(www\.)?/, ''); login = { token: accessToken , refreshToken: refreshToken , expiresAt: expiresAt , appScopedId: params.app_scoped_id || params.appScopedId || data.idx || data.usr || username || null , loginId: params.loginId || params.login_id || data.id || data.usr , accountId: params.accountId || params.account_id || data.acx || data.acc // TODO app_name in oauth3.json "AJ on Facebook" , comment: data.sub || data.com || ( (username && (username + ' via ') || '') + (providerUri) ) , loginType: ('password' === data.grt || username) ? 'localaccount' : null , providerUri: providerUri }; return Oauth3.PromiseA.resolve(login); }; TLogins.requireLogin = function (conf, opts) { return restore(conf).then(function (session) { return session; }, function (/*err*/) { return conf.config.invokeLogin(opts); }); }; TLogins.create = function (conf, username, type, secret, kdf, mfa) { // secret is optional (for server-side requirement checking) // kdf is mandatory ( return Oauth3.request({ url: conf.config.apiBaseUri + '/api' + '/org.oauth3.provider' + '/logins/' , method: 'POST' , data: { id: username , type: type , secret: secret , kdf: kdf , mfa: mfa } }); }; TLogins.softTestUsername = function (conf, username) { if ('string' !== typeof username) { throw new Error("[Developer Error] username should be a string"); } /* if (!/^[0-9a-z\.\-_]+$/i.test(username)) { // TODO validate this is true on the server return new Error("Only alphanumeric characters, '-', '_', and '.' are allowed in usernames."); } */ if (!/^[^@]+@[^\.]+\.[^\.]+$/i.test(username)) { // TODO validate this is true on the server return new Error("You must use an email address."); } if (username.length < conf.usernameMinLength) { // TODO validate this is true on the server return new Error('Username too short. Use at least ' + conf.usernameMinLength + ' characters.'); } return true; }; TLogins.getMeta = function (conf, username) { // TODO support username as type var type = null; // TODO update backend to /api/promoonlyonline/username/:username? return Oauth3.request({ url: conf.config.apiBaseUri + '/api' + '/org.oauth3.provider' + '/logins/meta/' + type + '/' + username , method: 'GET' }).then(function (resp) { if (!resp.data.kdf) { return Oauth3.PromiseA.reject(new Error("metadata for username does not exist")); } return resp.data; }, function (err) { if (/does not exist/.test(err.message)) { return Oauth3.PromiseA.reject(err); } throw err; }); }; TLogins.meta = function (conf, username, type) { // TODO support username as type // TODO update backend to /api/promoonlyonline/username/:username? return Oauth3.request({ url: conf.config.apiBaseUri + '/api' + '/org.oauth3.provider' + '/logins/meta/' + type + '/' + username , method: 'GET' }).then(function (resp) { // TODO better check if (!resp.data.salt) { return Oauth3.PromiseA.reject(new Error("data for username does not exist")); } return resp.data; }, function (err) { if (/does not exist/.test(err.message)) { return Oauth3.PromiseA.reject(err); } throw err; }); }; TLogins.hardTestUsername = function (conf, username) { // TODO support username as type var type = null; // TODO update backend to /api/promoonlyonline/username/:username? return Oauth3.request({ url: conf.config.apiBaseUri + '/api' + '/org.oauth3.provider' + '/logins/check/' + type + '/' + username , method: 'GET' }).then(function (result) { if (!result.data.exists) { return Oauth3.PromiseA.reject(new Error("username does not exist")); } }, function (err) { if (/does not exist/.test(err.message)) { return Oauth3.PromiseA.reject(err); } throw err; }); }; TAccounts.getId = function (o, p) { // object if (!o) { return null; } // prefix if (!p) { return o.appScopedId || o.app_scoped_id || o.id || null; } else { return o[p + 'AppScopedId'] || o[p + '_app_scoped_id'] || o[p + 'Id'] || o[p + '_id'] || null; } }; TAccounts.realCreateAccount = function (conf, login) { return Oauth3.request({ url: conf.config.apiBaseUri + '/api' + '/org.oauth3.provider' + '/accounts' , method: 'POST' , data: { account: {} , logins: [{ // TODO make appScopedIds even for root app id: login.appScopedId || login.app_scoped_id || login.loginId || login.login_id || login.id , token: login.token || login.accessToken || login.accessToken }] } , headers: { Authorization: 'Bearer ' + login.token } }).then(function (resp) { return resp.data; }, function (err) { return Oauth3.PromiseA.reject(err); }); }; // TODO move to LocalApiLogin ? TAccounts.attachLoginToAccount = function (conf, account, newLogin) { var url = conf.config.apiBaseUri + '/api' + '/org.oauth3.provider' + '/accounts/' + account.appScopedId + '/logins'; var token = TherapySession.api.getToken(conf, account); return Oauth3.request({ url: url , method: 'POST' , data: { logins: [{ id: newLogin.appScopedId || newLogin.app_scoped_id || newLogin.loginId || newLogin.login_id || newLogin.id , token: newLogin.token || newLogin.accessToken || newLogin.access_token }] } , headers: { 'Authorization': 'Bearer ' + token } }).then(function (resp) { if (!resp.data) { return Oauth3.PromiseA.reject(new Error("no response when linking login to account")); } if (resp.data.error) { return Oauth3.PromiseA.reject(resp.data.error); } // return nothing }, function (err) { console.error('[Error] failed to attach login to account'); console.warn(err.message); console.warn(err.stack); return Oauth3.PromiseA.reject(err); }); }; TAccounts.requireAccountHelper = function (conf) { var session = conf.session; var promise; var locallogins; var err; if (session.accounts.length) { return Oauth3.PromiseA.resolve(session); } if (!session.logins.length) { console.error("doesn't have any logins"); return Oauth3.PromiseA.reject(new Error("[Developer Error] do not call requireAccount when you have not called requireLogin.")); } locallogins = session.logins.filter(function (login) { return 'localaccount' === login.loginType; }); if (!locallogins.length) { console.error("no local accounts"); err = new Error("Login with your Local Account at least once before linking other accounts."); err.code = "E_NO_LOCAL_ACCOUNT"; return Oauth3.PromiseA.reject(err); } // at this point we have a valid locallogin, but still no localaccount promise = Oauth3.PromiseA.resolve(); locallogins.forEach(function (login) { promise = promise.then(function () { return TAccounts.realCreateAccount(conf, login).then(function (account) { login.accounts.push(account); return save(conf, { login: login, accounts: login.accounts }); }); }); }); return promise.then(function (session) { return session; }); }; TAccounts.requireAccount = function (conf) { return TAccounts.requireAccountHelper(conf).then(function () { return api.handleOrphanLogins(conf); }); }; // TODO move to LocalApiAccount ? TAccounts.cloneAccount = function (conf, account) { // retrieve the most fresh token of all associated logins var token = TherapySession.api.getToken(conf, account); var id = TAccounts.getId(account); // We don't want to modify the original object and end up // with potentially whole stakes in the local storage session key account = JSON.parse(JSON.stringify(account)); account.token = token; account.accountId = account.accountId || account.appScopedId || id; account.appScopedId = account.appScopedId || id; return account; }; // TODO check for account and account create if not exists in requireSession // TODO move to LocalApiAccount ? TAccounts.selectAccount = function (conf, accountId) { var session = conf.session; // needs to return the account with a valid login var account; if (!accountId) { accountId = session.accountId; } if (!session.accounts.some(function (a) { if (!accountId || accountId === TAccounts.getId(a)) { account = a; return true; } })) { account = session.accounts[0]; } if (!account) { console.error("Developer Error: require session before selecting an account"); console.error(session); throw new Error("Developer Error: require session before selecting an account"); } account = TAccounts.cloneAccount(conf, account); session.accountId = account.accountId; session.id = account.accountId; session.appScopedId = account.accountId; session.token = account.token; // XXX really? conf.account = account; return account; }; InternalApi = { accounts: accounts , login: login , getToken: getToken }; api = { save: save , restore: restore , checkSession: restore , destroy: destroy , require: requireSession , accounts: accounts , requireSession: requireSession , getToken: getToken , addAccountsToSession: addAccountsToSession , pruneAccountsFromSession: pruneAccountsFromSession , refreshCurrentAccount: refreshCurrentAccount , updateSession: updateSession , sanityCheckAccounts: sanityCheckAccounts , handleOrphanLogins: handleOrphanLogins , validateUsername: TLogins.softTestUsername , checkUsername: TLogins.hardTestUsername , getMeta: TLogins.meta , createLogin: TLogins.create , login: login // this is intended for the resourceOwnerPassword strategy , backgroundLogin: backgroundLogin , logout: logout , onLogin: onLogin , onLogout: onLogout , requireAccount: TAccounts.requireAccount , selectAccount: TAccounts.selectAccount // TODO nix this 'un , account: TAccounts.selectAccount , testLoginAccounts: testLoginAccounts , cloneAccount: TAccounts.cloneAccount //, getId: TAccounts.getId }; TherapySession = { create: create , api: api , getId: TAccounts.getId }; // XXX // These are underscore prefixed because they aren't official API yet // I need more time to figure out the proper separation TherapySession._logins = TLogins; TherapySession._accounts = TAccounts; exports.TherapySession = TherapySession.TherapySession = TherapySession; if ('undefined' !== typeof module) { module.exports = TherapySession; } }('undefined' !== typeof exports ? exports : window));