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
1514const 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+
17131exports . 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