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