goldilocks.js/lib/storage.js

226 lines
6.8 KiB
JavaScript

'use strict';
var PromiseA = require('bluebird');
var path = require('path');
var fs = PromiseA.promisifyAll(require('fs'));
var jwt = require('jsonwebtoken');
var crypto = require('crypto');
module.exports.create = function (deps, conf) {
var hrIds = require('human-readable-ids').humanReadableIds;
var scmp = require('scmp');
var storageDir = path.join(__dirname, '..', 'var');
function read(fileName) {
return fs.readFileAsync(path.join(storageDir, fileName))
.then(JSON.parse, function (err) {
if (err.code === 'ENOENT') {
return {};
}
throw err;
});
}
function write(fileName, obj) {
return fs.mkdirAsync(storageDir).catch(function (err) {
if (err.code !== 'EEXIST') {
console.error('failed to mkdir', storageDir, err.toString());
}
}).then(function () {
return fs.writeFileAsync(path.join(storageDir, fileName), JSON.stringify(obj), 'utf8');
});
}
var owners = {
_filename: 'owners.json'
, all: function () {
return read(this._filename).then(function (owners) {
return Object.keys(owners).map(function (id) {
var owner = owners[id];
owner.id = id;
return owner;
});
});
}
, get: function (id) {
// While we could directly read the owners file and access the id directly from
// the resulting object I'm not sure of the details of how the object key lookup
// works or whether that would expose us to timing attacks.
// See https://codahale.com/a-lesson-in-timing-attacks/
return this.all().then(function (owners) {
return owners.filter(function (owner) {
return scmp(id, owner.id);
})[0];
});
}
, exists: function (id) {
return this.get(id).then(function (owner) {
return !!owner;
});
}
, set: function (id, obj) {
var self = this;
return read(self._filename).then(function (owners) {
obj.id = id;
owners[id] = obj;
return write(self._filename, owners);
});
}
};
var confCb;
var config = {
save: function (changes) {
deps.messenger.send({
type: 'com.daplie.goldilocks/config'
, changes: changes
});
return new deps.PromiseA(function (resolve, reject) {
var timeoutId = setTimeout(function () {
reject(new Error('Did not receive config update from main process in a reasonable time'));
confCb = null;
}, 15*1000);
confCb = function (config) {
confCb = null;
clearTimeout(timeoutId);
resolve(config);
};
});
}
};
function updateConf(config) {
if (confCb) {
confCb(config);
}
}
var userTokens = {
_filename: 'user-tokens.json'
, _cache: {}
, _convertToken: function convertToken(id, token) {
// convert the token into something that looks more like what OAuth3 uses internally
// as sessions so we can use it with OAuth3. We don't use OAuth3's internal session
// storage because it effectively only supports storing tokens based on provider URI.
// We also use the token as the `access_token` instead of `refresh_token` because the
// refresh functionality is closely tied to the storage.
var decoded = jwt.decode(token);
if (!decoded) {
return null;
}
return {
id: id
, access_token: token
, token: decoded
, provider_uri: decoded.iss || decoded.issuer || decoded.provider_uri
, client_uri: decoded.azp
, scope: decoded.scp || decoded.scope || decoded.grants
};
}
, all: function allUserTokens() {
var self = this;
if (self._cacheComplete) {
return deps.PromiseA.resolve(Object.values(self._cache));
}
return read(self._filename).then(function (tokens) {
// We will read every single token into our cache, so it will be complete once we finish
// creating the result (it's set out of order so we can directly return the result).
self._cacheComplete = true;
return Object.keys(tokens).map(function (id) {
self._cache[id] = self._convertToken(id, tokens[id]);
return self._cache[id];
});
});
}
, get: function getUserToken(id) {
var self = this;
if (self._cache.hasOwnProperty(id) || self._cacheComplete) {
return deps.PromiseA.resolve(self._cache[id] || null);
}
return read(self._filename).then(function (tokens) {
self._cache[id] = self._convertToken(id, tokens[id]);
return self._cache[id];
});
}
, save: function saveUserToken(newToken) {
var self = this;
return read(self._filename).then(function (tokens) {
var rawToken;
if (typeof newToken === 'string') {
rawToken = newToken;
} else {
rawToken = newToken.refresh_token || newToken.access_token;
}
if (typeof rawToken !== 'string') {
throw new Error('cannot save invalid session: missing refresh_token and access_token');
}
var decoded = jwt.decode(rawToken);
var idHash = crypto.createHash('sha256');
idHash.update(decoded.sub || decoded.ppid || decoded.appScopedId || '');
idHash.update(decoded.iss || decoded.issuer || '');
idHash.update(decoded.aud || decoded.audience || '');
var scope = decoded.scope || decoded.scp || decoded.grants || '';
idHash.update(scope.split(/[,\s]+/mg).sort().join(','));
var id = idHash.digest('hex');
tokens[id] = rawToken;
return write(self._filename, tokens).then(function () {
// Delete the current cache so that if this is an update it will refresh
// the cache once we read the ID.
delete self._cache[id];
return self.get(id);
});
});
}
, remove: function removeUserToken(id) {
var self = this;
return read(self._filename).then(function (tokens) {
var present = delete tokens[id];
if (!present) {
return present;
}
return write(self._filename, tokens).then(function () {
delete self._cache[id];
return true;
});
});
}
};
var mdnsId = {
_filename: 'mdns-id'
, get: function () {
var self = this;
return read("mdns-id").then(function (result) {
if (typeof result !== 'string') {
throw new Error('mDNS ID not present');
}
return result;
}).catch(function () {
return self.set(hrIds.random());
});
}
, set: function (value) {
var self = this;
return write(self._filename, value).then(function () {
return self.get();
});
}
};
return {
owners: owners
, config: config
, updateConf: updateConf
, tokens: userTokens
, mdnsId: mdnsId
};
};