Skip to content

Commit 59d93aa

Browse files
committed
Add ability to link a federated id with the updateUser() method.
1 parent bd9a0dd commit 59d93aa

File tree

6 files changed

+500
-45
lines changed

6 files changed

+500
-45
lines changed

src/auth/auth-api-request.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
265265
phoneNumber: true,
266266
customAttributes: true,
267267
validSince: true,
268+
// Pass linkProviderUserInfo only for updates (i.e. not for uploads.)
269+
linkProviderUserInfo: !uploadAccountRequest,
268270
// Pass tenantId only for uploadAccount requests.
269271
tenantId: uploadAccountRequest,
270272
passwordHash: uploadAccountRequest,
@@ -410,6 +412,11 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
410412
validateProviderUserInfo(providerUserInfoEntry);
411413
});
412414
}
415+
416+
// linkProviderUserInfo must be a (single) UserInfo value.
417+
if (typeof request.linkProviderUserInfo !== 'undefined') {
418+
validateProviderUserInfo(request.linkProviderUserInfo);
419+
}
413420
}
414421

415422

@@ -961,6 +968,31 @@ export abstract class AbstractAuthRequestHandler {
961968
'Properties argument must be a non-null object.',
962969
),
963970
);
971+
} else if (validator.isNonNullObject(properties.providerToLink)) {
972+
if (!validator.isNonEmptyString(properties.providerToLink.providerId)) {
973+
throw new FirebaseAuthError(
974+
AuthClientErrorCode.INVALID_ARGUMENT,
975+
'providerToLink.providerId of properties argument must be a non-empty string.');
976+
}
977+
if (!validator.isNonEmptyString(properties.providerToLink.uid)) {
978+
throw new FirebaseAuthError(
979+
AuthClientErrorCode.INVALID_ARGUMENT,
980+
'providerToLink.uid of properties argument must be a non-empty string.');
981+
}
982+
} else if (typeof properties.providersToDelete !== 'undefined') {
983+
if (!validator.isNonEmptyArray(properties.providersToDelete)) {
984+
throw new FirebaseAuthError(
985+
AuthClientErrorCode.INVALID_ARGUMENT,
986+
'providersToDelete of properties argument must be a non-empty array of strings.');
987+
}
988+
989+
properties.providersToDelete.forEach((providerId) => {
990+
if (!validator.isNonEmptyString(providerId)) {
991+
throw new FirebaseAuthError(
992+
AuthClientErrorCode.INVALID_ARGUMENT,
993+
'providersToDelete of properties argument must be a non-empty array of strings.');
994+
}
995+
});
964996
}
965997

966998
// Build the setAccountInfo request.
@@ -995,13 +1027,25 @@ export abstract class AbstractAuthRequestHandler {
9951027
// It will be removed from the backend request and an additional parameter
9961028
// deleteProvider: ['phone'] with an array of providerIds (phone in this case),
9971029
// will be passed.
998-
// Currently this applies to phone provider only.
9991030
if (request.phoneNumber === null) {
1000-
request.deleteProvider = ['phone'];
1031+
request.deleteProvider ? request.deleteProvider.push('phone') : request.deleteProvider = ['phone'];
10011032
delete request.phoneNumber;
1002-
} else {
1003-
// Doesn't apply to other providers in admin SDK.
1004-
delete request.deleteProvider;
1033+
}
1034+
1035+
if (typeof(request.providerToLink) !== 'undefined') {
1036+
request.linkProviderUserInfo = deepCopy(request.providerToLink);
1037+
delete request.providerToLink;
1038+
1039+
request.linkProviderUserInfo.rawId = request.linkProviderUserInfo.uid;
1040+
delete request.linkProviderUserInfo.uid;
1041+
}
1042+
1043+
if (typeof(request.providersToDelete) !== 'undefined') {
1044+
if (!validator.isArray(request.deleteProvider)) {
1045+
request.deleteProvider = [];
1046+
}
1047+
request.deleteProvider = request.deleteProvider.concat(request.providersToDelete);
1048+
delete request.providersToDelete;
10051049
}
10061050

10071051
// Rewrite photoURL to photoUrl.

src/auth/auth.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
import {deepCopy} from '../utils/deep-copy';
1718
import {UserRecord, CreateRequest, UpdateRequest} from './user-record';
1819
import {FirebaseApp} from '../firebase-app';
1920
import {FirebaseTokenGenerator, cryptoSignerFromApp} from './token-generator';
@@ -279,6 +280,49 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> {
279280
* @return {Promise<UserRecord>} A promise that resolves with the modified user record.
280281
*/
281282
public updateUser(uid: string, properties: UpdateRequest): Promise<UserRecord> {
283+
// Although we don't really advertise it, we want to also handle linking of
284+
// non-federated idps with this call. So if we detect one of them, we'll
285+
// adjust the properties parameter appropriately. This *does* imply that a
286+
// conflict could arise, e.g. if the user provides a phoneNumber property,
287+
// but also provides a providerToLink with a 'phone' provider id. In that
288+
// case, we'll throw an error.
289+
properties = deepCopy(properties);
290+
if (validator.isNonNullObject(properties) && typeof properties.providerToLink !== 'undefined') {
291+
if (properties.providerToLink.providerId === 'email') {
292+
if (typeof properties.email !== 'undefined') {
293+
throw new FirebaseAuthError(
294+
AuthClientErrorCode.INVALID_ARGUMENT,
295+
"Both UpdateRequest.email and UpdateRequest.providerToLink.providerId='email' were set. To "
296+
+ 'link to the email/password provider, only specify the UpdateRequest.email field.');
297+
}
298+
properties.email = properties.providerToLink.uid;
299+
delete properties.providerToLink;
300+
} else if (properties.providerToLink.providerId === 'phone') {
301+
if (typeof properties.phoneNumber !== 'undefined') {
302+
throw new FirebaseAuthError(
303+
AuthClientErrorCode.INVALID_ARGUMENT,
304+
"Both UpdateRequest.phoneNumber and UpdateRequest.providerToLink.providerId='phone' were set. To "
305+
+ 'link to a phone provider, only specify the UpdateRequest.phoneNumber field.');
306+
}
307+
properties.phoneNumber = properties.providerToLink.uid;
308+
delete properties.providerToLink;
309+
}
310+
}
311+
if (validator.isNonNullObject(properties) && typeof properties.providersToDelete !== 'undefined') {
312+
if (properties.providersToDelete.indexOf('phone') !== -1) {
313+
// If we've been told to unlink the phone provider both via setting
314+
// phoneNumber to null *and* by setting providersToDelete to include
315+
// 'phone', then we'll reject that. Though it might also be reasonable
316+
// to relax this restriction and just unlink it.
317+
if (properties.phoneNumber === null) {
318+
throw new FirebaseAuthError(
319+
AuthClientErrorCode.INVALID_ARGUMENT,
320+
"Both UpdateRequest.phoneNumber=null and UpdateRequest.providersToDelete=['phone'] were set. To "
321+
+ 'unlink from a phone provider, only specify the UpdateRequest.phoneNumber=null field.');
322+
}
323+
}
324+
}
325+
282326
return this.authRequestHandler.updateExistingAccount(uid, properties)
283327
.then((existingUid) => {
284328
// Return the corresponding user record.

src/auth/user-record.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export interface UpdateRequest {
5050
password?: string;
5151
phoneNumber?: string | null;
5252
photoURL?: string | null;
53+
providerToLink?: UserProvider;
54+
providersToDelete?: string[];
5355
}
5456

5557
/** Parameters for create user operation */
@@ -132,6 +134,41 @@ export class UserInfo {
132134
}
133135
}
134136

137+
/**
138+
* Represents a user identity provider that can be associated with a Firebase user.
139+
*/
140+
interface UserProvider {
141+
/**
142+
* The user identifier for the linked provider.
143+
*/
144+
uid?: string;
145+
146+
/**
147+
* The display name for the linked provider.
148+
*/
149+
displayName?: string;
150+
151+
/**
152+
* The email for the linked provider.
153+
*/
154+
email?: string;
155+
156+
/**
157+
* The phone number for the linked provider.
158+
*/
159+
phoneNumber?: string;
160+
161+
/**
162+
* The photo URL for the linked provider.
163+
*/
164+
photoURL?: string;
165+
166+
/**
167+
* The linked provider ID (for example, "google.com" for the Google provider).
168+
*/
169+
providerId?: string;
170+
}
171+
135172
/**
136173
* User record class that defines the Firebase user object populated from
137174
* the Firebase Auth getAccountInfo response.

src/index.d.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,42 @@ declare namespace admin.auth {
545545
toJSON(): Object;
546546
}
547547

548+
/**
549+
* Represents a user identity provider that can be associated with a Firebase user.
550+
*/
551+
interface UserProvider {
552+
553+
/**
554+
* The user identifier for the linked provider.
555+
*/
556+
uid?: string;
557+
558+
/**
559+
* The display name for the linked provider.
560+
*/
561+
displayName?: string;
562+
563+
/**
564+
* The email for the linked provider.
565+
*/
566+
email?: string;
567+
568+
/**
569+
* The phone number for the linked provider.
570+
*/
571+
phoneNumber?: string;
572+
573+
/**
574+
* The photo URL for the linked provider.
575+
*/
576+
photoURL?: string;
577+
578+
/**
579+
* The linked provider ID (for example, "google.com" for the Google provider).
580+
*/
581+
providerId?: string;
582+
}
583+
548584
/**
549585
* Interface representing a user.
550586
*/
@@ -686,6 +722,16 @@ declare namespace admin.auth {
686722
* The user's photo URL.
687723
*/
688724
photoURL?: string | null;
725+
726+
/**
727+
* Links this user to the specified federated provider.
728+
*/
729+
providerToLink?: UserProvider;
730+
731+
/**
732+
* Unlinks this user from the specified federated providers.
733+
*/
734+
providersToDelete?: string[];
689735
}
690736

691737
/**

test/integration/auth.spec.ts

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -323,22 +323,106 @@ describe('admin.auth', () => {
323323
});
324324
});
325325

326-
it('updateUser() updates the user record with the given parameters', () => {
327-
const updatedDisplayName = 'Updated User ' + newUserUid;
328-
return admin.auth().updateUser(newUserUid, {
329-
email: updatedEmail,
330-
phoneNumber: updatedPhone,
331-
emailVerified: true,
332-
displayName: updatedDisplayName,
333-
})
334-
.then((userRecord) => {
335-
expect(userRecord.emailVerified).to.be.true;
336-
expect(userRecord.displayName).to.equal(updatedDisplayName);
337-
// Confirm expected email.
338-
expect(userRecord.email).to.equal(updatedEmail);
339-
// Confirm expected phone number.
340-
expect(userRecord.phoneNumber).to.equal(updatedPhone);
326+
describe('updateUser()', () => {
327+
/**
328+
* Creates a new user for testing purposes. The user's uid will be
329+
* '$name_$tenRandomChars' and email will be
330+
331+
*/
332+
// TODO(rsgowman): This function could usefully be employed throughout this file.
333+
function createTestUser(name: string): Promise<admin.auth.UserRecord> {
334+
const tenRandomChars = generateRandomString(10);
335+
return admin.auth().createUser({
336+
uid: name + '_' + tenRandomChars,
337+
displayName: name,
338+
email: name + '_' + tenRandomChars + '@example.com',
339+
});
340+
}
341+
342+
let updateUser: admin.auth.UserRecord;
343+
before(async () => {
344+
updateUser = await createTestUser('UpdateUser');
345+
});
346+
347+
after(() => {
348+
return safeDelete(updateUser.uid);
349+
});
350+
351+
it('updates the user record with the given parameters', async () => {
352+
const updatedDisplayName = 'Updated User ' + updateUser.uid;
353+
const userRecord = await admin.auth().updateUser(updateUser.uid, {
354+
email: updatedEmail,
355+
phoneNumber: updatedPhone,
356+
emailVerified: true,
357+
displayName: updatedDisplayName,
358+
});
359+
360+
expect(userRecord.emailVerified).to.be.true;
361+
expect(userRecord.displayName).to.equal(updatedDisplayName);
362+
// Confirm expected email.
363+
expect(userRecord.email).to.equal(updatedEmail);
364+
// Confirm expected phone number.
365+
expect(userRecord.phoneNumber).to.equal(updatedPhone);
366+
});
367+
368+
it('can link/unlink with a federated provider', async () => {
369+
const federatedUid = 'google_uid_' + generateRandomString(10);
370+
let userRecord = await admin.auth().updateUser(updateUser.uid, {
371+
providerToLink: {
372+
providerId: 'google.com',
373+
uid: federatedUid,
374+
},
375+
});
376+
377+
let providerUids = userRecord.providerData.map((userInfo) => userInfo.uid);
378+
let providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId);
379+
expect(providerUids).to.deep.include(federatedUid);
380+
expect(providerIds).to.deep.include('google.com');
381+
382+
userRecord = await admin.auth().updateUser(updateUser.uid, {
383+
providersToDelete: ['google.com'],
384+
});
385+
386+
providerUids = userRecord.providerData.map((userInfo) => userInfo.uid);
387+
providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId);
388+
expect(providerUids).to.not.deep.include(federatedUid);
389+
expect(providerIds).to.not.deep.include('google.com');
390+
});
391+
392+
it('can unlink multiple providers at once, incl a non-federated provider', async () => {
393+
await deletePhoneNumberUser('+15555550001');
394+
395+
const googleFederatedUid = 'google_uid_' + generateRandomString(10);
396+
const facebookFederatedUid = 'facebook_uid_' + generateRandomString(10);
397+
398+
let userRecord = await admin.auth().updateUser(updateUser.uid, {
399+
phoneNumber: '+15555550001',
400+
providerToLink: {
401+
providerId: 'google.com',
402+
uid: googleFederatedUid,
403+
},
341404
});
405+
userRecord = await admin.auth().updateUser(updateUser.uid, {
406+
providerToLink: {
407+
providerId: 'facebook.com',
408+
uid: facebookFederatedUid,
409+
},
410+
});
411+
412+
let providerUids = userRecord.providerData.map((userInfo) => userInfo.uid);
413+
let providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId);
414+
expect(providerUids).to.deep.include.members([googleFederatedUid, facebookFederatedUid, '+15555550001']);
415+
expect(providerIds).to.deep.include.members(['google.com', 'facebook.com', 'phone']);
416+
417+
userRecord = await admin.auth().updateUser(updateUser.uid, {
418+
providersToDelete: ['google.com', 'facebook.com', 'phone'],
419+
});
420+
421+
providerUids = userRecord.providerData.map((userInfo) => userInfo.uid);
422+
providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId);
423+
expect(providerUids).to.not.deep.include.members([googleFederatedUid, facebookFederatedUid, '+15555550001']);
424+
expect(providerIds).to.not.deep.include.members(['google.com', 'facebook.com', 'phone']);
425+
});
342426
});
343427

344428
it('getUser() fails when called with a non-existing UID', () => {
@@ -1615,11 +1699,11 @@ function testImportAndSignInUser(
16151699
/**
16161700
* Helper function that deletes the user with the specified phone number
16171701
* if it exists.
1618-
* @param {string} phoneNumber The phone number of the user to delete.
1619-
* @return {Promise} A promise that resolves when the user is deleted
1702+
* @param phoneNumber The phone number of the user to delete.
1703+
* @return A promise that resolves when the user is deleted
16201704
* or is found not to exist.
16211705
*/
1622-
function deletePhoneNumberUser(phoneNumber: string) {
1706+
function deletePhoneNumberUser(phoneNumber: string): Promise<void> {
16231707
return admin.auth().getUserByPhoneNumber(phoneNumber)
16241708
.then((userRecord) => {
16251709
return safeDelete(userRecord.uid);

0 commit comments

Comments
 (0)