2026-09-15 13:00:32 +02:00
import { Stream } from 'node:stream' ;
import nmfetch from '../fetch/index.js' ;
import crypto from 'node:crypto' ;
import * as shared from '../shared/index.js' ;
import * as errors from '../errors.js' ;
2020-02-29 23:01:03 +01:00
/**
* 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
2026-09-15 13:00:32 +02:00
* @param options Client information for token generation
* @param options.user User e-mail address
* @param options.clientId Client ID value
* @param options.clientSecret Client secret value
* @param options.refreshToken Refresh token for an user
* @param options.accessUrl Endpoint for token generation, defaults to 'https://accounts.google.com/o/oauth2/token'
* @param options.accessToken An existing valid accessToken
* @param options.privateKey Private key for JSW
* @param options.expires Optional Access Token expire time in ms
* @param options.timeout Optional TTL for Access Token in seconds
* @param options.provisionCallback Function to run when a new access token is required
* @param options.tls Optional TLS options forwarded to the HTTPS token request. Defaults to strict cert validation; supply { rejectUnauthorized: false } only for self-hosted OAuth providers on private CAs.
2020-02-29 23:01:03 +01:00
*/
class XOAuth2 extends Stream {
constructor ( options , logger ) {
super ();
this . options = options || {};
if ( options && options . serviceClient ) {
if ( ! options . privateKey || ! options . user ) {
2026-04-28 12:50:45 +02:00
const err = new Error ( 'Options "privateKey" and "user" are required for service account!' );
2026-02-05 08:49:11 +01:00
err . code = errors . EOAUTH2 ;
setImmediate (() => this . emit ( 'error' , err ));
2020-02-29 23:01:03 +01:00
return ;
}
2026-04-28 12:50:45 +02:00
const serviceRequestTimeout = Math . min ( Math . max ( Number ( this . options . serviceRequestTimeout ) || 0 , 0 ), 3600 );
2020-02-29 23:01:03 +01:00
this . options . serviceRequestTimeout = serviceRequestTimeout || 5 * 60 ;
}
2026-09-15 13:00:32 +02:00
this . logger = shared . getLogger ({
logger
}, {
component : this . options . component || 'OAuth2'
});
2020-02-29 23:01:03 +01:00
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 ;
2026-09-15 13:00:32 +02:00
}
else {
2026-04-28 12:50:45 +02:00
const timeout = Math . max ( Number ( this . options . timeout ) || 0 , 0 );
2020-02-29 23:01:03 +01:00
this . expires = ( timeout && Date . now () + timeout * 1000 ) || 0 ;
}
2025-12-25 10:58:28 +01:00
this . renewing = false ; // Track if renewal is in progress
this . renewalQueue = []; // Queue for pending requests during renewal
2020-02-29 23:01:03 +01:00
}
/**
* Returns or generates (if previous has expired) a XOAuth2 token
*
2026-09-15 13:00:32 +02:00
* @param renew If false then use cached access token (if available)
* @param callback Callback function with error object and token string
2020-02-29 23:01:03 +01:00
*/
getToken ( renew , callback ) {
2026-10-01 09:48:38 +02:00
// the error paths hand over the error alone
const done = callback ;
2020-02-29 23:01:03 +01:00
if ( ! renew && this . accessToken && ( ! this . expires || this . expires > Date . now ())) {
2026-09-15 13:00:32 +02:00
this . logger . debug ({
tnx : 'OAUTH2' ,
user : this . options . user ,
action : 'reuse'
}, 'Reusing existing access token for %s' , this . options . user );
2020-02-29 23:01:03 +01:00
return callback ( null , this . accessToken );
}
2025-12-25 10:58:28 +01:00
// check if it is possible to renew, if not, return the current token or error
if ( ! this . provisionCallback && ! this . options . refreshToken && ! this . options . serviceClient ) {
if ( this . accessToken ) {
2026-09-15 13:00:32 +02:00
this . logger . debug ({
2025-12-25 10:58:28 +01:00
tnx : 'OAUTH2' ,
user : this . options . user ,
2026-09-15 13:00:32 +02:00
action : 'reuse'
}, 'Reusing existing access token (no refresh capability) for %s' , this . options . user );
return callback ( null , this . accessToken );
}
this . logger . error ({
tnx : 'OAUTH2' ,
user : this . options . user ,
action : 'renew'
}, 'Cannot renew access token for %s: No refresh mechanism available' , this . options . user );
2026-04-28 12:50:45 +02:00
const err = new Error ( "Can't create new access token for user" );
2026-02-05 08:49:11 +01:00
err . code = errors . EOAUTH2 ;
2026-10-01 09:48:38 +02:00
return done ( err );
2025-12-25 10:58:28 +01:00
}
// If renewal already in progress, queue this request instead of starting another
if ( this . renewing ) {
2026-10-01 09:48:38 +02:00
this . renewalQueue . push ({ renew , callback : done });
2026-09-15 13:00:32 +02:00
return ;
2025-12-25 10:58:28 +01:00
}
this . renewing = true ;
// Handles token renewal completion - processes queued requests and cleans up
const generateCallback = ( err , accessToken ) => {
this . renewalQueue . forEach ( item => item . callback ( err , accessToken ));
this . renewalQueue = [];
this . renewing = false ;
if ( err ) {
2026-09-15 13:00:32 +02:00
this . logger . error ({
err ,
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 );
2020-02-29 23:01:03 +01:00
}
2025-12-25 10:58:28 +01:00
// Complete original request
2026-10-01 09:48:38 +02:00
done ( err , accessToken );
2020-02-29 23:01:03 +01:00
};
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 );
});
2026-09-15 13:00:32 +02:00
}
else {
2020-02-29 23:01:03 +01:00
this . generateToken ( generateCallback );
}
}
/**
* Updates token values
*
2026-09-15 13:00:32 +02:00
* @param accessToken New access token
* @param timeout Access token lifetime in seconds
2020-02-29 23:01:03 +01:00
*
* 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
*
2026-09-15 13:00:32 +02:00
* @param callback Callback function with error object and token string
2020-02-29 23:01:03 +01:00
*/
generateToken ( callback ) {
2026-10-01 09:48:38 +02:00
// the error paths hand over the error alone
const done = callback ;
2020-02-29 23:01:03 +01:00
let urlOptions ;
let loggedUrlOptions ;
if ( this . options . serviceClient ) {
// service account - https://developers.google.com/identity/protocols/OAuth2ServiceAccount
2026-04-28 12:50:45 +02:00
const iat = Math . floor ( Date . now () / 1000 ); // unix time
const tokenData = {
2020-02-29 23:01:03 +01:00
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
};
2020-07-09 15:48:05 +02:00
let token ;
try {
token = this . jwtSignRS256 ( tokenData );
2026-09-15 13:00:32 +02:00
}
catch ( _err ) {
2026-04-28 12:50:45 +02:00
const err = new Error ( "Can't generate token. Check your auth options" );
2026-02-05 08:49:11 +01:00
err . code = errors . EOAUTH2 ;
2026-10-01 09:48:38 +02:00
return done ( err );
2020-07-09 15:48:05 +02:00
}
2020-02-29 23:01:03 +01:00
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
};
2026-09-15 13:00:32 +02:00
}
else {
2020-02-29 23:01:03 +01:00
if ( ! this . options . refreshToken ) {
2026-04-28 12:50:45 +02:00
const err = new Error ( "Can't create new access token for user" );
2026-02-05 08:49:11 +01:00
err . code = errors . EOAUTH2 ;
2026-10-01 09:48:38 +02:00
return done ( err );
2020-02-29 23:01:03 +01:00
}
// 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'
};
}
2026-04-28 12:50:45 +02:00
Object . assign ( urlOptions , this . options . customParams );
Object . assign ( loggedUrlOptions , this . options . customParams );
2026-09-15 13:00:32 +02:00
this . logger . debug ({
tnx : 'OAUTH2' ,
user : this . options . user ,
action : 'generate'
}, 'Requesting token using: %s' , JSON . stringify ( loggedUrlOptions ));
2020-02-29 23:01:03 +01:00
this . postRequest ( this . options . accessUrl , urlOptions , this . options , ( error , body ) => {
let data ;
if ( error ) {
2026-10-01 09:48:38 +02:00
return done ( error );
2020-02-29 23:01:03 +01:00
}
try {
data = JSON . parse ( body . toString ());
2026-09-15 13:00:32 +02:00
}
catch ( E ) {
2026-10-01 09:48:38 +02:00
return done ( E );
2020-02-29 23:01:03 +01:00
}
if ( ! data || typeof data !== 'object' ) {
2026-09-15 13:00:32 +02:00
this . logger . debug ({
tnx : 'OAUTH2' ,
user : this . options . user ,
action : 'post'
}, 'Response: %s' , ( body || '' ). toString ());
2026-04-28 12:50:45 +02:00
const err = new Error ( 'Invalid authentication response' );
2026-02-05 08:49:11 +01:00
err . code = errors . EOAUTH2 ;
2026-10-01 09:48:38 +02:00
return done ( err );
2020-02-29 23:01:03 +01:00
}
2026-04-28 12:50:45 +02:00
const logData = Object . assign ({}, data );
if ( logData . access_token ) {
logData . access_token = ( logData . access_token || '' ). toString (). substr ( 0 , 6 ) + '...' ;
}
2026-09-15 13:00:32 +02:00
this . logger . debug ({
tnx : 'OAUTH2' ,
user : this . options . user ,
action : 'post'
}, 'Response: %s' , JSON . stringify ( logData ));
2020-02-29 23:01:03 +01:00
if ( data . error ) {
2020-11-12 16:30:46 +01:00
// Error Response : https://tools.ietf.org/html/rfc6749#section-5.2
let errorMessage = data . error ;
2020-11-12 16:31:20 +01:00
if ( data . error_description ) {
errorMessage += ': ' + data . error_description ;
2020-11-12 16:30:46 +01:00
}
2020-11-12 16:31:20 +01:00
if ( data . error_uri ) {
errorMessage += ' (' + data . error_uri + ')' ;
2020-11-12 16:30:46 +01:00
}
2026-04-28 12:50:45 +02:00
const err = new Error ( errorMessage );
2026-02-05 08:49:11 +01:00
err . code = errors . EOAUTH2 ;
2026-10-01 09:48:38 +02:00
return done ( err );
2020-02-29 23:01:03 +01:00
}
if ( data . access_token ) {
this . updateToken ( data . access_token , data . expires_in );
return callback ( null , this . accessToken );
}
2026-04-28 12:50:45 +02:00
const err = new Error ( 'No access token' );
2026-02-05 08:49:11 +01:00
err . code = errors . EOAUTH2 ;
2026-10-01 09:48:38 +02:00
return done ( err );
2020-02-29 23:01:03 +01:00
});
}
/**
* Converts an access_token and user id into a base64 encoded XOAuth2 token
*
2026-09-15 13:00:32 +02:00
* @param [accessToken] Access token string
* @return Base64 encoded token for IMAP or SMTP login
2020-02-29 23:01:03 +01:00
*/
buildXOAuth2Token ( accessToken ) {
2026-04-28 12:50:45 +02:00
const authData = [ 'user=' + ( this . options . user || '' ), 'auth=Bearer ' + ( accessToken || this . accessToken ), '' , '' ];
2020-02-29 23:01:03 +01:00
return Buffer . from ( authData . join ( '\x01' ), 'utf-8' ). toString ( 'base64' );
}
/**
* Custom POST request handler.
2026-09-15 13:00:32 +02:00
* This is only needed to keep paths short in Windows, usually this module
2020-02-29 23:01:03 +01:00
* 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.
*
2026-09-15 13:00:32 +02:00
* @param url Url to POST to
* @param payload Payload to POST
* @param params Client options, the customHeaders and tls values are used for the request
* @param callback Callback function with (err, buff)
2020-02-29 23:01:03 +01:00
*/
postRequest ( url , payload , params , callback ) {
let returned = false ;
2026-04-28 12:50:45 +02:00
const chunks = [];
2020-02-29 23:01:03 +01:00
let chunklen = 0 ;
2026-06-15 07:32:52 +02:00
const fetchOptions = {
2020-02-29 23:01:03 +01:00
method : 'post' ,
headers : params . customHeaders ,
body : payload ,
allowErrorResponse : true
2026-06-15 07:32:52 +02:00
};
2026-09-15 13:00:32 +02:00
// OAuth2 token endpoints are credential-bearing. src/fetch already
2026-06-15 07:32:52 +02:00
// validates certs by default; pin rejectUnauthorized:true here so the
// token fetch stays strict, while still layering params.tls (the
// user's options.tls) on top so callers with a self-hosted provider on
// a private CA can override.
if ( /^https:/i . test ( url )) {
fetchOptions . tls = Object . assign ({ rejectUnauthorized : true }, params . tls || {});
}
const req = nmfetch ( url , fetchOptions );
2020-02-29 23:01:03 +01:00
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
*
2026-09-15 13:00:32 +02:00
* @param data The data to convert
* @return The encoded string
2020-02-29 23:01:03 +01:00
*/
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)
*
2026-09-15 13:00:32 +02:00
* @param payload The payload to include in the generated token
* @return The generated and signed token
2020-02-29 23:01:03 +01:00
*/
jwtSignRS256 ( payload ) {
2026-09-15 13:00:32 +02:00
const signedPayload = [ '{"alg":"RS256","typ":"JWT"}' , JSON . stringify ( payload )]. map ( val => this . toBase64URL ( val )). join ( '.' );
const signature = crypto
. createSign ( 'RSA-SHA256' )
. update ( signedPayload )
. sign ( this . options . privateKey );
return signedPayload + '.' + this . toBase64URL ( signature );
2020-02-29 23:01:03 +01:00
}
}
2026-09-15 13:00:32 +02:00
export default XOAuth2 ;