index.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. 'use strict';
  2. const Stream = require('stream').Stream;
  3. const fetch = require('../fetch');
  4. const crypto = require('crypto');
  5. const shared = require('../shared');
  6. /**
  7. * XOAUTH2 access_token generator for Gmail.
  8. * Create client ID for web applications in Google API console to use it.
  9. * See Offline Access for receiving the needed refreshToken for an user
  10. * https://developers.google.com/accounts/docs/OAuth2WebServer#offline
  11. *
  12. * Usage for generating access tokens with a custom method using provisionCallback:
  13. * provisionCallback(user, renew, callback)
  14. * * user is the username to get the token for
  15. * * renew is a boolean that if true indicates that existing token failed and needs to be renewed
  16. * * callback is the callback to run with (error, accessToken [, expires])
  17. * * accessToken is a string
  18. * * expires is an optional expire time in milliseconds
  19. * If provisionCallback is used, then Nodemailer does not try to attempt generating the token by itself
  20. *
  21. * @constructor
  22. * @param {Object} options Client information for token generation
  23. * @param {String} options.user User e-mail address
  24. * @param {String} options.clientId Client ID value
  25. * @param {String} options.clientSecret Client secret value
  26. * @param {String} options.refreshToken Refresh token for an user
  27. * @param {String} options.accessUrl Endpoint for token generation, defaults to 'https://accounts.google.com/o/oauth2/token'
  28. * @param {String} options.accessToken An existing valid accessToken
  29. * @param {String} options.privateKey Private key for JSW
  30. * @param {Number} options.expires Optional Access Token expire time in ms
  31. * @param {Number} options.timeout Optional TTL for Access Token in seconds
  32. * @param {Function} options.provisionCallback Function to run when a new access token is required
  33. */
  34. class XOAuth2 extends Stream {
  35. constructor(options, logger) {
  36. super();
  37. this.options = options || {};
  38. if (options && options.serviceClient) {
  39. if (!options.privateKey || !options.user) {
  40. setImmediate(() => this.emit('error', new Error('Options "privateKey" and "user" are required for service account!')));
  41. return;
  42. }
  43. let serviceRequestTimeout = Math.min(Math.max(Number(this.options.serviceRequestTimeout) || 0, 0), 3600);
  44. this.options.serviceRequestTimeout = serviceRequestTimeout || 5 * 60;
  45. }
  46. this.logger = shared.getLogger(
  47. {
  48. logger
  49. },
  50. {
  51. component: this.options.component || 'OAuth2'
  52. }
  53. );
  54. this.provisionCallback = typeof this.options.provisionCallback === 'function' ? this.options.provisionCallback : false;
  55. this.options.accessUrl = this.options.accessUrl || 'https://accounts.google.com/o/oauth2/token';
  56. this.options.customHeaders = this.options.customHeaders || {};
  57. this.options.customParams = this.options.customParams || {};
  58. this.accessToken = this.options.accessToken || false;
  59. if (this.options.expires && Number(this.options.expires)) {
  60. this.expires = this.options.expires;
  61. } else {
  62. let timeout = Math.max(Number(this.options.timeout) || 0, 0);
  63. this.expires = (timeout && Date.now() + timeout * 1000) || 0;
  64. }
  65. }
  66. /**
  67. * Returns or generates (if previous has expired) a XOAuth2 token
  68. *
  69. * @param {Boolean} renew If false then use cached access token (if available)
  70. * @param {Function} callback Callback function with error object and token string
  71. */
  72. getToken(renew, callback) {
  73. if (!renew && this.accessToken && (!this.expires || this.expires > Date.now())) {
  74. return callback(null, this.accessToken);
  75. }
  76. let generateCallback = (...args) => {
  77. if (args[0]) {
  78. this.logger.error(
  79. {
  80. err: args[0],
  81. tnx: 'OAUTH2',
  82. user: this.options.user,
  83. action: 'renew'
  84. },
  85. 'Failed generating new Access Token for %s',
  86. this.options.user
  87. );
  88. } else {
  89. this.logger.info(
  90. {
  91. tnx: 'OAUTH2',
  92. user: this.options.user,
  93. action: 'renew'
  94. },
  95. 'Generated new Access Token for %s',
  96. this.options.user
  97. );
  98. }
  99. callback(...args);
  100. };
  101. if (this.provisionCallback) {
  102. this.provisionCallback(this.options.user, !!renew, (err, accessToken, expires) => {
  103. if (!err && accessToken) {
  104. this.accessToken = accessToken;
  105. this.expires = expires || 0;
  106. }
  107. generateCallback(err, accessToken);
  108. });
  109. } else {
  110. this.generateToken(generateCallback);
  111. }
  112. }
  113. /**
  114. * Updates token values
  115. *
  116. * @param {String} accessToken New access token
  117. * @param {Number} timeout Access token lifetime in seconds
  118. *
  119. * Emits 'token': { user: User email-address, accessToken: the new accessToken, timeout: TTL in seconds}
  120. */
  121. updateToken(accessToken, timeout) {
  122. this.accessToken = accessToken;
  123. timeout = Math.max(Number(timeout) || 0, 0);
  124. this.expires = (timeout && Date.now() + timeout * 1000) || 0;
  125. this.emit('token', {
  126. user: this.options.user,
  127. accessToken: accessToken || '',
  128. expires: this.expires
  129. });
  130. }
  131. /**
  132. * Generates a new XOAuth2 token with the credentials provided at initialization
  133. *
  134. * @param {Function} callback Callback function with error object and token string
  135. */
  136. generateToken(callback) {
  137. let urlOptions;
  138. let loggedUrlOptions;
  139. if (this.options.serviceClient) {
  140. // service account - https://developers.google.com/identity/protocols/OAuth2ServiceAccount
  141. let iat = Math.floor(Date.now() / 1000); // unix time
  142. let tokenData = {
  143. iss: this.options.serviceClient,
  144. scope: this.options.scope || 'https://mail.google.com/',
  145. sub: this.options.user,
  146. aud: this.options.accessUrl,
  147. iat,
  148. exp: iat + this.options.serviceRequestTimeout
  149. };
  150. let token = this.jwtSignRS256(tokenData);
  151. urlOptions = {
  152. grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
  153. assertion: token
  154. };
  155. loggedUrlOptions = {
  156. grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
  157. assertion: tokenData
  158. };
  159. } else {
  160. if (!this.options.refreshToken) {
  161. return callback(new Error('Can\x27t create new access token for user'));
  162. }
  163. // web app - https://developers.google.com/identity/protocols/OAuth2WebServer
  164. urlOptions = {
  165. client_id: this.options.clientId || '',
  166. client_secret: this.options.clientSecret || '',
  167. refresh_token: this.options.refreshToken,
  168. grant_type: 'refresh_token'
  169. };
  170. loggedUrlOptions = {
  171. client_id: this.options.clientId || '',
  172. client_secret: (this.options.clientSecret || '').substr(0, 6) + '...',
  173. refresh_token: (this.options.refreshToken || '').substr(0, 6) + '...',
  174. grant_type: 'refresh_token'
  175. };
  176. }
  177. Object.keys(this.options.customParams).forEach(key => {
  178. urlOptions[key] = this.options.customParams[key];
  179. loggedUrlOptions[key] = this.options.customParams[key];
  180. });
  181. this.logger.debug(
  182. {
  183. tnx: 'OAUTH2',
  184. user: this.options.user,
  185. action: 'generate'
  186. },
  187. 'Requesting token using: %s',
  188. JSON.stringify(loggedUrlOptions)
  189. );
  190. this.postRequest(this.options.accessUrl, urlOptions, this.options, (error, body) => {
  191. let data;
  192. if (error) {
  193. return callback(error);
  194. }
  195. try {
  196. data = JSON.parse(body.toString());
  197. } catch (E) {
  198. return callback(E);
  199. }
  200. if (!data || typeof data !== 'object') {
  201. this.logger.debug(
  202. {
  203. tnx: 'OAUTH2',
  204. user: this.options.user,
  205. action: 'post'
  206. },
  207. 'Response: %s',
  208. (body || '').toString()
  209. );
  210. return callback(new Error('Invalid authentication response'));
  211. }
  212. let logData = {};
  213. Object.keys(data).forEach(key => {
  214. if (key !== 'access_token') {
  215. logData[key] = data[key];
  216. } else {
  217. logData[key] = (data[key] || '').toString().substr(0, 6) + '...';
  218. }
  219. });
  220. this.logger.debug(
  221. {
  222. tnx: 'OAUTH2',
  223. user: this.options.user,
  224. action: 'post'
  225. },
  226. 'Response: %s',
  227. JSON.stringify(logData)
  228. );
  229. if (data.error) {
  230. return callback(new Error(data.error));
  231. }
  232. if (data.access_token) {
  233. this.updateToken(data.access_token, data.expires_in);
  234. return callback(null, this.accessToken);
  235. }
  236. return callback(new Error('No access token'));
  237. });
  238. }
  239. /**
  240. * Converts an access_token and user id into a base64 encoded XOAuth2 token
  241. *
  242. * @param {String} [accessToken] Access token string
  243. * @return {String} Base64 encoded token for IMAP or SMTP login
  244. */
  245. buildXOAuth2Token(accessToken) {
  246. let authData = ['user=' + (this.options.user || ''), 'auth=Bearer ' + (accessToken || this.accessToken), '', ''];
  247. return Buffer.from(authData.join('\x01'), 'utf-8').toString('base64');
  248. }
  249. /**
  250. * Custom POST request handler.
  251. * This is only needed to keep paths short in Windows – usually this module
  252. * is a dependency of a dependency and if it tries to require something
  253. * like the request module the paths get way too long to handle for Windows.
  254. * As we do only a simple POST request we do not actually require complicated
  255. * logic support (no redirects, no nothing) anyway.
  256. *
  257. * @param {String} url Url to POST to
  258. * @param {String|Buffer} payload Payload to POST
  259. * @param {Function} callback Callback function with (err, buff)
  260. */
  261. postRequest(url, payload, params, callback) {
  262. let returned = false;
  263. let chunks = [];
  264. let chunklen = 0;
  265. let req = fetch(url, {
  266. method: 'post',
  267. headers: params.customHeaders,
  268. body: payload,
  269. allowErrorResponse: true
  270. });
  271. req.on('readable', () => {
  272. let chunk;
  273. while ((chunk = req.read()) !== null) {
  274. chunks.push(chunk);
  275. chunklen += chunk.length;
  276. }
  277. });
  278. req.once('error', err => {
  279. if (returned) {
  280. return;
  281. }
  282. returned = true;
  283. return callback(err);
  284. });
  285. req.once('end', () => {
  286. if (returned) {
  287. return;
  288. }
  289. returned = true;
  290. return callback(null, Buffer.concat(chunks, chunklen));
  291. });
  292. }
  293. /**
  294. * Encodes a buffer or a string into Base64url format
  295. *
  296. * @param {Buffer|String} data The data to convert
  297. * @return {String} The encoded string
  298. */
  299. toBase64URL(data) {
  300. if (typeof data === 'string') {
  301. data = Buffer.from(data);
  302. }
  303. return data
  304. .toString('base64')
  305. .replace(/[=]+/g, '') // remove '='s
  306. .replace(/\+/g, '-') // '+' → '-'
  307. .replace(/\//g, '_'); // '/' → '_'
  308. }
  309. /**
  310. * Creates a JSON Web Token signed with RS256 (SHA256 + RSA)
  311. *
  312. * @param {Object} payload The payload to include in the generated token
  313. * @return {String} The generated and signed token
  314. */
  315. jwtSignRS256(payload) {
  316. payload = ['{"alg":"RS256","typ":"JWT"}', JSON.stringify(payload)].map(val => this.toBase64URL(val)).join('.');
  317. let signature = crypto
  318. .createSign('RSA-SHA256')
  319. .update(payload)
  320. .sign(this.options.privateKey);
  321. return payload + '.' + this.toBase64URL(signature);
  322. }
  323. }
  324. module.exports = XOAuth2;