[feat] Refresh Gmail access tokens when needed

Summary:
This is a small patch but it's pretty complex, because of the numbers of moving parts. Gmail has two types of tokens, access and refresh tokens. Access tokens have a limited shelf life of one hour. After that they expire and you need to use your refresh token to get a new one.

We've decided to do the access token generation on the server, because we don't feel comfortable giving our users both our Google client id and secret. To do that, I've added an endpoint, `/gmail/auth/refresh` which returns a valid access token as well as an expiration date for the token.

The only place where we handle token expiration is in the sync workers. Before trying opening a new connection we check if our access token is expired. If yes, we get a new one from the API. If there's an issue doing this, we notify N1 using `NylasAPIHelpers.handleAuthenticationFailure`.

There's a second patch for N1 with tiny related fixes.

Test Plan: Tested manually. Will need to test more in the real world.

Reviewers: evan, jackie, juan

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D3522
This commit is contained in:
Karim Hamidou 2016-12-16 11:38:45 -08:00
parent 86a2b13730
commit 101b99f4a7
3 changed files with 78 additions and 34 deletions

View file

@ -17,6 +17,7 @@ const imapSmtpSettings = Joi.object().keys({
const resolvedGmailSettings = Joi.object().keys({ const resolvedGmailSettings = Joi.object().keys({
xoauth2: Joi.string().required(), xoauth2: Joi.string().required(),
expiry_date: Joi.number().integer().required(),
}).required(); }).required();
const exchangeSettings = Joi.object().keys({ const exchangeSettings = Joi.object().keys({
@ -26,7 +27,7 @@ const exchangeSettings = Joi.object().keys({
}).required(); }).required();
const USER_ERRORS = { const USER_ERRORS = {
AUTH_500: "Please contact support@nylas.com. An unforseen error has occurred.", AUTH_500: "Please contact support@nylas.com. An unforeseen error has occurred.",
IMAP_AUTH: "Incorrect username or password", IMAP_AUTH: "Incorrect username or password",
IMAP_RETRY: "We were unable to reach your mail provider. Please try again.", IMAP_RETRY: "We were unable to reach your mail provider. Please try again.",
} }
@ -81,6 +82,7 @@ module.exports = {
} }
connectionCredentials = { connectionCredentials = {
xoauth2: settings.xoauth2, xoauth2: settings.xoauth2,
expiry_date: settings.expiry_date,
} }
} }

View file

@ -20,6 +20,8 @@ const Capabilities = {
Sort: 'SORT', Sort: 'SORT',
} }
const ONE_HOUR_SECS = 60 * 60;
class IMAPConnection extends EventEmitter { class IMAPConnection extends EventEmitter {
static connect(...args) { static connect(...args) {
@ -46,6 +48,13 @@ class IMAPConnection extends EventEmitter {
this._connectPromise = null; this._connectPromise = null;
} }
static generateXOAuth2Token(username, accessToken) {
// See https://developers.google.com/gmail/xoauth2_protocol
// for more details.
const s = `user=${username}\x01auth=Bearer ${accessToken}\x01\x01`
return new Buffer(s).toString('base64');
}
get account() { get account() {
return this._account return this._account
} }
@ -89,6 +98,7 @@ class IMAPConnection extends EventEmitter {
if (err) { return reject(err) } if (err) { return reject(err) }
delete result.password; delete result.password;
result.xoauth2 = token; result.xoauth2 = token;
result.expiry_date = Math.floor(Date.now() / 1000) + ONE_HOUR_SECS;
return resolve(result); return resolve(result);
}); });
}); });
@ -99,6 +109,7 @@ class IMAPConnection extends EventEmitter {
if (this._settings.xoauth2) { if (this._settings.xoauth2) {
delete result.password; delete result.password;
result.xoauth2 = this._settings.xoauth2; result.xoauth2 = this._settings.xoauth2;
result.expiry_date = this._settings.expiry_date;
} }
return Promise.resolve(result); return Promise.resolve(result);

View file

@ -9,6 +9,8 @@ const FetchFolderList = require('./imap/fetch-folder-list')
const FetchMessagesInFolder = require('./imap/fetch-messages-in-folder') const FetchMessagesInFolder = require('./imap/fetch-messages-in-folder')
const SyncbackTaskFactory = require('./syncback-task-factory') const SyncbackTaskFactory = require('./syncback-task-factory')
const SyncMetricsReporter = require('./sync-metrics-reporter'); const SyncMetricsReporter = require('./sync-metrics-reporter');
const {NylasAPI, N1CloudAPI, NylasAPIRequest, NylasAPIHelpers} = require('nylas-exports');
const RESTART_THRESHOLD = 10 const RESTART_THRESHOLD = 10
@ -76,6 +78,68 @@ class SyncWorker {
return this._db.Folder.find({where: {role: ['all', 'inbox']}}) return this._db.Folder.find({where: {role: ['all', 'inbox']}})
} }
async ensureConnection() {
if (this._conn) {
return await this._conn.connect();
}
const settings = this._account.connectionSettings;
let credentials = this._account.decryptedCredentials();
if (!settings || !settings.imap_host) {
throw new Error("ensureConnection: There are no IMAP connection settings for this account.");
}
if (!credentials) {
throw new Error("ensureConnection: There are no IMAP connection credentials for this account.");
}
if (this._account.provider == 'gmail') {
// Before creating a connection, check if the our access token expired.
// If yes, we need to get a new one.
const currentUnixDate = Math.floor(Date.now() / 1000);
if (currentUnixDate > credentials.expiry_date) {
const req = new NylasAPIRequest({
api: N1CloudAPI,
options: {
path: `/auth/gmail/refresh`,
method: 'POST',
accountId: this._account.id,
},
});
try {
const newCredentials = await req.run()
this._account.setCredentials(newCredentials);
await this._account.save()
credentials = newCredentials;
this._logger.info("Refreshed and updated access token.");
} catch (error) {
const accountToken = NylasAPI.accessTokenForAccountId(this._account.id);
NylasAPIHelpers.handleAuthenticationFailure('/auth/gmail/refresh', accountToken);
throw new Error("Unable to authenticate to the remote server.");
}
}
}
const conn = new IMAPConnection({
db: this._db,
settings: Object.assign({}, settings, credentials),
logger: this._logger,
});
conn.on('mail', () => {
this._onConnectionIdleUpdate();
})
conn.on('update', () => {
this._onConnectionIdleUpdate();
})
conn.on('queue-empty', () => {});
this._conn = conn;
return await this._conn.connect();
}
/** /**
* Returns a list of at most 100 Syncback requests, sorted by creation date * Returns a list of at most 100 Syncback requests, sorted by creation date
* (older first) and by how they affect message IMAP uids. * (older first) and by how they affect message IMAP uids.
@ -158,39 +222,6 @@ class SyncWorker {
) )
} }
async ensureConnection() {
if (this._conn) {
return await this._conn.connect();
}
const settings = this._account.connectionSettings;
const credentials = this._account.decryptedCredentials();
if (!settings || !settings.imap_host) {
throw new Error("ensureConnection: There are no IMAP connection settings for this account.");
}
if (!credentials) {
throw new Error("ensureConnection: There are no IMAP connection credentials for this account.");
}
const conn = new IMAPConnection({
db: this._db,
account: this._account,
settings: Object.assign({}, settings, credentials),
logger: this._logger,
});
conn.on('mail', () => {
this._onConnectionIdleUpdate();
})
conn.on('update', () => {
this._onConnectionIdleUpdate();
})
conn.on('queue-empty', () => {});
this._conn = conn;
return await this._conn.connect();
}
cleanup() { cleanup() {
clearTimeout(this._syncTimer); clearTimeout(this._syncTimer);
this._syncTimer = null; this._syncTimer = null;