Skip to content

Commit 5bdc41b

Browse files
committed
linkUserByEmail: Split out helper functions, reduce scope of try/catches
Jira: IAM-1761
1 parent 538f836 commit 5bdc41b

1 file changed

Lines changed: 161 additions & 121 deletions

File tree

tf/actions/linkUserByEmail.js

Lines changed: 161 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,125 @@
99
*
1010
* Please see https://github.com/mozilla-iam/mozilla-iam/blob/master/docs/deratcheting-user-flows.md#user-logs-in-with-the-mozilla-iam-system-for-the-first-time
1111
* for detailed explanation of what happens here.
12-
*
1312
*/
1413

1514
const auth0Sdk = require("auth0");
1615

16+
// A wrapper around getByEmail to retry any time it fails. The SDK already has
17+
// some code to deal with retrying, though it does not cover issues like
18+
// transient network errors.
19+
//
20+
// https://github.com/auth0/node-auth0/blob/1e0fbf0e9aeafffa680360a7b324575ff6f1830c/src/lib/retry.ts#L56
21+
async function usersByEmail(mgmtClient, email) {
22+
console.log(`Searching for users with email ${email}`);
23+
let error;
24+
for (var retries = 0; retries < 3; retries++) {
25+
try {
26+
return await mgmtClient.usersByEmail.getByEmail({ email });
27+
} catch (err) {
28+
// In the future, we can use `err.errorCode` we should abort or retry on.
29+
// For now, we don't have enough information.
30+
console.error(
31+
"Error getting user by email:",
32+
err.errorCode,
33+
err.statusCode,
34+
err.error
35+
);
36+
error = err;
37+
}
38+
}
39+
throw error;
40+
}
41+
42+
// Links the account in the event with another profile we've found.
43+
async function linkAccount(api, event, mgmtClient, otherProfile) {
44+
// sanity check if both accounts have LDAP as primary
45+
// we should NOT link these accounts and simply allow the user to continue logging in.
46+
if (
47+
event.user.user_id.startsWith("ad|Mozilla-LDAP") &&
48+
otherProfile.user_id.startsWith("ad|Mozilla-LDAP")
49+
) {
50+
console.error(
51+
`Error: both ${event.user.user_id} and ${otherProfile.user_id} are LDAP Primary accounts. Linking will not occur.`
52+
);
53+
return; // Continue with user login without account linking
54+
}
55+
56+
// LDAP takes priority being the primary identity
57+
// So we need to determine if one or neither are LDAP
58+
// If both are non-primary, linking order doesn't matter
59+
let primaryUser;
60+
let secondaryUser;
61+
62+
if (event.user.user_id.startsWith("ad|Mozilla-LDAP")) {
63+
primaryUser = event.user;
64+
secondaryUser = otherProfile;
65+
} else {
66+
primaryUser = otherProfile;
67+
secondaryUser = event.user;
68+
}
69+
70+
// Link the secondary account into the primary account
71+
console.log(
72+
`Linking secondary identity ${secondaryUser.user_id} into primary identity ${primaryUser.user_id}`
73+
);
74+
75+
// We no longer keep the user_metadata nor app_metadata from the secondary account
76+
// that is being linked. If the primary account is LDAP, then its existing
77+
// metadata should prevail. And in the case of both, primary and secondary being
78+
// non-ldap, account priority does not matter and neither does the metadata of
79+
// the secondary account.
80+
81+
// Link the accounts
82+
try {
83+
await mgmtClient.users.link(
84+
{ id: String(primaryUser.user_id) },
85+
{
86+
provider: secondaryUser.identities[0].provider,
87+
user_id: secondaryUser.identities[0].user_id,
88+
}
89+
);
90+
} catch (err) {
91+
console.error(
92+
"Error linking accounts:",
93+
err.errorCode,
94+
err.statusCode,
95+
err.error
96+
);
97+
throw err;
98+
}
99+
100+
// Auth0 Action api object provides a method for updating the current
101+
// authenticated user to the new user_id after account linking has taken place
102+
return api.authentication.setPrimaryUser(primaryUser.user_id);
103+
}
104+
105+
// Can probably be inlined, but pulled out for clarity.
106+
function mergeAccountLists(accumulator, response) {
107+
return accumulator.concat(response.data);
108+
}
109+
110+
// Since email addresses within auth0 are allowed to be mixed case and the /user-by-email search endpoint
111+
// is case sensitive, we need to search for both situations. In the first search we search by "this" users email
112+
// which might be mixed case (or not). Our second search is for the lowercase equivalent but only if two searches
113+
// would be different.
114+
async function searchMultipleEmailCases(mgmtClient, email) {
115+
let userAccountsFound = [];
116+
117+
// Push the base case.
118+
userAccountsFound.push(usersByEmail(mgmtClient, email));
119+
// if this user is mixed case, we need to also search for the lower case equivalent
120+
if (email !== email.toLowerCase()) {
121+
userAccountsFound.push(usersByEmail(mgmtClient, email.toLowerCase()));
122+
}
123+
124+
// await all json responses promises to resolve
125+
const allJSONResponses = await Promise.all(userAccountsFound);
126+
127+
// flatten the array of arrays to get one array of profiles
128+
return allJSONResponses.reduce(mergeAccountLists, []);
129+
}
130+
17131
exports.onExecutePostLogin = async (event, api) => {
18132
console.log("Running actions:", "linkUsersByEmail");
19133

@@ -37,135 +151,61 @@ exports.onExecutePostLogin = async (event, api) => {
37151
scope: "update:users",
38152
});
39153

40-
// Since email addresses within auth0 are allowed to be mixed case and the /user-by-email search endpoint
41-
// is case sensitive, we need to search for both situations. In the first search we search by "this" users email
42-
// which might be mixed case (or not). Our second search is for the lowercase equivalent but only if two searches
43-
// would be different.
44-
const searchMultipleEmailCases = async () => {
45-
let userAccountsFound = [];
46-
47-
// Push the
48-
userAccountsFound.push(
49-
mgmtClient.usersByEmail.getByEmail({ email: event.user.email })
50-
);
51-
52-
// if this user is mixed case, we need to also search for the lower case equivalent
53-
if (event.user.email !== event.user.email.toLowerCase()) {
54-
userAccountsFound.push(
55-
mgmtClient.usersByEmail.getByEmail({
56-
email: event.user.email.toLowerCase(),
57-
})
58-
);
59-
}
60-
61-
// await all json responses promises to resolve
62-
const allJSONResponses = await Promise.all(userAccountsFound);
63-
64-
// flatten the array of arrays to get one array of profiles
65-
const mergedDataProfiles = allJSONResponses.reduce((acc, response) => {
66-
return acc.concat(response.data);
67-
}, []);
68-
69-
return mergedDataProfiles;
70-
};
71-
72-
const linkAccount = async (otherProfile) => {
73-
// sanity check if both accounts have LDAP as primary
74-
// we should NOT link these accounts and simply allow the user to continue logging in.
75-
if (
76-
event.user.user_id.startsWith("ad|Mozilla-LDAP") &&
77-
otherProfile.user_id.startsWith("ad|Mozilla-LDAP")
78-
) {
79-
console.error(
80-
`Error: both ${event.user.user_id} and ${otherProfile.user_id} are LDAP Primary accounts. Linking will not occur.`
81-
);
82-
return; // Continue with user login without account linking
83-
}
84-
85-
// LDAP takes priority being the primary identity
86-
// So we need to determine if one or neither are LDAP
87-
// If both are non-primary, linking order doesn't matter
88-
let primaryUser;
89-
let secondaryUser;
90-
91-
if (event.user.user_id.startsWith("ad|Mozilla-LDAP")) {
92-
primaryUser = event.user;
93-
secondaryUser = otherProfile;
94-
} else {
95-
primaryUser = otherProfile;
96-
secondaryUser = event.user;
97-
}
98-
99-
// Link the secondary account into the primary account
100-
console.log(
101-
`Linking secondary identity ${secondaryUser.user_id} into primary identity ${primaryUser.user_id}`
102-
);
103-
104-
// We no longer keep the user_metadata nor app_metadata from the secondary account
105-
// that is being linked. If the primary account is LDAP, then its existing
106-
// metadata should prevail. And in the case of both, primary and secondary being
107-
// non-ldap, account priority does not matter and neither does the metadata of
108-
// the secondary account.
109-
110-
// Link the accounts
111-
try {
112-
await mgmtClient.users.link(
113-
{ id: String(primaryUser.user_id) },
114-
{
115-
provider: secondaryUser.identities[0].provider,
116-
user_id: secondaryUser.identities[0].user_id,
117-
}
118-
);
119-
120-
// Auth0 Action api object provides a method for updating the current
121-
// authenticated user to the new user_id after account linking has taken place
122-
api.authentication.setPrimaryUser(primaryUser.user_id);
123-
} catch (err) {
124-
console.error("An unknown error occurred while linking accounts:", err);
125-
throw err;
126-
}
127-
128-
return;
129-
};
130-
131154
// Main
155+
let candidateUserAccountList;
132156
try {
133157
// Search for multiple accounts of the same user to link
134-
let userAccountList = await searchMultipleEmailCases();
135-
136-
// Ignore non-verified users
137-
userAccountList = userAccountList.filter((u) => u.email_verified);
158+
candidateUserAccountList = await searchMultipleEmailCases(
159+
mgmtClient,
160+
event.user.email
161+
);
162+
} catch (err) {
163+
console.error(
164+
"Error searching for users by email:",
165+
err.errorCode,
166+
err.statusCode,
167+
err.error
168+
);
169+
return;
170+
}
138171

139-
if (userAccountList.length <= 1) {
140-
// The user logged in with an identity which is the only one Auth0 knows about
141-
// or no data returned
142-
// Do not perform any account linking
143-
return;
144-
}
172+
// Ignore non-verified users
173+
let userAccountList = candidateUserAccountList.filter(
174+
(u) => u.email_verified
175+
);
145176

146-
if (userAccountList.length === 2) {
147-
// Auth0 is aware of 2 identities with the same email address which means
148-
// that the user just logged in with a new identity that hasn't been linked
149-
// into the other existing identity. Here we pass the other account to the
150-
// linking function
177+
// The user logged in with an identity which is the only one Auth0 knows about
178+
// or no data returned. Do not perform any account linking
179+
if (userAccountList.length <= 1) {
180+
return;
181+
}
151182

152-
await linkAccount(
183+
// Auth0 is aware of 2 identities with the same email address which means
184+
// that the user just logged in with a new identity that hasn't been linked
185+
// into the other existing identity. Here we pass the other account to the
186+
// linking function.
187+
if (userAccountList.length === 2) {
188+
try {
189+
return await linkAccount(
190+
api,
191+
event,
192+
mgmtClient,
153193
userAccountList.filter((u) => u.user_id !== event.user.user_id)[0]
154194
);
155-
} else {
156-
// data.length is > 2 which, post November 2020 when all identities were
157-
// force linked manually, shouldn't be possible
158-
var error_message =
159-
`Error linking account ${event.user.user_id} as there are ` +
160-
`over 2 identities with the email address ${event.user.email} ` +
161-
userAccountList.map((x) => x.user_id).join();
162-
console.error(error_message);
163-
throw new Error(error_message);
195+
} catch (err) {
196+
return api.access.deny("Error linking accounts. Please contact support.");
164197
}
165-
} catch (err) {
166-
console.error("An error occurred while linking accounts:", err);
167-
return api.access.deny(err.message || String(err));
168198
}
169199

170-
return;
200+
// data.length is > 2 which, post November 2020 when all identities were
201+
// force linked manually, shouldn't be possible.
202+
//
203+
// While this is, strictly speaking, not an authentication failure, it is a
204+
// state that we don't expect to be in and should be investigated immediately.
205+
var error_message =
206+
`Error linking account ${event.user.user_id} as there are ` +
207+
`over 2 identities with the email address ${event.user.email} ` +
208+
userAccountList.map((x) => x.user_id).join();
209+
console.error(error_message);
210+
return api.access.deny(error_message);
171211
};

0 commit comments

Comments
 (0)