UNPKG

xoauth2

Version:

XOAuth2 token generation for accessing GMail SMTP and IMAP

300 lines (260 loc) 10.1 kB
'use strict'; var Stream = require('stream').Stream; var utillib = require('util'); var querystring = require('querystring'); var http = require('http'); var https = require('https'); var urllib = require('url'); var crypto = require('crypto'); /** * Wrapper for new XOAuth2Generator. * * Usage: * * var xoauthgen = createXOAuth2Generator({}); * xoauthgen.getToken(function(err, xoauthtoken){ * socket.send('AUTH XOAUTH2 ' + xoauthtoken); * }); * * @param {Object} options See XOAuth2Generator for details * @return {Object} */ module.exports.createXOAuth2Generator = function(options) { return new XOAuth2Generator(options); }; /** * XOAUTH2 access_token generator for Gmail. * Create client ID for web applications in Google API console to use it. * See Offline Access for receiving the needed refreshToken for an user * https://developers.google.com/accounts/docs/OAuth2WebServer#offline * * @constructor * @param {Object} options Client information for token generation * @param {String} options.user (Required) User e-mail address * @param {String} options.clientId (Required) Client ID value * @param {String} options.clientSecret (Required) Client secret value * @param {String} options.refreshToken (Required) Refresh token for an user * @param {String} options.accessUrl (Optional) Endpoint for token generation, defaults to 'https://accounts.google.com/o/oauth2/token' * @param {String} options.accessToken (Optional) An existing valid accessToken * @param {int} options.timeout (Optional) TTL in seconds */ function XOAuth2Generator(options) { Stream.call(this); this.options = options || {}; if (options && options.service) { if (!options.scope || !options.privateKey || !options.user) { throw new Error('Options "scope", "privateKey" and "user" are required for service account!'); } var serviceRequestTimeout = Math.min(Math.max(Number(this.options.serviceRequestTimeout) || 0, 0), 3600); this.options.serviceRequestTimeout = serviceRequestTimeout || 5 * 60; } this.options.accessUrl = this.options.accessUrl || 'https://accounts.google.com/o/oauth2/token'; this.options.customHeaders = this.options.customHeaders || {}; this.options.customParams = this.options.customParams || {}; this.token = this.options.accessToken && this.buildXOAuth2Token(this.options.accessToken) || false; this.accessToken = this.token && this.options.accessToken || false; var timeout = Math.max(Number(this.options.timeout) || 0, 0); this.timeout = timeout && Date.now() + timeout * 1000 || 0; } utillib.inherits(XOAuth2Generator, Stream); /** * Returns or generates (if previous has expired) a XOAuth2 token * * @param {Function} callback Callback function with error object and token string */ XOAuth2Generator.prototype.getToken = function(callback) { if (this.token && (!this.timeout || this.timeout > Date.now())) { return callback(null, this.token, this.accessToken); } this.generateToken(callback); }; /** * Updates token values * * @param {String} accessToken New access token * @param {Number} timeout Access token lifetime in seconds * * Emits 'token': { user: User email-address, accessToken: the new accessToken, timeout: TTL in seconds} */ XOAuth2Generator.prototype.updateToken = function(accessToken, timeout) { this.token = this.buildXOAuth2Token(accessToken); this.accessToken = accessToken; timeout = Math.max(Number(timeout) || 0, 0); this.timeout = timeout && Date.now() + timeout * 1000 || 0; this.emit('token', { user: this.options.user, accessToken: accessToken || '', timeout: Math.max(Math.floor((this.timeout - Date.now()) / 1000), 0) }); }; /** * Generates a new XOAuth2 token with the credentials provided at initialization * * @param {Function} callback Callback function with error object and token string */ XOAuth2Generator.prototype.generateToken = function(callback) { var urlOptions; if (this.options.service) { // service account - https://developers.google.com/identity/protocols/OAuth2ServiceAccount var iat = Math.floor(Date.now() / 1000); // unix time var token = jwtSignRS256({ iss: this.options.service, scope: this.options.scope, sub: this.options.user, aud: this.options.accessUrl, iat: iat, exp: iat + this.options.serviceRequestTimeout, }, this.options.privateKey); urlOptions = { grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token }; } else { // web app - https://developers.google.com/identity/protocols/OAuth2WebServer urlOptions = { client_id: this.options.clientId || '', client_secret: this.options.clientSecret || '', refresh_token: this.options.refreshToken, grant_type: 'refresh_token' }; } for (var param in this.options.customParams) { urlOptions[param] = this.options.customParams[param]; } var payload = querystring.stringify(urlOptions); var self = this; postRequest(this.options.accessUrl, payload, this.options, function (error, response, body) { var data; if (error) { return callback(error); } try { data = JSON.parse(body.toString()); } catch (E) { return callback(E); } if (!data || typeof data !== 'object') { return callback(new Error('Invalid authentication response')); } if (data.error) { return callback(new Error(data.error)); } if (data.access_token) { self.updateToken(data.access_token, data.expires_in); return callback(null, self.token, self.accessToken); } return callback(new Error('No access token')); }); }; /** * Converts an access_token and user id into a base64 encoded XOAuth2 token * * @param {String} accessToken Access token string * @return {String} Base64 encoded token for IMAP or SMTP login */ XOAuth2Generator.prototype.buildXOAuth2Token = function(accessToken) { var authData = [ 'user=' + (this.options.user || ''), 'auth=Bearer ' + accessToken, '', '' ]; return new Buffer(authData.join('\x01'), 'utf-8').toString('base64'); }; /** * Custom POST request handler. * This is only needed to keep paths short in Windows – usually this module * is a dependency of a dependency and if it tries to require something * like the request module the paths get way too long to handle for Windows. * As we do only a simple POST request we do not actually require complicated * logic support (no redirects, no nothing) anyway. * * @param {String} url Url to POST to * @param {String|Buffer} payload Payload to POST * @param {Function} callback Callback function with (err, buff) */ function postRequest(url, payload, params, callback) { var options = urllib.parse(url), finished = false, response = null, req; options.method = 'POST'; /** * Cleanup all the event listeners registered on the request, and ensure that *callback* is only called one time * * @note passes all the arguments passed to this function to *callback* */ var cleanupAndCallback = function() { if (finished === true) { return; } finished = true; req.removeAllListeners(); if (response !== null) { response.removeAllListeners(); } callback.apply(null, arguments); }; req = (options.protocol === 'https:' ? https : http).request(options, function(res) { response = res; var data = []; var datalen = 0; res.on('data', function(chunk) { data.push(chunk); datalen += chunk.length; }); res.on('end', function() { return cleanupAndCallback(null, res, Buffer.concat(data, datalen)); }); res.on('error', function(err) { return cleanupAndCallback(err); }); }); req.on('error', function(err) { return cleanupAndCallback(err); }); if (payload) { req.setHeader('Content-Type', 'application/x-www-form-urlencoded'); req.setHeader('Content-Length', typeof payload === 'string' ? Buffer.byteLength(payload) : payload.length); } for (var customHeaderName in params.customHeaders) { req.setHeader(customHeaderName, params.customHeaders[customHeaderName]); } req.end(payload); } /** * Encodes a buffer or a string into Base64url format * * @param {Buffer|String} data The data to convert * @return {String} The encoded string */ function toBase64URL(data) { if (typeof data === 'string') { data = new Buffer(data); } return data.toString('base64') .replace(/=+/g, '') // remove '='s .replace(/\+/g, '-') // '+' → '-' .replace(/\//g, '_'); // '/' → '_' } /** * Header used for RS256 JSON Web Tokens, encoded as Base64URL. */ var JWT_RS256_HEADER = toBase64URL('{"alg":"RS256","typ":"JWT"}'); /** * Creates a JSON Web Token signed with RS256 (SHA256 + RSA) * Only this specific operation is needed so it's implemented here * instead of depending on jsonwebtoken. * * @param {Object} payload The payload to include in the generated token * @param {String} privateKey Private key in PEM format for signing the token * @return {String} The generated and signed token */ function jwtSignRS256(payload, privateKey) { var signaturePayload = JWT_RS256_HEADER + '.' + toBase64URL(JSON.stringify(payload)); var rs256Signer = crypto.createSign('RSA-SHA256'); rs256Signer.update(signaturePayload); var signature = toBase64URL(rs256Signer.sign(privateKey)); return signaturePayload + '.' + signature; }