123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366 |
- 'use strict';
- const Stream = require('stream').Stream;
- const fetch = require('../fetch');
- const crypto = require('crypto');
- const shared = require('../shared');
- /**
- * 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
- *
- * Usage for generating access tokens with a custom method using provisionCallback:
- * provisionCallback(user, renew, callback)
- * * user is the username to get the token for
- * * renew is a boolean that if true indicates that existing token failed and needs to be renewed
- * * callback is the callback to run with (error, accessToken [, expires])
- * * accessToken is a string
- * * expires is an optional expire time in milliseconds
- * If provisionCallback is used, then Nodemailer does not try to attempt generating the token by itself
- *
- * @constructor
- * @param {Object} options Client information for token generation
- * @param {String} options.user User e-mail address
- * @param {String} options.clientId Client ID value
- * @param {String} options.clientSecret Client secret value
- * @param {String} options.refreshToken Refresh token for an user
- * @param {String} options.accessUrl Endpoint for token generation, defaults to 'https://accounts.google.com/o/oauth2/token'
- * @param {String} options.accessToken An existing valid accessToken
- * @param {String} options.privateKey Private key for JSW
- * @param {Number} options.expires Optional Access Token expire time in ms
- * @param {Number} options.timeout Optional TTL for Access Token in seconds
- * @param {Function} options.provisionCallback Function to run when a new access token is required
- */
- class XOAuth2 extends Stream {
- constructor(options, logger) {
- super();
- this.options = options || {};
- if (options && options.serviceClient) {
- if (!options.privateKey || !options.user) {
- setImmediate(() => this.emit('error', new Error('Options "privateKey" and "user" are required for service account!')));
- return;
- }
- let serviceRequestTimeout = Math.min(Math.max(Number(this.options.serviceRequestTimeout) || 0, 0), 3600);
- this.options.serviceRequestTimeout = serviceRequestTimeout || 5 * 60;
- }
- this.logger = shared.getLogger(
- {
- logger
- },
- {
- component: this.options.component || 'OAuth2'
- }
- );
- this.provisionCallback = typeof this.options.provisionCallback === 'function' ? this.options.provisionCallback : false;
- 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.accessToken = this.options.accessToken || false;
- if (this.options.expires && Number(this.options.expires)) {
- this.expires = this.options.expires;
- } else {
- let timeout = Math.max(Number(this.options.timeout) || 0, 0);
- this.expires = (timeout && Date.now() + timeout * 1000) || 0;
- }
- }
- /**
- * Returns or generates (if previous has expired) a XOAuth2 token
- *
- * @param {Boolean} renew If false then use cached access token (if available)
- * @param {Function} callback Callback function with error object and token string
- */
- getToken(renew, callback) {
- if (!renew && this.accessToken && (!this.expires || this.expires > Date.now())) {
- return callback(null, this.accessToken);
- }
- let generateCallback = (...args) => {
- if (args[0]) {
- this.logger.error(
- {
- err: args[0],
- tnx: 'OAUTH2',
- user: this.options.user,
- action: 'renew'
- },
- 'Failed generating new Access Token for %s',
- this.options.user
- );
- } else {
- this.logger.info(
- {
- tnx: 'OAUTH2',
- user: this.options.user,
- action: 'renew'
- },
- 'Generated new Access Token for %s',
- this.options.user
- );
- }
- callback(...args);
- };
- if (this.provisionCallback) {
- this.provisionCallback(this.options.user, !!renew, (err, accessToken, expires) => {
- if (!err && accessToken) {
- this.accessToken = accessToken;
- this.expires = expires || 0;
- }
- generateCallback(err, accessToken);
- });
- } else {
- this.generateToken(generateCallback);
- }
- }
- /**
- * 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}
- */
- updateToken(accessToken, timeout) {
- this.accessToken = accessToken;
- timeout = Math.max(Number(timeout) || 0, 0);
- this.expires = (timeout && Date.now() + timeout * 1000) || 0;
- this.emit('token', {
- user: this.options.user,
- accessToken: accessToken || '',
- expires: this.expires
- });
- }
- /**
- * Generates a new XOAuth2 token with the credentials provided at initialization
- *
- * @param {Function} callback Callback function with error object and token string
- */
- generateToken(callback) {
- let urlOptions;
- let loggedUrlOptions;
- if (this.options.serviceClient) {
- // service account - https://developers.google.com/identity/protocols/OAuth2ServiceAccount
- let iat = Math.floor(Date.now() / 1000); // unix time
- let tokenData = {
- iss: this.options.serviceClient,
- scope: this.options.scope || 'https://mail.google.com/',
- sub: this.options.user,
- aud: this.options.accessUrl,
- iat,
- exp: iat + this.options.serviceRequestTimeout
- };
- let token = this.jwtSignRS256(tokenData);
- urlOptions = {
- grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
- assertion: token
- };
- loggedUrlOptions = {
- grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
- assertion: tokenData
- };
- } else {
- if (!this.options.refreshToken) {
- return callback(new Error('Can\x27t create new access token for user'));
- }
- // 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'
- };
- loggedUrlOptions = {
- client_id: this.options.clientId || '',
- client_secret: (this.options.clientSecret || '').substr(0, 6) + '...',
- refresh_token: (this.options.refreshToken || '').substr(0, 6) + '...',
- grant_type: 'refresh_token'
- };
- }
- Object.keys(this.options.customParams).forEach(key => {
- urlOptions[key] = this.options.customParams[key];
- loggedUrlOptions[key] = this.options.customParams[key];
- });
- this.logger.debug(
- {
- tnx: 'OAUTH2',
- user: this.options.user,
- action: 'generate'
- },
- 'Requesting token using: %s',
- JSON.stringify(loggedUrlOptions)
- );
- this.postRequest(this.options.accessUrl, urlOptions, this.options, (error, body) => {
- let data;
- if (error) {
- return callback(error);
- }
- try {
- data = JSON.parse(body.toString());
- } catch (E) {
- return callback(E);
- }
- if (!data || typeof data !== 'object') {
- this.logger.debug(
- {
- tnx: 'OAUTH2',
- user: this.options.user,
- action: 'post'
- },
- 'Response: %s',
- (body || '').toString()
- );
- return callback(new Error('Invalid authentication response'));
- }
- let logData = {};
- Object.keys(data).forEach(key => {
- if (key !== 'access_token') {
- logData[key] = data[key];
- } else {
- logData[key] = (data[key] || '').toString().substr(0, 6) + '...';
- }
- });
- this.logger.debug(
- {
- tnx: 'OAUTH2',
- user: this.options.user,
- action: 'post'
- },
- 'Response: %s',
- JSON.stringify(logData)
- );
- if (data.error) {
- return callback(new Error(data.error));
- }
- if (data.access_token) {
- this.updateToken(data.access_token, data.expires_in);
- return callback(null, this.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
- */
- buildXOAuth2Token(accessToken) {
- let authData = ['user=' + (this.options.user || ''), 'auth=Bearer ' + (accessToken || this.accessToken), '', ''];
- return Buffer.from(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)
- */
- postRequest(url, payload, params, callback) {
- let returned = false;
- let chunks = [];
- let chunklen = 0;
- let req = fetch(url, {
- method: 'post',
- headers: params.customHeaders,
- body: payload,
- allowErrorResponse: true
- });
- req.on('readable', () => {
- let chunk;
- while ((chunk = req.read()) !== null) {
- chunks.push(chunk);
- chunklen += chunk.length;
- }
- });
- req.once('error', err => {
- if (returned) {
- return;
- }
- returned = true;
- return callback(err);
- });
- req.once('end', () => {
- if (returned) {
- return;
- }
- returned = true;
- return callback(null, Buffer.concat(chunks, chunklen));
- });
- }
- /**
- * Encodes a buffer or a string into Base64url format
- *
- * @param {Buffer|String} data The data to convert
- * @return {String} The encoded string
- */
- toBase64URL(data) {
- if (typeof data === 'string') {
- data = Buffer.from(data);
- }
- return data
- .toString('base64')
- .replace(/[=]+/g, '') // remove '='s
- .replace(/\+/g, '-') // '+' → '-'
- .replace(/\//g, '_'); // '/' → '_'
- }
- /**
- * Creates a JSON Web Token signed with RS256 (SHA256 + RSA)
- *
- * @param {Object} payload The payload to include in the generated token
- * @return {String} The generated and signed token
- */
- jwtSignRS256(payload) {
- payload = ['{"alg":"RS256","typ":"JWT"}', JSON.stringify(payload)].map(val => this.toBase64URL(val)).join('.');
- let signature = crypto
- .createSign('RSA-SHA256')
- .update(payload)
- .sign(this.options.privateKey);
- return payload + '.' + this.toBase64URL(signature);
- }
- }
- module.exports = XOAuth2;
|