@earnaha/auth0-action-helper
Version:
AHA auth0 action helper
811 lines (729 loc) • 19.5 kB
JavaScript
/* eslint-disable no-await-in-loop */
/* eslint-disable no-console */
const axios = require('axios');
const Sentry = require('@sentry/node');
const LoggerHelper = require('./logger.js');
const { version, name } = require('../package.json');
const SIGN_UP_BY_SMS_DOMAIN = '@sign-up-by-sms.com';
class BaseHelper {
// TIP: the input arguments must be destructed
// otherwise, this parent class cannot get any data in constructor
constructor({
ENV,
SERVICE,
DOMAIN,
ACCESS_KEY,
ACCESS_SALT,
AUTH0_DOMAIN,
AUTH0_CLIENT_ID,
AUTH0_CLIENT_SECRET,
SENTRY_DSN,
SENTRY_TRACES_SAMPLE_RATE,
LINK_ACCOUNT_TIME,
OPEN_SEARCH_NODE,
DEBUG_LEVEL,
}) {
const env = ENV || 'dev';
const isPROD = env === 'prod' || env === 'production';
// TIP: service = SERVICE || isPROD ? 'aha' : `aha-${env}`; doesn't work
// always gets 'aha', cannot mixing or and ternary statement
const service = SERVICE || `aha${isPROD ? '' : `-${env}`}`;
const apiDomain =
DOMAIN || `https://${isPROD ? 'api' : `api-${env}`}.earnaha.com`;
const packageAppNameAndVersion = `${name}@${version}`;
if (env !== 'test') {
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate:
(typeof SENTRY_TRACES_SAMPLE_RATE === 'string'
? parseFloat(SENTRY_TRACES_SAMPLE_RATE)
: SENTRY_TRACES_SAMPLE_RATE) || 0.01,
});
this._SENTRY = Sentry;
}
// TIP: setters don't work in constructor, set to internal variables directly
this._APP = packageAppNameAndVersion;
this._ENV = env;
this._SERVICE = service;
this._DOMAIN = apiDomain;
this._ACCESS_KEY = ACCESS_KEY;
this._ACCESS_SALT = Number(ACCESS_SALT);
this._AUTH0_DOMAIN = AUTH0_DOMAIN;
this._AUTH0_CLIENT_ID = AUTH0_CLIENT_ID;
this._AUTH0_CLIENT_SECRET = AUTH0_CLIENT_SECRET;
this._LINK_ACCOUNT_TIME = new Date(LINK_ACCOUNT_TIME).getTime();
this._LOGGER = new LoggerHelper(env, packageAppNameAndVersion, {
CONSOLE: DEBUG_LEVEL === 'trace',
})
.setOpenSearchNode(OPEN_SEARCH_NODE)
.setSentryDsn(SENTRY_DSN)
.setSentryTracesSampleRate(SENTRY_TRACES_SAMPLE_RATE)
.setLogLevel(DEBUG_LEVEL)
.setMeta()
.initialize();
console.log('env values=>', {
OPEN_SEARCH_NODE,
DEBUG_LEVEL,
});
this._LOGGER.logger.info(
{
OPEN_SEARCH_NODE,
DEBUG_LEVEL,
DOMAIN,
ACCESS_KEY,
ACCESS_SALT,
AUTH0_DOMAIN,
AUTH0_CLIENT_ID,
AUTH0_CLIENT_SECRET,
LINK_ACCOUNT_TIME,
},
`Base.constructor | initializing arguments`,
);
}
get APP() {
return this._APP;
}
set APP(value) {
this._APP = value;
}
get ENV() {
return this._ENV;
}
set ENV(value) {
this._ENV = value;
}
get SERVICE() {
return this._SERVICE;
}
set SERVICE(value) {
this._SERVICE = value;
}
get DOMAIN() {
return this._DOMAIN;
}
set DOMAIN(value) {
this._DOMAIN = value;
}
get ACCESS_KEY() {
return this._ACCESS_KEY;
}
set ACCESS_KEY(value) {
this._ACCESS_KEY = value;
}
get ACCESS_SALT() {
return this._ACCESS_SALT;
}
set ACCESS_SALT(value) {
this._ACCESS_SALT = value;
}
get AUTH0_DOMAIN() {
return this._AUTH0_DOMAIN;
}
set AUTH0_DOMAIN(value) {
this._AUTH0_DOMAIN = value;
}
get AUTH0_CLIENT_ID() {
return this._AUTH0_CLIENT_ID;
}
set AUTH0_CLIENT_ID(value) {
this._AUTH0_CLIENT_ID = value;
}
get AUTH0_CLIENT_SECRET() {
return this._AUTH0_CLIENT_SECRET;
}
set AUTH0_CLIENT_SECRET(value) {
this._AUTH0_CLIENT_SECRET = value;
}
get LINK_ACCOUNT_TIME() {
return this._LINK_ACCOUNT_TIME;
}
set LINK_ACCOUNT_TIME(value) {
this._LINK_ACCOUNT_TIME = value;
}
get SENTRY_DSN() {
return this._SENTRY_DSN;
}
set SENTRY_DSN(value) {
this._SENTRY_DSN = value;
}
get SENTRY_TRACES_SAMPLE_RATE() {
return this._SENTRY_TRACES_SAMPLE_RATE;
}
set SENTRY_TRACES_SAMPLE_RATE(value) {
this._SENTRY_TRACES_SAMPLE_RATE = value;
}
get SENTRY_LOGGER_LEVEL() {
return this._SENTRY_LOGGER_LEVEL;
}
set SENTRY_LOGGER_LEVEL(value) {
this._SENTRY_LOGGER_LEVEL = value;
}
get SENTRY() {
return this._SENTRY;
}
set SENTRY(value) {
this._SENTRY = value;
}
get LOGGER() {
return this._LOGGER;
}
get LOG() {
return this._LOGGER.logger;
}
get DEFAULT_CONN() {
return 'Username-Password-Authentication';
}
async axiosGetWithBreadcrumb(url, options) {
try {
const res = await axios.get(url, options);
Sentry.addBreadcrumb({ url, options, res });
return res;
} catch (error) {
this.LOG.error(
{ url, options, error },
`Base.axiosGetWithBreadcrumb`,
);
throw error;
}
}
async axiosPostWithBreadcrumb(url, payload, options) {
try {
const res = await axios.post(url, payload, options);
Sentry.addBreadcrumb({ url, payload, options, res });
return res;
} catch (error) {
this.LOG.error(
{ url, payload, options, error },
`Base.axiosPostWithBreadcrumb`,
);
throw error;
}
}
async axiosPutWithBreadcrumb(url, payload, options) {
try {
const res = await axios.put(url, payload, options);
Sentry.addBreadcrumb({ url, payload, options, res });
return res;
} catch (error) {
this.LOG.error(
{ url, payload, options, error },
`Base.axiosPutWithBreadcrumb`,
);
throw error;
}
}
getUtcDate(date = new Date(), addDay = 0) {
const msUTC = Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
);
return new Date(msUTC + addDay * 86400000);
}
async getAccessToken() {
/*
const res = await axios.get(
`${this.DOMAIN}/auth/v3/tool/server-access-key?service=${this.SERVICE}` +
`&accessSalt=${this.ACCESS_SALT}&accessKey=${this.ACCESS_KEY}&roles=auth0`
);
*/
const memberId = '00000000-0000-0000-0000-000000000000';
const now = this.getUtcDate().getTime();
const tokenString = `${this.ACCESS_KEY}+${memberId}+${this.SERVICE}+${
now + this.ACCESS_SALT
}+auth0`;
const res = Buffer.from(tokenString, 'ascii').toString('base64');
return res;
}
async getServerAccessKey(auth0ApiObject) {
const cachedKey = auth0ApiObject.cache.get('accessKey');
/*
if (!cachedKey) {
const accessKey = this.getAccessToken();
if (typeof accessKey === 'string') {
auth0ApiObject.cache.set('accessKey', accessKey, {
ttl: 50 * 1000,
});
}
return accessKey;
} else {
return cachedKey;
}
*/
const accessKey = await this.getAccessToken();
this.LOGGER.setMeta({
auth0Id: auth0ApiObject?.user?.user_id,
});
this.LOG.debug(
{ cachedKey, accessKey },
`Base.getServerAccessKey | cached key checking`,
);
auth0ApiObject.cache.set('accessKey', accessKey, {
ttl: 50 * 1000,
});
return accessKey;
}
async getUserToken() {
const res = await axios.post(
`${this.AUTH0_DOMAIN}/oauth/token`,
{
client_id: this.AUTH0_CLIENT_ID,
client_secret: this.AUTH0_CLIENT_SECRET,
grant_type: 'client_credentials',
audience: `${this.AUTH0_DOMAIN}/api/v2/`,
},
{
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
},
);
return res;
}
async getUsersByEmail(email, token = null) {
if (!email || email.length <= 0) {
return null;
}
const userToken = token || (await this.getUserToken());
const res = await axios.get(
`${
this.AUTH0_DOMAIN
}/api/v2/users-by-email?email=${encodeURIComponent(
email.toLowerCase(),
)}`,
{
headers: {
Authorization: `Bearer ${userToken.data.access_token}`,
},
},
);
return res;
}
async getUserById(userId, token = null) {
const userToken = token || (await this.getUserToken());
const res = await axios.get(
`${this.AUTH0_DOMAIN}/api/v2/users/${userId}`,
{
headers: {
Authorization: `Bearer ${userToken.data.access_token}`,
},
},
);
return res;
}
async deleteUserById(userId, token = null) {
const userToken = token || (await this.getUserToken());
const res = await axios.delete(
`${this.AUTH0_DOMAIN}/api/v2/users/${userId}`,
{
headers: {
Authorization: `Bearer ${userToken.data.access_token}`,
},
},
);
return res;
}
async updateUser(user, values = {}, token = null) {
const userToken = token || (await this.getUserToken());
const res = await axios.patch(
`${this.AUTH0_DOMAIN}/api/v2/users/${user.user_id}`,
values,
{
headers: {
Authorization: `Bearer ${userToken.data.access_token}`,
},
},
);
return res;
}
async updateUserMetadata(user, values = {}, token = null) {
const userToken = token || (await this.getUserToken());
const newMetadata = {
...user.user_metadata,
...values,
};
const res = await axios.patch(
`${this.AUTH0_DOMAIN}/api/v2/users/${user.user_id}`,
{
user_metadata: newMetadata,
},
{
headers: {
Authorization: `Bearer ${userToken.data.access_token}`,
},
},
);
return res;
}
async setEmailVerified(user, token = null) {
if (user.email.includes(SIGN_UP_BY_SMS_DOMAIN)) {
return null;
}
const userToken = token || (await this.getUserToken());
const res = await axios.patch(
`${this.AUTH0_DOMAIN}/api/v2/users/${user.user_id}`,
{ email_verified: true },
{
headers: {
Authorization: `Bearer ${userToken.data.access_token}`,
},
},
);
return res;
}
async searchChangePasswordRequestSucceededLogs(event, token = null) {
const userToken = token || (await this.getUserToken());
const paramStr =
`type:"scpr"` + // Change password request succeeded
` AND connection:"Username-Password-Authentication"` + // event.connection.name
` AND strategy:"auth0"` + // event.connection.strategy
` AND user_name:"${event?.user_name || event?.user?.email}"`;
const res = await axios.get(
`${this.AUTH0_DOMAIN}/api/v2/logs?q=${encodeURIComponent(
paramStr,
)}`,
{
headers: {
Authorization: `Bearer ${userToken.data.access_token}`,
},
},
);
return res;
}
async searchSuccessChangePasswordLogs(event, token = null) {
const userToken = token || (await this.getUserToken());
const paramStr =
`type:"scp"` + // Success Change Password
` AND connection:"Username-Password-Authentication"` + // event.connection.name
` AND strategy:"auth0"` + // event.connection.strategy
` AND user_id:"${event?.user_id || event?.user?.user_id}"`;
const res = await axios.get(
`${this.AUTH0_DOMAIN}/api/v2/logs?q=${encodeURIComponent(
paramStr,
)}`,
{
headers: {
Authorization: `Bearer ${userToken.data.access_token}`,
},
},
);
return res;
}
async linkAccount(
primaryAccountUserId,
secondaryAccountUserId,
secondaryAccountProvider,
token = null,
) {
const userToken = token || (await this.getUserToken());
const res = await axios.post(
`${this.AUTH0_DOMAIN}/api/v2/users/${primaryAccountUserId}/identities`,
{
provider: secondaryAccountProvider,
user_id: secondaryAccountUserId,
},
{
headers: {
Authorization: `Bearer ${userToken.data.access_token}`,
},
},
);
return res;
}
isValidUserName(username) {
if (username && username.length > 0) {
const checkingValue = username.trim().toLowerCase();
if (checkingValue.length > 0 && checkingValue.length <= 36) {
return /^[a-z0-9_.]*$/.test(checkingValue);
}
}
return false;
}
isNamePwdAuthUser(user) {
let result = false;
if (user && user.user_id) {
const userIdNum = user.user_id.split('|').pop();
if (userIdNum && Array.isArray(user.identities)) {
const [identity] = user.identities.filter(
e => e.user_id === userIdNum,
);
if (identity) {
result = identity.connection === this.DEFAULT_CONN;
}
}
}
return result;
}
isThirdPartyAuthUser(user) {
let result = false;
if (user && user.user_id) {
const userIdNum = user.user_id.split('|').pop();
if (userIdNum && Array.isArray(user.identities)) {
const [identity] = user.identities.filter(
e => e.user_id === userIdNum,
);
if (identity) {
result = identity.connection !== this.DEFAULT_CONN;
}
}
}
return result;
}
async formUsersHavingTheSameEmail(event, userToken) {
const resUsers = {
eventUser: null,
namePwdAuthUsers: [],
otherAuthUsers: [],
};
if (!event || !event?.user || !event?.user?.email) {
return resUsers;
}
const { user: eventUser } = event;
resUsers.eventUser = eventUser;
const sameMailUsers = await this.getUsersByEmail(
eventUser.email,
userToken,
);
if (sameMailUsers && Array.isArray(sameMailUsers.data)) {
const userAry = sameMailUsers.data.map(u => ({
...u,
create_time_ms: new Date(u.created_at).getTime(),
}));
userAry.sort((a, b) =>
a.create_time_ms > b.create_time_ms ? 1 : -1,
);
for (let idx = 0; idx < userAry.length; idx += 1) {
const checkingUser = userAry[idx];
const isNamePwdAuth = this.isNamePwdAuthUser(checkingUser);
if (isNamePwdAuth === true) {
resUsers.namePwdAuthUsers.push(checkingUser);
} else {
resUsers.otherAuthUsers.push(checkingUser);
}
}
}
return resUsers;
}
async prioritizeUsersHavingTheSameEmail(event, userToken) {
const resUsers = {
primaryUser: null,
secondaryUsers: [],
isEventUserEqualPrimary: false,
};
if (event.connection.name === 'sms') {
// eslint-disable-next-line no-param-reassign
event.user.email = `${event.user.phone_number.replace(
'+',
'n',
)}${SIGN_UP_BY_SMS_DOMAIN}`;
}
if (!event || !event?.user || !event?.user?.email) {
return resUsers;
}
const { eventUser, namePwdAuthUsers, otherAuthUsers } =
await this.formUsersHavingTheSameEmail(event, userToken);
if (namePwdAuthUsers.length <= 0 && otherAuthUsers.length <= 0) {
resUsers.primaryUser = eventUser;
resUsers.isEventUserEqualPrimary = true;
return resUsers;
}
// namePwdAuthUsers && otherAuthUsers are sorted by created time asc
if (namePwdAuthUsers.length > 0) {
let firstVerifiedUser = null;
let maxLoginCountUser = null;
let firstCreatedUser = null;
for (let idx = 0; idx < namePwdAuthUsers.length; idx += 1) {
const tempUser = namePwdAuthUsers[idx];
// 3rd priority : first created
if (idx === 0) {
firstCreatedUser = tempUser;
}
// 1st priority : email already verified
if (tempUser.email_verified === true) {
firstVerifiedUser = tempUser;
break;
}
// 2nd priority : max logins_count
if (
maxLoginCountUser === null ||
tempUser.logins_count > maxLoginCountUser.logins_count
) {
maxLoginCountUser = tempUser;
}
}
resUsers.primaryUser =
firstVerifiedUser || maxLoginCountUser || firstCreatedUser;
resUsers.secondaryUsers = namePwdAuthUsers
.filter(e => e.user_id !== resUsers.primaryUser.user_id)
.concat(otherAuthUsers);
} else {
const isNamePwdAuth = this.isNamePwdAuthUser(eventUser);
if (isNamePwdAuth === true) {
resUsers.primaryUser = eventUser;
resUsers.secondaryUsers = otherAuthUsers;
} else {
// first created 3rdAuth user
const [firstCreated3rdAuthUser] = otherAuthUsers;
resUsers.primaryUser = firstCreated3rdAuthUser;
resUsers.secondaryUsers = otherAuthUsers.slice(
1,
otherAuthUsers.length,
);
}
}
resUsers.isEventUserEqualPrimary =
resUsers.primaryUser?.user_id === eventUser?.user_id;
return resUsers;
}
async getMajorUser(event, userToken, isAutoMerge = true) {
const { primaryUser, secondaryUsers } =
await this.prioritizeUsersHavingTheSameEmail(event, userToken);
const resUser = { ...primaryUser };
if (isAutoMerge !== false && secondaryUsers.length > 0) {
for (let idx = 0; idx < secondaryUsers.length; idx += 1) {
const secondaryUser = secondaryUsers[idx];
try {
const linkedRes = await this.linkAccount(
primaryUser.user_id,
secondaryUser.user_id,
secondaryUser.identities[0].provider,
userToken,
);
if (linkedRes && linkedRes?.data) {
resUser.identities = linkedRes.data;
}
} catch (linkAccountErr) {
this.LOG.error(
{
primaryUser,
secondaryUser,
error: linkAccountErr,
},
`Base.getMajorUser | link account error`,
);
}
}
}
const isNamePwdAuth = this.isNamePwdAuthUser(resUser);
if (!isNamePwdAuth && resUser.email_verified !== true) {
await this.setEmailVerified(resUser, userToken);
}
return resUser;
}
safeEncodeParam(data, key = '') {
let resVal = '';
if (!!data && Object.keys(data).length > 0) {
const stringifyObj = JSON.stringify(data);
const bufferingTxt = Buffer.from(stringifyObj, 'latin1');
const compressedTxt = bufferingTxt.toString('base64');
resVal = encodeURIComponent(compressedTxt);
}
if (!!key && typeof key === 'string' && key.length > 0) {
resVal = `${key}=${resVal}`;
}
return resVal;
}
safeDecodeParam(rawTxt) {
let resTxt = null;
if (rawTxt && rawTxt.length > 0) {
const trimmedTxt = rawTxt.trim();
const isEncoded = /%/i.test(trimmedTxt);
const decodedTxt = isEncoded
? decodeURIComponent(trimmedTxt)
: trimmedTxt;
if (decodedTxt && decodedTxt.length > 0) {
resTxt = decodedTxt;
}
}
return resTxt;
}
safeDecompressParam(rawTxt) {
let resTxt = null;
if (rawTxt && rawTxt.length > 0) {
const decodedTxt = this.safeDecodeParam(rawTxt);
if (decodedTxt && decodedTxt.length > 0) {
const tmpBuffer = Buffer.from(decodedTxt, 'base64');
if (tmpBuffer) {
const decompressedTxt = tmpBuffer.toString('latin1');
if (decompressedTxt && decompressedTxt.length > 0) {
resTxt = decompressedTxt;
}
}
}
}
return resTxt;
}
organizeEventReqQueryParam(event) {
const resData = {};
const queryObject = event?.request?.query;
if (queryObject) {
const refer = this.safeDecompressParam(queryObject.refer);
if (refer) {
resData.refer = refer;
}
const country = this.safeDecodeParam(queryObject.country);
if (country) {
resData.country = country;
}
const timezone = this.safeDecodeParam(queryObject.timezone);
if (timezone) {
resData.timezone = timezone;
}
const action = this.safeDecodeParam(queryObject.action);
if (action) {
resData.action = action;
}
const target = this.safeDecodeParam(queryObject.target);
if (target) {
resData.target = target;
}
const sourceId = this.safeDecodeParam(queryObject.sourceId);
if (sourceId) {
resData.sourceId = sourceId;
}
// for claiming guest account
const claimUsername = this.safeDecodeParam(
queryObject.claimUsername,
);
if (claimUsername) {
resData.claimUsername = claimUsername;
}
const claimUserEmail = this.safeDecompressParam(
queryObject.claimUserEmail,
);
if (claimUserEmail) {
resData.claimUserEmail = claimUserEmail;
}
const verifiedRedirection = this.safeDecompressParam(
queryObject.verifiedRedirection,
);
if (verifiedRedirection) {
resData.verifiedRedirection = verifiedRedirection;
}
const isAutoTest = `${queryObject.isAutoTest}`.toLowerCase();
resData.isAutoTest = isAutoTest === 'true';
}
return resData;
}
parseJson(jsonStr, defaultVal) {
if (jsonStr === null || jsonStr === undefined) {
return defaultVal;
}
if (typeof jsonStr === 'object') {
return jsonStr;
}
if (typeof jsonStr !== 'string') {
return defaultVal;
}
try {
return JSON.parse(jsonStr);
} catch (e) {
return defaultVal;
}
}
}
module.exports = BaseHelper;