const { ManagementClient, AuthenticationClient } = require('auth0');
/**
* @constant {string} - Account linking timestamp key
*/
const ACCOUNT_LINKING_TIMESTAMP_KEY = 'account_linking_timestamp';
/**
* @constant {number} - TTL leeway factor
*/
const TTL_LEEWAY_FACTOR = .2;
/**
* @constant {string[]} - Properties to complete based on identities
*/
const PROPERTIES_TO_COMPLETE = [
'given_name',
'family_name',
'name'
];
/**
* @param {Event} event
* @param {PostLoginAPI} api
* @returns {Promise<string>} - Management API access token
*/
const getManagementAccessToken = async (event, api) => {
const managementApiTokenCacheKey = `mgmt-api-token-${event.secrets.MANAGEMENT_API_CLIENT_ID}`;
const { value: cachedAccessToken } = api.cache.get(managementApiTokenCacheKey) || {};
if (cachedAccessToken) {
return cachedAccessToken;
}
const authentication = new AuthenticationClient({
domain: event.secrets.MANAGEMENT_API_DOMAIN,
clientId: event.secrets.MANAGEMENT_API_CLIENT_ID,
clientSecret: event.secrets.MANAGEMENT_API_CLIENT_SECRET
});
const { data: { access_token: accessToken, expires_in: expiresIn } } = await authentication.oauth.clientCredentialsGrant({
audience: `https://${event.secrets.MANAGEMENT_API_DOMAIN}/api/v2/`,
});
api.cache.set(managementApiTokenCacheKey, accessToken, {
ttl: expiresIn - expiresIn * TTL_LEEWAY_FACTOR
});
return accessToken;
}
/**
* @param {Event} event
* @param {PostLoginAPI} api
* @returns {Promise<ManagementClient>} - Auth0 management client
*/
const getManagementClient = async (event, api) => {
const token = await getManagementAccessToken(event, api);
return new ManagementClient({
domain: event.secrets.MANAGEMENT_API_DOMAIN,
token,
});
}
/**
* @typedef {Object} CandidateUsersIdentities
* @property {string} user_id
* @property {string} provider
* @property {string} connection
*/
/**
* @typedef {Object} CandidateUsers
* @property {string} email
* @property {boolean} email_verified
* @property {string} user_id
* @property {CandidateUsersIdentities[]} identities
*/
/**
* @typedef {Object} CandidateIdentities
* @property {User['user_id']} user_id
* @property {string} connection
* @property {string} provider
*/
/**
* @typedef {Object} LinkedIdentity
* @property {User['user_id']} user_id
*/
/**
* @param {Event} event
* @param {PostLoginAPI} api
* @returns {Promise<CandidateUsers[]>} - List of users with the same email address
*/
const getUsersWithSameEmail = async (event, api) => {
const management = await getManagementClient(event, api);
const { data } = await management.usersByEmail.getByEmail({
email: event.user.email,
fields: 'email,email_verified,user_id,identities',
include_fields: true,
});
return data;
}
/**
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {CandidateUsers[]} candidateUsers - List of users that match the same email address
* @returns {CandidateIdentities[]} - List of candidate identities
*/
const getCandidateIdentitiesWithVerifiedEmail = (event, candidateUsers) => {
// Removes current user from candidate identities and checks the email is verified
return candidateUsers
.filter((user) => user.user_id !== event.user.user_id && user.email_verified === true)
.filter((user) => user.identities)
// .flatMap((user) => user.identities)
.map((user) => {
return {
user_id: user.user_id,
provider: user.identities[0].provider,
connection: user.identities[0].connection
}
});
}
/**
* @param {Event} event
* @param {PostLoginAPI} api
* @returns {Promise<LinkedIdentity[]>} - Linked identity response
*/
const linkIdentities = async (event, api, primaryIdentity, secondaryIdentity) => {
const management = await getManagementClient(event, api);
const { data } = await management.users.link({
id: primaryIdentity.user_id
}, {
provider: secondaryIdentity.provider,
user_id: secondaryIdentity.user_id
});
return data;
}
/**
* @param {Event} event
* @param {PostLoginAPI} api
* @returns {void}
*/
const completeProperties = (event, api) => {
// go over each property and try to get missing
// information from secondary identities for ID Token
for (const property of PROPERTIES_TO_COMPLETE) {
if (!event.user[property]) {
for(const identity of event.user.identities) {
if (identity.profileData && identity.profileData[property]) {
api.idToken.setCustomClaim(property, identity.profileData[property]);
break;
}
}
}
}
}
/**
* Handler that will be called during the execution of a PostLogin flow.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onExecutePostLogin = async (event, api) => {
if (
!event.secrets.MANAGEMENT_API_DOMAIN ||
!event.secrets.MANAGEMENT_API_CLIENT_ID ||
!event.secrets.MANAGEMENT_API_CLIENT_SECRET ||
!event.secrets.SESSION_TOKEN_SHARED_SECRET ||
!event.secrets.ACCOUNT_LINKING_SERVICE_URL
) {
console.log('Missing required configuration for account linking action. Skipping.');
return;
}
// We won't process users for account linking until they have verified their email address.
// We might consider rejecting logins here or redirecting users to an external tool to
// remind the user to confirm their email address before proceeding.
//
// In this example, we simply won't process users unless their email is verified.
if (!event.user.email_verified) {
return;
}
// Account linking has already been processed and completed for this user. No further work
// to be done in this Action.
if (event.user.app_metadata[ACCOUNT_LINKING_TIMESTAMP_KEY] !== undefined) {
completeProperties(event, api);
return;
}
try {
const candidateUsers = await getUsersWithSameEmail(event, api);
// If there are no candidates, skip
if (!Array.isArray(candidateUsers) || candidateUsers.length === 0) {
return;
}
const candidateIdentities = getCandidateIdentitiesWithVerifiedEmail(event, candidateUsers);
// If there are no candidates, skip
if (candidateIdentities.length === 0) {
return;
}
// Encode the current user and an array of their candidate identities
const sessionToken = api.redirect.encodeToken({
payload: {
current_identity: {
user_id: event.user.user_id,
provider: event.connection.strategy,
connection: event.connection.name
},
candidate_identities: candidateIdentities,
},
secret: event.secrets.SESSION_TOKEN_SHARED_SECRET,
expiresInSeconds: 20
});
// Redirect to your Account Linking UX
api.redirect.sendUserTo(event.secrets.ACCOUNT_LINKING_SERVICE_URL, {
query: {
session_token: sessionToken
}
});
} catch (err) {
console.error(err);
// Handle error
}
};
/**
* Handler that will be invoked when this action is resuming after an external redirect. If your
* onExecutePostLogin function does not perform a redirect, this function can be safely ignored.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onContinuePostLogin = async (event, api) => {
// Validate the session token passed to `/continue?state` and extract the `user_id` claim.
const { primary_identity: primaryIdentity, secondary_identity: secondaryIdentity } = api.redirect.validateToken({
secret: event.secrets.SESSION_TOKEN_SHARED_SECRET,
tokenParameterName: 'session_token',
});
try {
const linkedIdentity = await linkIdentities(event, api, primaryIdentity, secondaryIdentity);
if (linkedIdentity !== undefined && linkedIdentity.length > 1) {
if (primaryIdentity.user_id !== event.user.user_id) {
// The account linking service indicated that the primary user changed.
api.authentication.setPrimaryUser(primaryIdentity.user_id);
}
// Mark the user as having been processed for account linking
api.user.setAppMetadata(ACCOUNT_LINKING_TIMESTAMP_KEY, Date.now());
completeProperties(event, api);
} else {
// Handle error
api.access.deny('Account linking failure');
}
} catch (err) {
console.error(err);
// Handle error
}
};