diff --git a/client/config.js b/client/config.js index 010a815..8b4b434 100644 --- a/client/config.js +++ b/client/config.js @@ -1,3 +1,8 @@ +/** + * This file includes base config vars. + * Additional config comes from the browser client when it initializes sync. + */ + module.exports = { // Set this to the origins where client.html may be loaded clientOrigins: ['http://localhost:8000', 'chrome-extension://mnojpmjdmbbfmejpflffifhffcmidifd'], diff --git a/client/requestUtil.js b/client/requestUtil.js index 908dd00..74b11aa 100644 --- a/client/requestUtil.js +++ b/client/requestUtil.js @@ -1,11 +1,16 @@ 'use strict' -const NONCE_COUNTER = 0 - const awsSdk = require('aws-sdk') const cryptoUtil = require('./cryptoUtil') const s3Helper = require('../lib/s3Helper') +const CONFIG = require('./config') +const S3_MAX_RETRIES = 1 +const EXPIRED_CREDENTIAL_ERRORS = [ + /The provided token has expired\./, + /Invalid according to Policy: Policy expired\./ +] + const checkFetchStatus = (response) => { if (response.status >= 200 && response.status < 300) { return response @@ -16,6 +21,12 @@ const checkFetchStatus = (response) => { } } +const isExpiredCredentialError = (error) => { + return EXPIRED_CREDENTIAL_ERRORS.some((message) => { + return error.message.match(message) + }) +} + const getTime = () => { return Math.floor(Date.now() / 1000) } @@ -40,7 +51,7 @@ const RequestUtil = function (opts = {}) { this.serializer = opts.serializer this.serverUrl = opts.serverUrl this.userId = Buffer.from(opts.keys.publicKey).toString('base64') - this.encrypt = cryptoUtil.Encrypt(this.serializer, opts.keys.secretboxKey, NONCE_COUNTER) + this.encrypt = cryptoUtil.Encrypt(this.serializer, opts.keys.secretboxKey, CONFIG.nonceCounter) this.decrypt = cryptoUtil.Decrypt(this.serializer, opts.keys.secretboxKey) this.sign = cryptoUtil.Sign(opts.keys.secretKey) if (opts.credentialsBytes) { @@ -71,6 +82,7 @@ RequestUtil.prototype.refreshAWSCredentials = function () { return response.arrayBuffer() }) .then((buffer) => { + console.log('Refreshed credentials.') const credentials = this.parseAWSResponse(new Uint8Array(buffer)) this.saveAWSCredentials(credentials) }) @@ -127,6 +139,7 @@ RequestUtil.prototype.parseAWSResponse = function (bytes) { // The bucket name is prepended to the endpoint to build the actual request URL, e.g. // https://brave-sync-staging.s3.dualstack.us-west-2.amazonaws.com endpoint: `https://s3.dualstack.${region}.amazonaws.com`, + maxRetries: S3_MAX_RETRIES, region: region, sslEnabled: true, useDualstack: true @@ -146,18 +159,17 @@ RequestUtil.prototype.list = function (category, startAt) { Bucket: this.bucket, Prefix: prefix } - if (startAt) { - options.StartAfter = `${prefix}/${startAt}` - } - return s3Helper.listObjects(this.s3, options) - .then((data) => { - return data.map((s3Object) => { - const parsedKey = s3Helper.parseS3Key(s3Object.Key) - // TODO: Recombine split records - const decodedData = s3Helper.s3StringToByteArray(parsedKey.recordPartString) - return decodedData - }) + if (startAt) { options.StartAfter = `${prefix}/${startAt}` } + return this.withRetry(() => { + return s3Helper.listObjects(this.s3, options) + }).then((data) => { + return data.map((s3Object) => { + const parsedKey = s3Helper.parseS3Key(s3Object.Key) + // TODO: Recombine split records + const decodedData = s3Helper.s3StringToByteArray(parsedKey.recordPartString) + return decodedData }) + }) } /** @@ -177,15 +189,17 @@ RequestUtil.prototype.currentRecordPrefix = function (category) { RequestUtil.prototype.put = function (category, record) { const s3Prefix = this.currentRecordPrefix(category) const s3Keys = s3Helper.encodeDataToS3KeyArray(s3Prefix, record) - const fetchPromises = s3Keys.map((key, _i) => { - const params = { - method: 'POST', - body: this.s3PostFormData(key) - } - return window.fetch(this.s3PostEndpoint, params) - .then(checkFetchStatus) + return this.withRetry(() => { + const fetchPromises = s3Keys.map((key, _i) => { + const params = { + method: 'POST', + body: this.s3PostFormData(key) + } + return window.fetch(this.s3PostEndpoint, params) + .then(checkFetchStatus) + }) + return Promise.all(fetchPromises) }) - return Promise.all(fetchPromises) } RequestUtil.prototype.s3PostFormData = function (objectKey) { @@ -204,7 +218,9 @@ RequestUtil.prototype.s3PostFormData = function (objectKey) { * @param {string} prefix */ RequestUtil.prototype.s3DeletePrefix = function (prefix) { - return s3Helper.deletePrefix(this.s3, this.bucket, prefix) + return this.withRetry(() => { + return s3Helper.deletePrefix(this.s3, this.bucket, prefix) + }) } RequestUtil.prototype.deleteUser = function () { @@ -218,4 +234,47 @@ RequestUtil.prototype.deleteCategory = function (category) { return this.s3DeletePrefix(`${this.apiVersion}/${this.userId}/${category}`) } +/** + * Wrapper to call a function and refresh credentials if needed. + * @param {Function(Promise)} Function which returns a Promise. + * @param {number} retries Retries left. You probably don't need to change this. + * @param {Error=} previousError Buffer with the previous error, for internal use. + */ +RequestUtil.prototype.withRetry = function (myFun, retries = 1, previousError) { + if (retries < 0) { throw previousError } + + return new Promise((resolve, reject) => { + const callMyFun = () => { + myFun() + .then((...args) => { resolve(...args) }) + .catch((error) => { + const retry = () => { + try { + this.withRetry(myFun, retries - 1, error) + .then((...args) => { resolve(...args) }) + .catch((error) => { reject(error) }) + } catch (error) { + reject(error) + } + } + // window.fetch() requests. checkFetchStatus() appends responses. + if (error.response) { + error.response.text().then((body) => { + error.message = error.message.concat(body) + retry() + }) + } else { + retry() + } + }) + } + if (previousError) { + if (!isExpiredCredentialError(previousError)) { throw previousError } + this.refreshAWSCredentials().then(callMyFun) + } else { + callMyFun() + } + }) +} + module.exports = RequestUtil diff --git a/client/sync.js b/client/sync.js index c592285..9f26d79 100644 --- a/client/sync.js +++ b/client/sync.js @@ -1,12 +1,10 @@ 'use strict' const initializer = require('./init') -const cryptoUtil = require('./cryptoUtil') const RequestUtil = require('./requestUtil') const messages = require('./constants/messages') const proto = require('./constants/proto') const serializer = require('../lib/serializer') -const conf = require('./config') const ipc = window.chrome.ipcRenderer @@ -22,7 +20,7 @@ var clientUserId = null var clientKeys = {} var config = {} -// aws sdk requests class +// RequestUtil which makes AWS requests var requester = {} console.log('in sync script') @@ -47,9 +45,6 @@ const logSync = (message, logLevel = DEBUG) => { } } -const decrypt = cryptoUtil.Decrypt(clientSerializer, clientKeys.secretboxKey) -const encrypt = cryptoUtil.Encrypt(clientSerializer, clientKeys.secretboxKey, conf.nonceCounter) - /** * Sets the device ID if one does not yet exist. * @returns {Promise} @@ -68,7 +63,7 @@ const maybeSetDeviceId = () => { records.forEach((bytes) => { var record = {} try { - record = decrypt(bytes) + record = requester.decrypt(bytes) } catch (e) { return } @@ -108,7 +103,7 @@ const startSync = () => { record.deviceId = new Uint8Array(record.deviceId) record.objectId = new Uint8Array(record.objectId) logSync(`sending record: ${JSON.stringify(record)}`) - requester.put(proto.categories[category], encrypt(record)) + requester.put(proto.categories[category], requester.encrypt(record)) }) }) logSync('success') @@ -141,7 +136,7 @@ Promise.all([serializer.init(''), initializer.init(window.chrome)]).then((values credentialsBytes: null, // TODO: Start with previous session's credentials keys: clientKeys, serializer: clientSerializer, - syncServerUrl: config.serverUrl + serverUrl: config.serverUrl }) return requester.refreshAWSCredentials() }) diff --git a/test/client/requestUtil.js b/test/client/requestUtil.js index c79bb7b..dcc003c 100644 --- a/test/client/requestUtil.js +++ b/test/client/requestUtil.js @@ -161,9 +161,9 @@ test('client RequestUtil', (t) => { region: requestUtil.region } t.test('RequestUtil with expired credentials', (t) => { - t.plan(2) + t.plan(3) - const args = { + const expiredArgs = { apiVersion: clientTestHelper.CONFIG.apiVersion, credentialsBytes: serializer.credentialsToByteArray(expiredCredentials), keys, @@ -171,9 +171,9 @@ test('client RequestUtil', (t) => { serverUrl: clientTestHelper.CONFIG.serverUrl } - t.doesNotThrow(() => { return new RequestUtil(args) }, `${t.name} instantiates without error`) + t.doesNotThrow(() => { return new RequestUtil(expiredArgs) }, `${t.name} instantiates without error`) - t.test('#refreshAWSCredentials', (t) => { + t.test('#refreshAWSCredentials()', (t) => { t.plan(2) const args = { apiVersion: clientTestHelper.CONFIG.apiVersion, @@ -193,6 +193,45 @@ test('client RequestUtil', (t) => { }) .catch((error) => { t.fail(error) }) }) + + t.test('automatic credential refresh', (t) => { + t.plan(2) + + t.test(`${t.name} list()`, (t) => { + t.plan(1) + const requestUtil = new RequestUtil(expiredArgs) + requestUtil.list(proto.categories.PREFERENCES) + .then((response) => { t.equals(response.length, 0, t.name) }) + .catch((error) => { t.fail(error) }) + }) + + t.test(`${t.name} put()`, (t) => { + t.plan(2) + const record = { + action: 'CREATE', + deviceId: new Uint8Array([1]), + objectId: testHelper.uuid(), + device: {name: 'sweet'} + } + const requestUtil = new RequestUtil(expiredArgs) + requestUtil.put(proto.categories.PREFERENCES, encrypt(record)) + .then((response) => { + t.pass(t.name) + testCredentialRefreshDelete(t) + }) + .catch((error) => { t.fail(error) }) + }) + + const testCredentialRefreshDelete = (t) => { + t.test(`${t.name} deleteCategory()`, (t) => { + t.plan(1) + const requestUtil = new RequestUtil(expiredArgs) + requestUtil.deleteCategory(proto.categories.PREFERENCES) + .then((response) => { t.pass(t.name) }) + .catch((error) => { t.fail(error) }) + }) + } + }) }) } })