Skip to content

Commit 5c44301

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

1 file changed

Lines changed: 173 additions & 120 deletions

File tree

tf/actions/linkUserByEmail.js

Lines changed: 173 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,129 @@
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(usersByEmail(mgmtClient, email.toLowerCase()));
127+
}
128+
129+
// await all json responses promises to resolve
130+
const allJSONResponses = await Promise.all(userAccountsFound);
131+
132+
// flatten the array of arrays to get one array of profiles
133+
return allJSONResponses.reduce(mergeAccountLists, []);
134+
}
135+
17136
exports.onExecutePostLogin = async (event, api) => {
18137
console.log("Running actions:", "linkUsersByEmail");
19138

@@ -37,135 +156,69 @@ exports.onExecutePostLogin = async (event, api) => {
37156
scope: "update:users",
38157
});
39158

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-
131159
// Main
160+
let candidateUserAccountList;
132161
try {
133162
// 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);
163+
candidateUserAccountList = await searchMultipleEmailCases(
164+
mgmtClient,
165+
event.user.email
166+
);
167+
} catch (err) {
168+
console.error(
169+
"Error searching for users by email:",
170+
err.errorCode,
171+
err.statusCode,
172+
err.error
173+
);
174+
return;
175+
}
138176

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

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

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

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

0 commit comments

Comments
 (0)