From 0b296e90c7fc01a1c2bce149b00426f39f3ad404 Mon Sep 17 00:00:00 2001 From: Bheesham Persaud Date: Wed, 25 Feb 2026 15:14:56 -0500 Subject: [PATCH 1/8] linkUsers: pull searchMultipleEmailCases up Jira: IAM-1761 --- tf/actions/linkUserByEmail.js | 67 ++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/tf/actions/linkUserByEmail.js b/tf/actions/linkUserByEmail.js index 4177ead..2b88dce 100644 --- a/tf/actions/linkUserByEmail.js +++ b/tf/actions/linkUserByEmail.js @@ -14,6 +14,36 @@ const auth0Sdk = require("auth0"); +// Since email addresses within auth0 are allowed to be mixed case and the /user-by-email search endpoint +// is case sensitive, we need to search for both situations. In the first search we search by "this" users email +// which might be mixed case (or not). Our second search is for the lowercase equivalent but only if two searches +// would be different. +async function searchMultipleEmailCases(mgmtClient, email) { + let userAccountsFound = []; + + // Push the + userAccountsFound.push(mgmtClient.usersByEmail.getByEmail({ email })); + + // if this user is mixed case, we need to also search for the lower case equivalent + if (email !== email.toLowerCase()) { + userAccountsFound.push( + mgmtClient.usersByEmail.getByEmail({ + email: email.toLowerCase(), + }) + ); + } + + // await all json responses promises to resolve + const allJSONResponses = await Promise.all(userAccountsFound); + + // flatten the array of arrays to get one array of profiles + const mergedDataProfiles = allJSONResponses.reduce((acc, response) => { + return acc.concat(response.data); + }, []); + + return mergedDataProfiles; +} + exports.onExecutePostLogin = async (event, api) => { console.log("Running actions:", "linkUsersByEmail"); @@ -37,38 +67,6 @@ exports.onExecutePostLogin = async (event, api) => { scope: "update:users", }); - // Since email addresses within auth0 are allowed to be mixed case and the /user-by-email search endpoint - // is case sensitive, we need to search for both situations. In the first search we search by "this" users email - // which might be mixed case (or not). Our second search is for the lowercase equivalent but only if two searches - // would be different. - const searchMultipleEmailCases = async () => { - let userAccountsFound = []; - - // Push the - userAccountsFound.push( - mgmtClient.usersByEmail.getByEmail({ email: event.user.email }) - ); - - // if this user is mixed case, we need to also search for the lower case equivalent - if (event.user.email !== event.user.email.toLowerCase()) { - userAccountsFound.push( - mgmtClient.usersByEmail.getByEmail({ - email: event.user.email.toLowerCase(), - }) - ); - } - - // await all json responses promises to resolve - const allJSONResponses = await Promise.all(userAccountsFound); - - // flatten the array of arrays to get one array of profiles - const mergedDataProfiles = allJSONResponses.reduce((acc, response) => { - return acc.concat(response.data); - }, []); - - return mergedDataProfiles; - }; - const linkAccount = async (otherProfile) => { // sanity check if both accounts have LDAP as primary // we should NOT link these accounts and simply allow the user to continue logging in. @@ -131,7 +129,10 @@ exports.onExecutePostLogin = async (event, api) => { // Main try { // Search for multiple accounts of the same user to link - let userAccountList = await searchMultipleEmailCases(); + let userAccountList = await searchMultipleEmailCases( + mgmtClient, + event.user.email + ); // Ignore non-verified users userAccountList = userAccountList.filter((u) => u.email_verified); From 2e932d298dc5dab16e062d7f4e1aa6656087450f Mon Sep 17 00:00:00 2001 From: Bheesham Persaud Date: Wed, 25 Feb 2026 15:14:56 -0500 Subject: [PATCH 2/8] linkUsers: pull linkAccount up Jira: IAM-1761 --- tf/actions/linkUserByEmail.js | 120 +++++++++++++++++----------------- 1 file changed, 61 insertions(+), 59 deletions(-) diff --git a/tf/actions/linkUserByEmail.js b/tf/actions/linkUserByEmail.js index 2b88dce..82cd5a0 100644 --- a/tf/actions/linkUserByEmail.js +++ b/tf/actions/linkUserByEmail.js @@ -44,6 +44,64 @@ async function searchMultipleEmailCases(mgmtClient, email) { return mergedDataProfiles; } +async function linkAccount(api, mgmtClient, originalProfile, otherProfile) { + // sanity check if both accounts have LDAP as primary + // we should NOT link these accounts and simply allow the user to continue logging in. + if ( + originalProfile.user_id.startsWith("ad|Mozilla-LDAP") && + otherProfile.user_id.startsWith("ad|Mozilla-LDAP") + ) { + console.error( + `Error: both ${originalProfile.user_id} and ${otherProfile.user_id} are LDAP Primary accounts. Linking will not occur.` + ); + return; // Continue with user login without account linking + } + + // LDAP takes priority being the primary identity + // So we need to determine if one or neither are LDAP + // If both are non-primary, linking order doesn't matter + let primaryUser; + let secondaryUser; + + if (originalProfile.user_id.startsWith("ad|Mozilla-LDAP")) { + primaryUser = originalProfile; + secondaryUser = otherProfile; + } else { + primaryUser = otherProfile; + secondaryUser = originalProfile; + } + + // Link the secondary account into the primary account + console.log( + `Linking secondary identity ${secondaryUser.user_id} into primary identity ${primaryUser.user_id}` + ); + + // We no longer keep the user_metadata nor app_metadata from the secondary account + // that is being linked. If the primary account is LDAP, then its existing + // metadata should prevail. And in the case of both, primary and secondary being + // non-ldap, account priority does not matter and neither does the metadata of + // the secondary account. + + // Link the accounts + try { + await mgmtClient.users.link( + { id: String(primaryUser.user_id) }, + { + provider: secondaryUser.identities[0].provider, + user_id: secondaryUser.identities[0].user_id, + } + ); + + // Auth0 Action api object provides a method for updating the current + // authenticated user to the new user_id after account linking has taken place + api.authentication.setPrimaryUser(primaryUser.user_id); + return; + } catch (err) { + console.error("An unknown error occurred while linking accounts:", err); + throw err; + } +} + exports.onExecutePostLogin = async (event, api) => { console.log("Running actions:", "linkUsersByEmail"); @@ -67,65 +125,6 @@ exports.onExecutePostLogin = async (event, api) => { scope: "update:users", }); - const linkAccount = async (otherProfile) => { - // sanity check if both accounts have LDAP as primary - // we should NOT link these accounts and simply allow the user to continue logging in. - if ( - event.user.user_id.startsWith("ad|Mozilla-LDAP") && - otherProfile.user_id.startsWith("ad|Mozilla-LDAP") - ) { - console.error( - `Error: both ${event.user.user_id} and ${otherProfile.user_id} are LDAP Primary accounts. Linking will not occur.` - ); - return; // Continue with user login without account linking - } - - // LDAP takes priority being the primary identity - // So we need to determine if one or neither are LDAP - // If both are non-primary, linking order doesn't matter - let primaryUser; - let secondaryUser; - - if (event.user.user_id.startsWith("ad|Mozilla-LDAP")) { - primaryUser = event.user; - secondaryUser = otherProfile; - } else { - primaryUser = otherProfile; - secondaryUser = event.user; - } - - // Link the secondary account into the primary account - console.log( - `Linking secondary identity ${secondaryUser.user_id} into primary identity ${primaryUser.user_id}` - ); - - // We no longer keep the user_metadata nor app_metadata from the secondary account - // that is being linked. If the primary account is LDAP, then its existing - // metadata should prevail. And in the case of both, primary and secondary being - // non-ldap, account priority does not matter and neither does the metadata of - // the secondary account. - - // Link the accounts - try { - await mgmtClient.users.link( - { id: String(primaryUser.user_id) }, - { - provider: secondaryUser.identities[0].provider, - user_id: secondaryUser.identities[0].user_id, - } - ); - - // Auth0 Action api object provides a method for updating the current - // authenticated user to the new user_id after account linking has taken place - api.authentication.setPrimaryUser(primaryUser.user_id); - } catch (err) { - console.error("An unknown error occurred while linking accounts:", err); - throw err; - } - - return; - }; - // Main try { // Search for multiple accounts of the same user to link @@ -151,6 +150,9 @@ exports.onExecutePostLogin = async (event, api) => { // linking function await linkAccount( + api, + mgmtClient, + event.user, userAccountList.filter((u) => u.user_id !== event.user.user_id)[0] ); } else { From b1a4a1ef71b6400e5601d87504a30f0af7674020 Mon Sep 17 00:00:00 2001 From: Bheesham Persaud Date: Wed, 25 Feb 2026 15:29:00 -0500 Subject: [PATCH 3/8] link: retry when searching for users This should address cases not addressed by the Auth0 SDK. Since we don't currently collect logs as to why these calls may be failing, we simply log the error and move on (er, retry). Once we understand where this code is failing we should be more specific about our try/catches. Jira: IAM-1761 --- tf/actions/linkUserByEmail.js | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/tf/actions/linkUserByEmail.js b/tf/actions/linkUserByEmail.js index 82cd5a0..5808d2b 100644 --- a/tf/actions/linkUserByEmail.js +++ b/tf/actions/linkUserByEmail.js @@ -14,6 +14,29 @@ const auth0Sdk = require("auth0"); +// A wrapper around getByEmail to retry any time it fails. The SDK already has +// some code to deal with retrying, though it does not cover issues like +// transient network errors. +// +// https://github.com/auth0/node-auth0/blob/1e0fbf0e9aeafffa680360a7b324575ff6f1830c/src/lib/retry.ts#L56 +async function userLookupWithGlobalRetry(mgmtClient, email) { + let error; + for (var retries = 0; retries < 3; retries++) { + try { + return await mgmtClient.usersByEmail.getByEmail({ email }); + } catch (err) { + console.error( + `Failed lookup for ${email}`, + err.errorCode, + err.statusCode, + err.error + ); + error = err; + } + } + throw error; +} + // Since email addresses within auth0 are allowed to be mixed case and the /user-by-email search endpoint // is case sensitive, we need to search for both situations. In the first search we search by "this" users email // which might be mixed case (or not). Our second search is for the lowercase equivalent but only if two searches @@ -21,15 +44,12 @@ const auth0Sdk = require("auth0"); async function searchMultipleEmailCases(mgmtClient, email) { let userAccountsFound = []; - // Push the - userAccountsFound.push(mgmtClient.usersByEmail.getByEmail({ email })); + userAccountsFound.push(userLookupWithGlobalRetry(mgmtClient, email)); // if this user is mixed case, we need to also search for the lower case equivalent if (email !== email.toLowerCase()) { userAccountsFound.push( - mgmtClient.usersByEmail.getByEmail({ - email: email.toLowerCase(), - }) + userLookupWithGlobalRetry(mgmtClient, email.toLowerCase()) ); } From d3d86233c08928d8fabcba8d272f2c5fceac6269 Mon Sep 17 00:00:00 2001 From: Bheesham Persaud Date: Wed, 25 Feb 2026 15:35:23 -0500 Subject: [PATCH 4/8] link: reduce try/catch area when looking up users Jira: IAM-1761 --- tf/actions/linkUserByEmail.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tf/actions/linkUserByEmail.js b/tf/actions/linkUserByEmail.js index 5808d2b..7d70067 100644 --- a/tf/actions/linkUserByEmail.js +++ b/tf/actions/linkUserByEmail.js @@ -146,15 +146,24 @@ exports.onExecutePostLogin = async (event, api) => { }); // Main + let candidateUserAccountList; + try { // Search for multiple accounts of the same user to link - let userAccountList = await searchMultipleEmailCases( + candidateUserAccountList = await searchMultipleEmailCases( mgmtClient, event.user.email ); + } catch (err) { + console.err(`Could not look up email for ${event.user.email}`); + return api.access.deny("Please contact support or the IAM team. (err=link-lookup)"); + } + try { // Ignore non-verified users - userAccountList = userAccountList.filter((u) => u.email_verified); + let userAccountList = candidateUserAccountList.filter( + (u) => u.email_verified + ); if (userAccountList.length <= 1) { // The user logged in with an identity which is the only one Auth0 knows about From 68fe7d4801b936f3b4bf664e19aef59e56cfdf07 Mon Sep 17 00:00:00 2001 From: Bheesham Persaud Date: Wed, 25 Feb 2026 15:50:11 -0500 Subject: [PATCH 5/8] link: split up try/cathes for the rest of "main" Jira: IAM-1761 --- tf/actions/linkUserByEmail.js | 68 +++++++++++++++++------------------ 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/tf/actions/linkUserByEmail.js b/tf/actions/linkUserByEmail.js index 7d70067..5b728b8 100644 --- a/tf/actions/linkUserByEmail.js +++ b/tf/actions/linkUserByEmail.js @@ -159,45 +159,43 @@ exports.onExecutePostLogin = async (event, api) => { return api.access.deny("Please contact support or the IAM team. (err=link-lookup)"); } - try { - // Ignore non-verified users - let userAccountList = candidateUserAccountList.filter( - (u) => u.email_verified - ); + // Ignore non-verified users + let userAccountList = candidateUserAccountList.filter( + (u) => u.email_verified + ); - if (userAccountList.length <= 1) { - // The user logged in with an identity which is the only one Auth0 knows about - // or no data returned - // Do not perform any account linking - return; - } + if (userAccountList.length <= 1) { + // The user logged in with an identity which is the only one Auth0 knows about + // or no data returned + // Do not perform any account linking + return; + } - if (userAccountList.length === 2) { - // Auth0 is aware of 2 identities with the same email address which means - // that the user just logged in with a new identity that hasn't been linked - // into the other existing identity. Here we pass the other account to the - // linking function - - await linkAccount( - api, - mgmtClient, - event.user, - userAccountList.filter((u) => u.user_id !== event.user.user_id)[0] + if (userAccountList.length === 2) { + const candidateUserId = userAccountList.filter( + (u) => u.user_id !== event.user.user_id + )[0]; + try { + return await linkAccount(api, mgmtClient, event.user, candidateUserId); + } catch (err) { + console.error( + `Could not link ${event.user.user_id} with ${candidateUserId}` ); - } else { - // data.length is > 2 which, post November 2020 when all identities were - // force linked manually, shouldn't be possible - var error_message = - `Error linking account ${event.user.user_id} as there are ` + - `over 2 identities with the email address ${event.user.email} ` + - userAccountList.map((x) => x.user_id).join(); - console.error(error_message); - throw new Error(error_message); + return api.access.deny("Please contact support or the IAM team. (err=link-link)"); } - } catch (err) { - console.error("An error occurred while linking accounts:", err); - return api.access.deny(err.message || String(err)); } - return; + // Auth0 is aware of 2 identities with the same email address which means + // that the user just logged in with a new identity that hasn't been linked + // into the other existing identity. Here we pass the other account to the + // linking function + + // data.length is > 2 which, post November 2020 when all identities were + // force linked manually, shouldn't be possible + var error_message = + `Error linking account ${event.user.user_id} as there are ` + + `over 2 identities with the email address ${event.user.email} ` + + userAccountList.map((x) => x.user_id).join(); + console.error(error_message); + return api.access.deny(error_message); }; From 2e9b2a12b438af620efca0be0a205f3ab1516ee1 Mon Sep 17 00:00:00 2001 From: Bheesham Persaud Date: Wed, 25 Feb 2026 15:54:03 -0500 Subject: [PATCH 6/8] chore: npm run format Jira: IAM-1761 --- tf/actions/linkUserByEmail.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tf/actions/linkUserByEmail.js b/tf/actions/linkUserByEmail.js index 5b728b8..0fcb4a5 100644 --- a/tf/actions/linkUserByEmail.js +++ b/tf/actions/linkUserByEmail.js @@ -156,7 +156,9 @@ exports.onExecutePostLogin = async (event, api) => { ); } catch (err) { console.err(`Could not look up email for ${event.user.email}`); - return api.access.deny("Please contact support or the IAM team. (err=link-lookup)"); + return api.access.deny( + "Please contact support or the IAM team. (err=link-lookup)" + ); } // Ignore non-verified users @@ -181,7 +183,9 @@ exports.onExecutePostLogin = async (event, api) => { console.error( `Could not link ${event.user.user_id} with ${candidateUserId}` ); - return api.access.deny("Please contact support or the IAM team. (err=link-link)"); + return api.access.deny( + "Please contact support or the IAM team. (err=link-link)" + ); } } From 219f4208305bf4f591bb5cd160af4a53a739d8e8 Mon Sep 17 00:00:00 2001 From: Bheesham Persaud Date: Wed, 25 Feb 2026 16:12:17 -0500 Subject: [PATCH 7/8] link: move linking and return to outside of the try/catch Jira: IAM-1761 --- tf/actions/linkUserByEmail.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tf/actions/linkUserByEmail.js b/tf/actions/linkUserByEmail.js index 0fcb4a5..e4d2d26 100644 --- a/tf/actions/linkUserByEmail.js +++ b/tf/actions/linkUserByEmail.js @@ -111,15 +111,14 @@ async function linkAccount(api, mgmtClient, originalProfile, otherProfile) { user_id: secondaryUser.identities[0].user_id, } ); - - // Auth0 Action api object provides a method for updating the current - // authenticated user to the new user_id after account linking has taken place - api.authentication.setPrimaryUser(primaryUser.user_id); - return; } catch (err) { console.error("An unknown error occurred while linking accounts:", err); throw err; } + // Auth0 Action api object provides a method for updating the current + // authenticated user to the new user_id after account linking has taken place + api.authentication.setPrimaryUser(primaryUser.user_id); + return; } exports.onExecutePostLogin = async (event, api) => { From 50442b52702d9aeaef2cb6fbf64607d0652fa706 Mon Sep 17 00:00:00 2001 From: Bheesham Persaud Date: Wed, 25 Feb 2026 16:13:09 -0500 Subject: [PATCH 8/8] link: slightly improve error messages Jira: IAM-1761 --- tf/actions/linkUserByEmail.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tf/actions/linkUserByEmail.js b/tf/actions/linkUserByEmail.js index e4d2d26..3c56020 100644 --- a/tf/actions/linkUserByEmail.js +++ b/tf/actions/linkUserByEmail.js @@ -112,7 +112,12 @@ async function linkAccount(api, mgmtClient, originalProfile, otherProfile) { } ); } catch (err) { - console.error("An unknown error occurred while linking accounts:", err); + console.error( + "An unknown error occurred while linking accounts:", + err.errorCode, + err.statusCode, + err.error + ); throw err; } // Auth0 Action api object provides a method for updating the current @@ -154,7 +159,7 @@ exports.onExecutePostLogin = async (event, api) => { event.user.email ); } catch (err) { - console.err(`Could not look up email for ${event.user.email}`); + console.error(`Could not look up email for ${event.user.email}`); return api.access.deny( "Please contact support or the IAM team. (err=link-lookup)" );