Modern Oauth2 authentication for sending mails using Nodemailer nodejs - node.js

I am using nodemailer to send email in my nodejs application.
var payload = { auth:
{
user: smtpuser,
pass: smtppass
},
to : toAddr,
from : emailfrom,
cc : ccAddr,
subject : subject,
html : content,
attachments: attachments
};
var transporter = nodemailer.createTransport(
{ host: payload.host || 'smtp.office365.com', // Office 365 server
port: payload.port || 587, // secure SMTP
secure:payload.secure || false, // false for TLS - as a boolean not string - but the default is false so just remove this completely
auth: payload.auth,
debug: true,
tls: payload.tls || {ciphers: 'SSLv3'}
});
transporter.sendMail(payload, function (error, info) {
if (error) {
return console.log(error);
}
updateMessage(updatedMsg);
});
I started getting this error:
Error: Invalid log in: 535 5.7.3 Authentication unsuccessful [SN4PR0601CA0002.namprd06.prod.outlook.com]
It seems my team has now disabled basic authentication.
I need to implement modern authentication(Oauth2) to be able to send mails via nodemailer using the outlook id.
Does anyone have any idea about this?
What configuration(code) changes will that require ?

After long time discovering how to send an email from your server with OAuth2, I end up with this working example.
Create an app https://go.microsoft.com/fwlink/?linkid=2083908
Add permissions in {Your APP admin panel} > API permissions > Add permission > Microsoft Graph > Application permissions > Mail.Send > Add permissions
Create certificate to get client secret {Your APP admin panel} > Certificates & secrets > Client secrets > New client secret (Save the "Value" string somewhere this is your client_secret)
Make sure you have installed required node apps
Now you can run this code to send any email from your server:
const msal = require('#azure/msal-node');
const fetch = require('node-fetch');
const clientSecret = process.env.CLIENT_SECRET;
const clientId = process.env.CLIENT_ID;
const tenantId = process.env.TENANT_ID;
const aadEndpoint =
process.env.AAD_ENDPOINT || 'https://login.microsoftonline.com';
const graphEndpoint =
process.env.GRAPH_ENDPOINT || 'https://graph.microsoft.com';
const msalConfig = {
auth: {
clientId,
clientSecret,
authority: aadEndpoint + '/' + tenantId,
},
};
const tokenRequest = {
scopes: [graphEndpoint + '/.default'],
};
const cca = new msal.ConfidentialClientApplication(msalConfig);
const tokenInfo = await cca.acquireTokenByClientCredential(tokenRequest);
const mail = {
subject: 'Microsoft Graph JavaScript Sample',
//This "from" is optional if you want to send from group email. For this you need to give permissions in that group to send emails from it.
from: {
emailAddress: {
address: 'noreply#company.com',
},
},
toRecipients: [
{
emailAddress: {
address: 'someemail#domain.com',
},
},
],
body: {
content:
'<h1>MicrosoftGraph JavaScript Sample</h1>This is the email body',
contentType: 'html',
},
};
const headers = new fetch.Headers();
const bearer = `Bearer ${tokenInfo.accessToken}`;
headers.append('Authorization', bearer);
headers.append('Content-Type', 'application/json');
const options = {
method: 'POST',
headers,
body: JSON.stringify({ message: mail, saveToSentItems: false }),
};
await fetch(
graphEndpoint + '/v1.0/users/youroutlookemail#company.com/sendMail',
options
);
Also take a look here for email setting:
https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0&tabs=javascript
May be it will help someone ;)

I'd suggest that you use Microsoft Graph to send emails. There's an easy to use REST API that works greatly with OAuth.
Please find below some links to help you build this out quickly.
https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0&tabs=http
https://learn.microsoft.com/en-us/graph/auth/auth-concepts?view=graph-rest-1.0
https://learn.microsoft.com/en-us/graph/tutorials/node?view=graph-rest-1.0

SMTP is being limited (for Office365)
Microsoft is moving to discourage direct SMTP protocol access (regardless whether auth is basic or modern), especially so for automated scripts/jobs. The proposed best practice (for Office365) is to stop using SMTP protocol and instead use the Microsoft Graph API to send emails.
NodeMailer (as of Oct-2022) has transports for SMTP, sendmail, SES - no support yet for Microsoft Graph transport. [Feature Request].
Implementation: Send Email (via Microsoft Graph-> sendMail)
Javascript/Node10+, Tested ~Nov 2022. Heavily sourced from Bogdan Le's answer
let tenantID = "" // Get from Azure App Registration
let oAuthClientID = "" // Get from Azure App Registration
let clientSecret = "" // Get from Azure App Registration
let oAuthToken; // declared, gets defined if successfully fetched
let userFrom = "foo#bar.com"
let msgPayload = {
//Ref: https://learn.microsoft.com/en-us/graph/api/resources/message#properties
message: {
subject: 'Test',
body: {
contentType: 'HTML',
content: 'Test123'
},
toRecipients: [{emailAddress: {address: 'meganb#contoso.onmicrosoft.com'}}]
}
};
const axios = require('axios'); //using axios as http helper
await axios({ // Get OAuth token to connect as OAuth client
method: 'post',
url: `https://login.microsoftonline.com/${tenantID}/oauth2/token`,
data: new URLSearchParams({
client_id: oAuthClientID,
client_secret: clientSecret,
resource: "https://graph.microsoft.com",
grant_type: "client_credentials"
}).toString()
})
.then(r => oAuthToken = r.data.access_token)
await axios ({ // Send Email using Microsoft Graph
method: 'post',
url: `https://graph.microsoft.com/v1.0/users/${userFrom}/sendMail`,
headers: {
'Authorization': "Bearer " + oAuthToken,
'Content-Type': 'application/json'
},
data: msgPayload
})
Appendix 1: Create Azure oAuth App for sending emails
Create an Azure App Registration
Add mail sending permission: Azure App Registration Admin > API permissions > Add permission > Microsoft Graph > Application permissions > Mail.Send
WARNING: You will want to limit access of the app registration to specific mailboxes using application access policy. By default
its tenant wide - allows impersonation of any user's mailbox!
Grant Admin Consent to permission to your newly created Mail.Send permission (likely requires Global Admin)
Create Application Password: Azure App Registration Admin > Certificates and secrets > Client Secrets -> New client secret (note the generated secret)
Create a reminder/plan to refresh this secret every expiration period (in Azure as well as your own code)
Appendix 2: Limit Mail.Send permission
Office 365 Admin: Create a mail-enabled security group (note the group name)
Add Members to that group: Add user(s) whose mailbox you want your App Registration to be able impersonate via Mail.Send.
Create (and test) Application Access Policy, via Powershell:
##Variables - Manually define these:
$AppClientID="" #ClientID from Azure App Registration
$Group="" #Name of Office365 group
$ForbiddenMailbox="" #Address of mailbox AppRegistration should NOT have permission to
$AllowedMailbox="" #Address of mailbox AppRegistration should have permission to
##Connect to Exchange
Install-Module -Name ExchangeOnlineManagement
Connect-ExchangeOnline
##Create ApplicationAccessPolicy
New-ApplicationAccessPolicy -AppId $AppClientID -PolicyScopeGroupId $Group -AccessRight RestrictAccess -Description "Limit AppRegistration mailbox permissions to subset of users"
##Test ApplicationAccessPolicy - Denied Scenario
Test-ApplicationAccessPolicy -Identity $ForbiddenMailbox -AppId $AppClientID
##Test ApplicationAccessPolicy - Granted Scenario
Test-ApplicationAccessPolicy -Identity $AllowedMailbox -AppId $AppClientID

Related

Azure B2C login with Cypress using cy.request()

Our app requires Azure b2c authentication when login in. I automated the login by simply typing the username and password. In addition I had to add "chromeWebSecurity": false to avoid cross-origin issue.
I think this is the incorrect way of login in. While I was searching for a solution I came across some articles regarding this. Apparently we could user Cy.request() and get the response and then access the app.
However, I was unable to implement this.
Has anyone implemented the Azure b2c login automation with cypress ? if so can someone explain how this needs to be done ?
Our app uses access bearer tokens.
It first sends authentication requests
GET https://{tenant}.b2clogin.com/{tenant}.onmicrosoft.com/oauth2/v2.0/authorize
Then gets the token
POST https://{tenant}.b2clogin.com/{tenant}.onmicrosoft.com/oauth2/v2.0/token
Thanks
I'm not too familiar with Azure b2c authentication, but looks like the general pattern is given here How to use Cypress to test your SharePoint solution with an Azure AD-secured API
The bulk of the code is this
Cypress.Commands.add("visitWithAdal", (pageUrl) => {
const config = {
username: process.env.CI ? Cypress.env('USERNAME') : Cypress.env('username'),
password: process.env.CI ? Cypress.env('PASSWORD') : Cypress.env('password'),
tenant: process.env.CI ? Cypress.env('TENANT') : Cypress.env('tenant'),
clientId: process.env.CI ? Cypress.env('CLIENTID') : Cypress.env('clientid'),
clientSecret: process.env.CI ? Cypress.env('CLIENTSECRET') : Cypress.env('clientsecret'),
resource: process.env.CI ? Cypress.env('RESOURCE') : Cypress.env('resource')
};
// Fetch the access token for the Microsoft Graph
cy.request({
method: 'POST',
url: `https://login.microsoft.com/${config.tenant}/oauth2/token`,
header: {
'cache-control': 'no-cache',
'Content-Type': 'application/x-www-form-urlencoded'
},
form: true,
body: {
grant_type: 'password',
client_id: config.clientId,
client_secret: config.clientSecret,
resource: config.resource,
password: config.password,
username: config.username
}
}).then(response => {
if (response && response.status === 200 && response.body) {
const accessToken = response.body["access_token"];
const expires = response.body["expires_on"];
// Store the retrieved access token in the session storage
cy.window().then((crntWindow) => {
crntWindow.sessionStorage.setItem(`adal.token.keys`, `${config.resource}|`);
crntWindow.sessionStorage.setItem(`adal.expiration.key${config.resource}`, expires);
crntWindow.sessionStorage.setItem(`adal.access.token.key${config.resource}`, accessToken);
cy.visit(pageUrl);
});
}
});
});

How to get Azure DevOps PAT list with the help of MSAL using Node.js

I want to get all Azure DevOps PAT list in my Azure function app. I am using Node.Js in Azure function app.
To get the Azure DevOps PAT list, I'm using this REST API.
So for Authentication I'm using MSAL library. So after getting the auth token, when I'm using it to call the Azure DevOps PAT list REST API then I'm not getting the PAT list. See below for my function app code.
'use strict';
const config = require('../config');
const rp = require('request-promise');
const msal = require('#azure/msal-node');
module.exports = async function (context, req) {
const clientId = 'config.DEFAULT_CLIENT_ID';
const clientSecret = 'config.DEFAULT_CLIENT_SECRET';
const tenantId = 'config.DEFAULT_TENANT_ID';
let authorityHostUrl = 'https://login.windows.net';
let authorityUrl = authorityHostUrl + '/' + tenantId;
const configuration = {
auth: {
clientId: clientId,
authority: authorityUrl,
clientSecret: clientSecret
}
};
// Create msal application object
const cca = new msal.ConfidentialClientApplication(configuration);
// With client credentials flows permissions need to be granted in the portal by a tenant administrator.
// The scope is always in the format "<resource>/.default"
const clientCredentialRequest = {
scopes: ["499b84ac-1321-427f-aa17-267ca6975798/.default"], // replace with your resource
};
const credentials = await cca.acquireTokenByClientCredential(clientCredentialRequest);
const tokenType = credentials.tokenType;
const token = credentials.accessToken;
const apiToken = `${tokenType} ${token}`; // 'Bearer <token>'
let url = `https://vssps.dev.azure.com/{organization}/_apis/tokens/pats?api-version=6.1-preview.1`;
const header = {
Authorization: `${apiToken}`
};
const result = await rp({
url: url,
json: true,
headers: header,
mode: 'cors',
cache: 'no-cache',
method: 'GET'
});
context.res = {
body: result
};
}
I am not getting the list of PAT but getting the below output:
Anonymous Sign out
Microsoft Internet Explorer's Enhanced Security Configuration is currently enabled on your environment. This enhanced level of security prevents our web integration experiences from displaying or performing correctly. To continue with your operation please disable this configuration or contact your administrator.
Why am I not getting the PAT list?
Please show the reference status of your request, from the description, it seems you are using PAT.
PAT is basic auth. But in your code you mentioned // 'Bearer <token>'

How Setup Azure AD and Cypress so SSO Login works

I try to automate testing with cypress.
For the app to work the user needs to login.
So i try to automate this step.
I wrote following command which i pass in the username password.
Cypress.Commands.add('login', ( userId, pwd ) => {
const domain = 'XXXX'
const clientId = 'XXXX'
const clientSecret = 'XXXX'
const scope = 'XXXX'
cy.request({
method:'POST',
url:`https://login.microsoftonline.com/${domain}/oauth2/v2.0/token`,
header:
{
'cache-control': 'no-cache',
'Content-Type': 'application/x-www-form-urlencoded'
},
form: true,
body: {
grant_type: 'password',
client_id: clientId,
client_secret: clientSecret,
password: pwd,
scope: scope,
username: userId
}
})
.then((resp)=>{
const token = resp.body['access_token']
console.log(token)
//cy.visit('http://{URL}/auth/#access_token='+token)
})
})
With this i get the error message back. That the Username or password is wrong.
Anyone knows how to fix this? or know a better way to setup Cypress with a SSO from azure AD?
The Azure AD ROPC flow should work, when using it, make sure you are using a work account whose format is xxx#xxx.onmicrosoft.com, and it is not MFA-enabled, details see screenshot below. If you don't have such an account, follow this to create one.
In your case, please double-check the username and password, make sure they are correct.(If they are correct, please make sure there is no escape issue in the actual request.)
I test the flow in the postman, it works fine.

Is there a way to programatically login to using AzureAD with Cypress on PKCE flow?

I want to athenticate myself (React application) using cypress.js (https://www.cypress.io/). Is there a way to do it programatically with PKCE? As i was reading and looking into all examples - all of them are using implicit flow
I was trying to use solutions like https://www.npmjs.com/package/react-adal but with no success as it needs an implicit flow to be turned on
I was trying this as well: https://xebia.com/blog/how-to-use-azure-ad-single-sign-on-with-cypress/ with no success
I expected to programatically signin inside cypress and save user info and access_token to sessionStorage to be able to perform another api calls
I have not found a way to do this programmatically with PKCE per se, but with the MSAL 2.0 library (#azure/msal-browser on npm) I was able to fill in the account cache ahead of time so it thought it was already logged in. The process looks like this:
With a cy.task, send a request to Azure AD using the ROPC flow to get the tokens.
const scopes = [
'openid',
'profile',
'user.read',
'email',
'offline_access' // needed to get a refresh token
];
const formdata = new URLSearchParams({
'grant_type': 'password',
'scope': scopes.join(' '),
'client_info': 1, // returns an extra token that MSAL needs
'client_id': aadClientId,
'client_secret': aadClientSecret,
'username': aadUsername,
'password': aadPassword,
});
const response = await fetch(`https://login.microsoft.com/${aadTenantId}/oauth2/v2.0/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formdata.toString(),
});
const tokens = await response.json();
Transform the tokens into the cache entries that MSAL wants (based on observing it in a real browser)
// The token tells us how many seconds until expiration;
// MSAL wants to know the timestamp of expiration.
const cachedAt = Math.round(new Date().getTime()/1000);
const expiresOn = cachedAt + tokens.expires_in;
const extendedExpiresOn = cachedAt + tokens.ext_expires_in;
// We can pull the rest of the data we need off of the ID token body
const id_token = JSON.parse(Buffer.from(tokens.id_token.split('.')[1], 'base64').toString('utf-8'));
const clientId = id_token.aud;
const tenantId = id_token.tid;
const userId = id_token.oid;
const name = id_token.name;
const username = id_token.preferred_username;
const environment = 'login.windows.net'; // 🤷‍♂️
const homeAccountId = `${userId}.${tenantId}`;
const cacheEntries = {};
// client info
cacheEntries[`${homeAccountId}-${environment}-${tenantId}`] = JSON.stringify({
authorityType: 'MSSTS',
clientInfo: tokens.client_info,
environment,
homeAccountId,
localAccountId: userId,
name,
realm: tenantId,
username,
});
// access token
cacheEntries[`${homeAccountId}-${environment}-accesstoken-${clientId}-${tenantId}-${token.scope}`] = JSON.stringify({
cachedAt: cachedAt.toString(),
clientId,
credentialType: "AccessToken",
environment,
expiresOn: expiresOn.toString(),
extendedExpiresOn: extendedExpiresOn.toString(),
homeAccountId,
realm: tenantId,
secret: tokens.access_token,
target: tokens.scope,
});
// id token
cacheEntries[`${homeAccountId}-${environment}-idtoken-${clientId}-${tenantId}-`] = JSON.stringify({
clientId,
credentialType: "IdToken",
environment,
homeAccountId,
realm: tenantId,
secret: tokens.id_token,
});
// refresh token
cacheEntries[`${homeAccountId}-${environment}-refreshtoken-${clientId}--`] = JSON.stringify({
clientId,
credentialType: "RefreshToken",
environment,
homeAccountId,
secret: tokens.refresh_token,
});
Use cy.window to store those in sessionStorage or localStorage, depending on how you have MSAL configured.
cy.task('login').then(cacheEntries => {
cy.window().then(window => {
for (let entry in cacheEntries) {
window.sessionStorage.setItem(entry, cacheEntries[entry]);
}
});
});
It's super fragile and not very pretty, but it works! The user that Cypress logs in as needs MFA to be disabled, of course.

Send mail via Google Apps Gmail using service account domain wide delegation in nodejs

I've been reading tutorials and seeing examples for 2 days already, with no success.
I want to send an email using Google Apps Gmail account in NodeJS environment, however, i get 400 response from Google API:
{[Error: Bad Request]
code: 400,
errors:
[{
domain: 'global',
reason: 'failedPrecondition',
message: 'Bad Request'
}]
}
Here's what I've done so far:
Created a project in Google Cloud Platform
Created a service account
Enabled Domain Wide Delegation for the service account
Downloaded the key for the service account in JSON format
API Manager > Credentials i have created OAuth 2.0 client ID
Enabled Gmail API for the project
In Google Apps Admin console:
In Security > Advanced Settings > Manage API client access i have added the Client ID from step 4 above
I have added all possible scopes for the Client ID
Here's the code that tries to send an email:
const google = require('googleapis');
const googleKey = require('./google-services.json');
const jwtClient = new google.auth.JWT(googleKey.client_email, null, googleKey.private_key, ['https://www.googleapis.com/auth/gmail.send'], null);
jwtClient.authorize((err, tokens) => {
if (err) {
console.err(err);
return;
}
console.log('Google auth success')
var gmail = google.gmail({version: 'v1', auth: jwtClient})
var raw = <build base64 string according to RFC 2822 specification>
var sendMessage = gmail.users.messages.send({
auth: jwtClient,
userId: 'user#domain.com',
message: {
raw: raw
}
}, (err, res) => {
if (err) {
console.error(err);
} else {
console.log(res);
}
});
I can see the Google auth success message and the request is sent with properly initialized token:
headers:
{ Authorization: 'Bearer ya29.CjLjAtVjGNJ8fcBnMSS8AEXAvIm4TbyNTc6g_S99HTxRVmzKpWrAv9ioTI4BsLKXW7uojQ',
'User-Agent': 'google-api-nodejs-client/0.9.8',
host: 'www.googleapis.com',
accept: 'application/json',
'content-type': 'application/json',
'content-length': 2 }
But still, the response is 400
So I was half-step close to the solution, the problem was that while creating const jwtClient = new google.auth.JWT(googleKey.client_email, null, googleKey.private_key, ['https://www.googleapis.com/auth/gmail.send'], null); i did not mention the account to be impersonated.
The correct initialization should be:
const jwtClient = new google.auth.JWT(googleKey.client_email, null, googleKey.private_key, ['https://www.googleapis.com/auth/gmail.send'], 'user#domain.com');
To summarize, the correct steps are:
Created a project in Google Cloud Platform
Created a service account
Enabled Domain Wide Delegation for the service account
Downloaded the key for the service account in JSON format
API Manager > Credentials i have created OAuth 2.0 Client ID
Enabled Gmail API for the project
In Google Apps Admin console:
In Security > Advanced Settings > Manage API client access i have added the Client ID from step 4 above
I have added all possible scopes for the Client ID
This is the code that sends mails:
const google = require('googleapis');
const googleKey = require('./google-services.json');
const jwtClient = new google.auth.JWT(googleKey.client_email, null, googleKey.private_key, ['https://www.googleapis.com/auth/gmail.send'], '<user to impersonate>');
jwtClient.authorize((err, tokens) => {
if (err) {
console.err(err);
return;
}
console.log('Google auth success')
var gmail = google.gmail({version: 'v1'})
var raw = <build base64 string according to RFC 2822 specification>
var sendMessage = gmail.users.messages.send({
auth: jwtClient,
userId: '<user to impersonate>',
resource: {
raw: raw
}
}, (err, res) => {
if (err) {
console.error(err);
} else {
console.log(res);
}
});
Hope that would be helpful for others
Thanks very much #agoldis. Both the summary steps and the code were very helpful.
It helped me pick up on a few things I needed to fix on both the GoogleWorkspace and the ApplicationCode end of the communication path. Below is my c# implementation for anyone who needs it.
private static void Try4()
{
try {
//file included in project with properties: CopyToOutputDirectory = CopyAlways
var credential = GoogleCredential.FromFile("MyPrj-MyServiceAccount-Credentials.json")
.CreateScoped(new[] { GmailService.Scope.GmailSend })
.CreateWithUser("WhoIAmImpersonating#bla.com")
.UnderlyingCredential as ServiceAccountCredential;
var service = new GmailService(new BaseClientService.Initializer() { HttpClientInitializer = credential });
var nl = Environment.NewLine;
string plainText = "From: WhoIAmImpersonating#bla.com"
+ nl + "To: myfriend#gmail.com,"
+ nl + "Subject: This is the Subject"
+ nl + "Content-Type: text/html; charset=us-ascii"
+ nl
+ nl + "This is the message text.";
var newMsg = new Message() { Raw = Base64UrlEncode(plainText) };
service.Users.Messages.Send(newMsg, "WhoIAmImpersonating#bla.com").Execute();
Console.WriteLine("Message Sent OK");
}
catch (Exception ex) {
Console.WriteLine("Message failed");
}
}
private static string Base64UrlEncode(string input)
{
var inputBytes = System.Text.Encoding.UTF8.GetBytes(input);
return Convert.ToBase64String(inputBytes)
.Replace('+', '-')
.Replace('/', '_')
.Replace("=", "" );
}

Resources