walnut.js/lib/com.daplie.walnut/daplie-scripts/therapy-session.js

908 lines
27 KiB
JavaScript

(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 <<base64>>.<<base64>>.<<base64>>
// 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));