Skip to content

Commit 7349ee8

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

1 file changed

Lines changed: 175 additions & 120 deletions

File tree

tf/actions/linkUserByEmail.js

Lines changed: 175 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,131 @@
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.
1212
*
13+
* A failure to link users isn't considered an authentication failure. We should
14+
* still allow the authentication process to continue. The errors related to
15+
* unlinked accounts will crop-up else where, such as when trying to log in with
16+
* a non-LDAP identity. (Enforced in ensureLdapUsersUseLdap).
1317
*/
1418

1519
const auth0Sdk = require("auth0");
1620

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

@@ -37,135 +158,69 @@ exports.onExecutePostLogin = async (event, api) => {
37158
scope: "update:users",
38159
});
39160

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-
131161
// Main
162+
let candidateUserAccountList;
132163
try {
133164
// 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);
165+
candidateUserAccountList = await searchMultipleEmailCases(
166+
mgmtClient,
167+
event.user.email
168+
);
169+
} catch (err) {
170+
console.error(
171+
"Error searching for users by email:",
172+
err.errorCode,
173+
err.statusCode,
174+
err.error
175+
);
176+
return;
177+
}
138178

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-
}
179+
// Ignore non-verified users
180+
let userAccountList = candidateUserAccountList.filter(
181+
(u) => u.email_verified
182+
);
145183

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
184+
// The user logged in with an identity which is the only one Auth0 knows about
185+
// or no data returned. Do not perform any account linking
186+
if (userAccountList.length <= 1) {
187+
return;
188+
}
151189

152-
await linkAccount(
190+
// Auth0 is aware of 2 identities with the same email address which means
191+
// that the user just logged in with a new identity that hasn't been linked
192+
// into the other existing identity. Here we pass the other account to the
193+
// linking function.
194+
//
195+
// If we fail to link the accounts, the user should still be allowed to log in.
196+
if (userAccountList.length === 2) {
197+
try {
198+
return await linkAccount(
199+
api,
200+
event,
201+
mgmtClient,
153202
userAccountList.filter((u) => u.user_id !== event.user.user_id)[0]
154203
);
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);
204+
} catch (err) {
205+
console.error(
206+
"Error linking accounts:",
207+
err.errorCode,
208+
err.statusCode,
209+
err.error
210+
);
211+
return;
164212
}
165-
} catch (err) {
166-
console.error("An error occurred while linking accounts:", err);
167-
return api.access.deny(err.message || String(err));
168213
}
169214

170-
return;
215+
// data.length is > 2 which, post November 2020 when all identities were
216+
// force linked manually, shouldn't be possible.
217+
//
218+
// While this is, strictly speaking, not an authentication failure, it is a
219+
// state that we don't expect to be in and should be investigated immediately.
220+
var error_message =
221+
`Error linking account ${event.user.user_id} as there are ` +
222+
`over 2 identities with the email address ${event.user.email} ` +
223+
userAccountList.map((x) => x.user_id).join();
224+
console.error(error_message);
225+
return api.access.deny(error_message);
171226
};

0 commit comments

Comments
 (0)