Skip to content

Commit bea66a9

Browse files
authored
feat(auth): Add ability to link a federated ID with the updateUser() method. (#770)
1 parent a00ce05 commit bea66a9

File tree

6 files changed

+543
-95
lines changed

6 files changed

+543
-95
lines changed

etc/firebase-admin.api.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ export namespace auth {
300300
password?: string;
301301
phoneNumber?: string | null;
302302
photoURL?: string | null;
303+
providersToUnlink?: string[];
304+
providerToLink?: UserProvider;
303305
}
304306
export interface UpdateTenantRequest {
305307
anonymousSignInEnabled?: boolean;
@@ -365,6 +367,14 @@ export namespace auth {
365367
creationTime?: string;
366368
lastSignInTime?: string;
367369
}
370+
export interface UserProvider {
371+
displayName?: string;
372+
email?: string;
373+
phoneNumber?: string;
374+
photoURL?: string;
375+
providerId?: string;
376+
uid?: string;
377+
}
368378
export interface UserProviderRequest {
369379
displayName?: string;
370380
email?: string;
@@ -393,6 +403,7 @@ export namespace auth {
393403
tokensValidAfterTime?: string;
394404
uid: string;
395405
}
406+
{};
396407
}
397408

398409
// @public (undocumented)

src/auth/auth-api-request.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,8 @@ function validateCreateEditRequest(request: any, writeOperationType: WriteOperat
403403
phoneNumber: true,
404404
customAttributes: true,
405405
validSince: true,
406+
// Pass linkProviderUserInfo only for updates (i.e. not for uploads.)
407+
linkProviderUserInfo: !uploadAccountRequest,
406408
// Pass tenantId only for uploadAccount requests.
407409
tenantId: uploadAccountRequest,
408410
passwordHash: uploadAccountRequest,
@@ -551,6 +553,12 @@ function validateCreateEditRequest(request: any, writeOperationType: WriteOperat
551553
validateProviderUserInfo(providerUserInfoEntry);
552554
});
553555
}
556+
557+
// linkProviderUserInfo must be a (single) UserProvider value.
558+
if (typeof request.linkProviderUserInfo !== 'undefined') {
559+
validateProviderUserInfo(request.linkProviderUserInfo);
560+
}
561+
554562
// mfaInfo is used for importUsers.
555563
// mfa.enrollments is used for setAccountInfo.
556564
// enrollments has to be an array of valid AuthFactorInfo requests.
@@ -1306,6 +1314,33 @@ export abstract class AbstractAuthRequestHandler {
13061314
'Properties argument must be a non-null object.',
13071315
),
13081316
);
1317+
} else if (validator.isNonNullObject(properties.providerToLink)) {
1318+
// TODO(rsgowman): These checks overlap somewhat with
1319+
// validateProviderUserInfo. It may be possible to refactor a bit.
1320+
if (!validator.isNonEmptyString(properties.providerToLink.providerId)) {
1321+
throw new FirebaseAuthError(
1322+
AuthClientErrorCode.INVALID_ARGUMENT,
1323+
'providerToLink.providerId of properties argument must be a non-empty string.');
1324+
}
1325+
if (!validator.isNonEmptyString(properties.providerToLink.uid)) {
1326+
throw new FirebaseAuthError(
1327+
AuthClientErrorCode.INVALID_ARGUMENT,
1328+
'providerToLink.uid of properties argument must be a non-empty string.');
1329+
}
1330+
} else if (typeof properties.providersToUnlink !== 'undefined') {
1331+
if (!validator.isArray(properties.providersToUnlink)) {
1332+
throw new FirebaseAuthError(
1333+
AuthClientErrorCode.INVALID_ARGUMENT,
1334+
'providersToUnlink of properties argument must be an array of strings.');
1335+
}
1336+
1337+
properties.providersToUnlink.forEach((providerId) => {
1338+
if (!validator.isNonEmptyString(providerId)) {
1339+
throw new FirebaseAuthError(
1340+
AuthClientErrorCode.INVALID_ARGUMENT,
1341+
'providersToUnlink of properties argument must be an array of strings.');
1342+
}
1343+
});
13091344
}
13101345

13111346
// Build the setAccountInfo request.
@@ -1340,13 +1375,25 @@ export abstract class AbstractAuthRequestHandler {
13401375
// It will be removed from the backend request and an additional parameter
13411376
// deleteProvider: ['phone'] with an array of providerIds (phone in this case),
13421377
// will be passed.
1343-
// Currently this applies to phone provider only.
13441378
if (request.phoneNumber === null) {
1345-
request.deleteProvider = ['phone'];
1379+
request.deleteProvider ? request.deleteProvider.push('phone') : request.deleteProvider = ['phone'];
13461380
delete request.phoneNumber;
1347-
} else {
1348-
// Doesn't apply to other providers in admin SDK.
1349-
delete request.deleteProvider;
1381+
}
1382+
1383+
if (typeof(request.providerToLink) !== 'undefined') {
1384+
request.linkProviderUserInfo = deepCopy(request.providerToLink);
1385+
delete request.providerToLink;
1386+
1387+
request.linkProviderUserInfo.rawId = request.linkProviderUserInfo.uid;
1388+
delete request.linkProviderUserInfo.uid;
1389+
}
1390+
1391+
if (typeof(request.providersToUnlink) !== 'undefined') {
1392+
if (!validator.isArray(request.deleteProvider)) {
1393+
request.deleteProvider = [];
1394+
}
1395+
request.deleteProvider = request.deleteProvider.concat(request.providersToUnlink);
1396+
delete request.providersToUnlink;
13501397
}
13511398

13521399
// Rewrite photoURL to photoUrl.

src/auth/auth.ts

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

18+
import { deepCopy } from '../utils/deep-copy';
1819
import { UserRecord } from './user-record';
1920
import {
2021
isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier,
@@ -381,6 +382,50 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
381382
* @return {Promise<UserRecord>} A promise that resolves with the modified user record.
382383
*/
383384
public updateUser(uid: string, properties: UpdateRequest): Promise<UserRecord> {
385+
// Although we don't really advertise it, we want to also handle linking of
386+
// non-federated idps with this call. So if we detect one of them, we'll
387+
// adjust the properties parameter appropriately. This *does* imply that a
388+
// conflict could arise, e.g. if the user provides a phoneNumber property,
389+
// but also provides a providerToLink with a 'phone' provider id. In that
390+
// case, we'll throw an error.
391+
properties = deepCopy(properties);
392+
393+
if (properties?.providerToLink) {
394+
if (properties.providerToLink.providerId === 'email') {
395+
if (typeof properties.email !== 'undefined') {
396+
throw new FirebaseAuthError(
397+
AuthClientErrorCode.INVALID_ARGUMENT,
398+
"Both UpdateRequest.email and UpdateRequest.providerToLink.providerId='email' were set. To "
399+
+ 'link to the email/password provider, only specify the UpdateRequest.email field.');
400+
}
401+
properties.email = properties.providerToLink.uid;
402+
delete properties.providerToLink;
403+
} else if (properties.providerToLink.providerId === 'phone') {
404+
if (typeof properties.phoneNumber !== 'undefined') {
405+
throw new FirebaseAuthError(
406+
AuthClientErrorCode.INVALID_ARGUMENT,
407+
"Both UpdateRequest.phoneNumber and UpdateRequest.providerToLink.providerId='phone' were set. To "
408+
+ 'link to a phone provider, only specify the UpdateRequest.phoneNumber field.');
409+
}
410+
properties.phoneNumber = properties.providerToLink.uid;
411+
delete properties.providerToLink;
412+
}
413+
}
414+
if (properties?.providersToUnlink) {
415+
if (properties.providersToUnlink.indexOf('phone') !== -1) {
416+
// If we've been told to unlink the phone provider both via setting
417+
// phoneNumber to null *and* by setting providersToUnlink to include
418+
// 'phone', then we'll reject that. Though it might also be reasonable
419+
// to relax this restriction and just unlink it.
420+
if (properties.phoneNumber === null) {
421+
throw new FirebaseAuthError(
422+
AuthClientErrorCode.INVALID_ARGUMENT,
423+
"Both UpdateRequest.phoneNumber=null and UpdateRequest.providersToUnlink=['phone'] were set. To "
424+
+ 'unlink from a phone provider, only specify the UpdateRequest.phoneNumber=null field.');
425+
}
426+
}
427+
}
428+
384429
return this.authRequestHandler.updateExistingAccount(uid, properties)
385430
.then((existingUid) => {
386431
// Return the corresponding user record.

src/auth/index.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,42 @@ export namespace auth {
153153
phoneNumber: string;
154154
}
155155

156+
/**
157+
* Represents a user identity provider that can be associated with a Firebase user.
158+
*/
159+
interface UserProvider {
160+
161+
/**
162+
* The user identifier for the linked provider.
163+
*/
164+
uid?: string;
165+
166+
/**
167+
* The display name for the linked provider.
168+
*/
169+
displayName?: string;
170+
171+
/**
172+
* The email for the linked provider.
173+
*/
174+
email?: string;
175+
176+
/**
177+
* The phone number for the linked provider.
178+
*/
179+
phoneNumber?: string;
180+
181+
/**
182+
* The photo URL for the linked provider.
183+
*/
184+
photoURL?: string;
185+
186+
/**
187+
* The linked provider ID (for example, "google.com" for the Google provider).
188+
*/
189+
providerId?: string;
190+
}
191+
156192
/**
157193
* Interface representing a user.
158194
*/
@@ -384,6 +420,26 @@ export namespace auth {
384420
* The user's updated multi-factor related properties.
385421
*/
386422
multiFactor?: MultiFactorUpdateSettings;
423+
424+
/**
425+
* Links this user to the specified provider.
426+
*
427+
* Linking a provider to an existing user account does not invalidate the
428+
* refresh token of that account. In other words, the existing account
429+
* would continue to be able to access resources, despite not having used
430+
* the newly linked provider to log in. If you wish to force the user to
431+
* authenticate with this new provider, you need to (a) revoke their
432+
* refresh token (see
433+
* https://firebase.google.com/docs/auth/admin/manage-sessions#revoke_refresh_tokens),
434+
* and (b) ensure no other authentication methods are present on this
435+
* account.
436+
*/
437+
providerToLink?: UserProvider;
438+
439+
/**
440+
* Unlinks this user from the specified providers.
441+
*/
442+
providersToUnlink?: string[];
387443
}
388444

389445
/**

0 commit comments

Comments
 (0)