diff --git a/etc/firebase-admin.api.md b/etc/firebase-admin.api.md index 38981491e2..f90136685f 100644 --- a/etc/firebase-admin.api.md +++ b/etc/firebase-admin.api.md @@ -300,6 +300,8 @@ export namespace auth { password?: string; phoneNumber?: string | null; photoURL?: string | null; + providersToUnlink?: string[]; + providerToLink?: UserProvider; } export interface UpdateTenantRequest { anonymousSignInEnabled?: boolean; @@ -365,6 +367,14 @@ export namespace auth { creationTime?: string; lastSignInTime?: string; } + export interface UserProvider { + displayName?: string; + email?: string; + phoneNumber?: string; + photoURL?: string; + providerId?: string; + uid?: string; + } export interface UserProviderRequest { displayName?: string; email?: string; @@ -393,6 +403,7 @@ export namespace auth { tokensValidAfterTime?: string; uid: string; } + {}; } // @public (undocumented) diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 33be9da092..d3ecf73fc7 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -403,6 +403,8 @@ function validateCreateEditRequest(request: any, writeOperationType: WriteOperat phoneNumber: true, customAttributes: true, validSince: true, + // Pass linkProviderUserInfo only for updates (i.e. not for uploads.) + linkProviderUserInfo: !uploadAccountRequest, // Pass tenantId only for uploadAccount requests. tenantId: uploadAccountRequest, passwordHash: uploadAccountRequest, @@ -551,6 +553,12 @@ function validateCreateEditRequest(request: any, writeOperationType: WriteOperat validateProviderUserInfo(providerUserInfoEntry); }); } + + // linkProviderUserInfo must be a (single) UserProvider value. + if (typeof request.linkProviderUserInfo !== 'undefined') { + validateProviderUserInfo(request.linkProviderUserInfo); + } + // mfaInfo is used for importUsers. // mfa.enrollments is used for setAccountInfo. // enrollments has to be an array of valid AuthFactorInfo requests. @@ -1306,6 +1314,33 @@ export abstract class AbstractAuthRequestHandler { 'Properties argument must be a non-null object.', ), ); + } else if (validator.isNonNullObject(properties.providerToLink)) { + // TODO(rsgowman): These checks overlap somewhat with + // validateProviderUserInfo. It may be possible to refactor a bit. + if (!validator.isNonEmptyString(properties.providerToLink.providerId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'providerToLink.providerId of properties argument must be a non-empty string.'); + } + if (!validator.isNonEmptyString(properties.providerToLink.uid)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'providerToLink.uid of properties argument must be a non-empty string.'); + } + } else if (typeof properties.providersToUnlink !== 'undefined') { + if (!validator.isArray(properties.providersToUnlink)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'providersToUnlink of properties argument must be an array of strings.'); + } + + properties.providersToUnlink.forEach((providerId) => { + if (!validator.isNonEmptyString(providerId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'providersToUnlink of properties argument must be an array of strings.'); + } + }); } // Build the setAccountInfo request. @@ -1340,13 +1375,25 @@ export abstract class AbstractAuthRequestHandler { // It will be removed from the backend request and an additional parameter // deleteProvider: ['phone'] with an array of providerIds (phone in this case), // will be passed. - // Currently this applies to phone provider only. if (request.phoneNumber === null) { - request.deleteProvider = ['phone']; + request.deleteProvider ? request.deleteProvider.push('phone') : request.deleteProvider = ['phone']; delete request.phoneNumber; - } else { - // Doesn't apply to other providers in admin SDK. - delete request.deleteProvider; + } + + if (typeof(request.providerToLink) !== 'undefined') { + request.linkProviderUserInfo = deepCopy(request.providerToLink); + delete request.providerToLink; + + request.linkProviderUserInfo.rawId = request.linkProviderUserInfo.uid; + delete request.linkProviderUserInfo.uid; + } + + if (typeof(request.providersToUnlink) !== 'undefined') { + if (!validator.isArray(request.deleteProvider)) { + request.deleteProvider = []; + } + request.deleteProvider = request.deleteProvider.concat(request.providersToUnlink); + delete request.providersToUnlink; } // Rewrite photoURL to photoUrl. diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 76a7cf336c..aa5d7b11ef 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { deepCopy } from '../utils/deep-copy'; import { UserRecord } from './user-record'; import { isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, @@ -381,6 +382,50 @@ export class BaseAuth implements BaseAuthI * @return {Promise} A promise that resolves with the modified user record. */ public updateUser(uid: string, properties: UpdateRequest): Promise { + // Although we don't really advertise it, we want to also handle linking of + // non-federated idps with this call. So if we detect one of them, we'll + // adjust the properties parameter appropriately. This *does* imply that a + // conflict could arise, e.g. if the user provides a phoneNumber property, + // but also provides a providerToLink with a 'phone' provider id. In that + // case, we'll throw an error. + properties = deepCopy(properties); + + if (properties?.providerToLink) { + if (properties.providerToLink.providerId === 'email') { + if (typeof properties.email !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "Both UpdateRequest.email and UpdateRequest.providerToLink.providerId='email' were set. To " + + 'link to the email/password provider, only specify the UpdateRequest.email field.'); + } + properties.email = properties.providerToLink.uid; + delete properties.providerToLink; + } else if (properties.providerToLink.providerId === 'phone') { + if (typeof properties.phoneNumber !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "Both UpdateRequest.phoneNumber and UpdateRequest.providerToLink.providerId='phone' were set. To " + + 'link to a phone provider, only specify the UpdateRequest.phoneNumber field.'); + } + properties.phoneNumber = properties.providerToLink.uid; + delete properties.providerToLink; + } + } + if (properties?.providersToUnlink) { + if (properties.providersToUnlink.indexOf('phone') !== -1) { + // If we've been told to unlink the phone provider both via setting + // phoneNumber to null *and* by setting providersToUnlink to include + // 'phone', then we'll reject that. Though it might also be reasonable + // to relax this restriction and just unlink it. + if (properties.phoneNumber === null) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "Both UpdateRequest.phoneNumber=null and UpdateRequest.providersToUnlink=['phone'] were set. To " + + 'unlink from a phone provider, only specify the UpdateRequest.phoneNumber=null field.'); + } + } + } + return this.authRequestHandler.updateExistingAccount(uid, properties) .then((existingUid) => { // Return the corresponding user record. diff --git a/src/auth/index.ts b/src/auth/index.ts index f3a387393d..7817435c14 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -153,6 +153,42 @@ export namespace auth { phoneNumber: string; } + /** + * Represents a user identity provider that can be associated with a Firebase user. + */ + interface UserProvider { + + /** + * The user identifier for the linked provider. + */ + uid?: string; + + /** + * The display name for the linked provider. + */ + displayName?: string; + + /** + * The email for the linked provider. + */ + email?: string; + + /** + * The phone number for the linked provider. + */ + phoneNumber?: string; + + /** + * The photo URL for the linked provider. + */ + photoURL?: string; + + /** + * The linked provider ID (for example, "google.com" for the Google provider). + */ + providerId?: string; + } + /** * Interface representing a user. */ @@ -384,6 +420,26 @@ export namespace auth { * The user's updated multi-factor related properties. */ multiFactor?: MultiFactorUpdateSettings; + + /** + * Links this user to the specified provider. + * + * Linking a provider to an existing user account does not invalidate the + * refresh token of that account. In other words, the existing account + * would continue to be able to access resources, despite not having used + * the newly linked provider to log in. If you wish to force the user to + * authenticate with this new provider, you need to (a) revoke their + * refresh token (see + * https://firebase.google.com/docs/auth/admin/manage-sessions#revoke_refresh_tokens), + * and (b) ensure no other authentication methods are present on this + * account. + */ + providerToLink?: UserProvider; + + /** + * Unlinks this user from the specified providers. + */ + providersToUnlink?: string[]; } /** diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 76eee8c85e..bf9f7f134d 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -583,79 +583,177 @@ describe('admin.auth', () => { }); }); - it('updateUser() updates the user record with the given parameters', () => { - const updatedDisplayName = 'Updated User ' + newUserUid; - return admin.auth().updateUser(newUserUid, { - email: updatedEmail, - phoneNumber: updatedPhone, - emailVerified: true, - displayName: updatedDisplayName, - }) - .then((userRecord) => { - expect(userRecord.emailVerified).to.be.true; - expect(userRecord.displayName).to.equal(updatedDisplayName); - // Confirm expected email. - expect(userRecord.email).to.equal(updatedEmail); - // Confirm expected phone number. - expect(userRecord.phoneNumber).to.equal(updatedPhone); + describe('updateUser()', () => { + /** + * Creates a new user for testing purposes. The user's uid will be + * '$name_$tenRandomChars' and email will be + * '$name_$tenRandomChars@example.com'. + */ + // TODO(rsgowman): This function could usefully be employed throughout this file. + function createTestUser(name: string): Promise { + const tenRandomChars = generateRandomString(10); + return admin.auth().createUser({ + uid: name + '_' + tenRandomChars, + displayName: name, + email: name + '_' + tenRandomChars + '@example.com', }); - }); - - it('updateUser() creates, updates, and removes second factors', function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. } - const now = new Date(1476235905000).toUTCString(); - // Update user with enrolled second factors. - const enrolledFactors = [ - { - uid: 'mfaUid1', - phoneNumber: '+16505550001', - displayName: 'Work phone number', - factorId: 'phone', - enrollmentTime: now, - }, - { - uid: 'mfaUid2', - phoneNumber: '+16505550002', - displayName: 'Personal phone number', - factorId: 'phone', - enrollmentTime: now, - }, - ]; - return admin.auth().updateUser(newUserUid, { - multiFactor: { - enrolledFactors, - }, - }) - .then((userRecord) => { - // Confirm second factors added to user. - const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); - expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); - expect(actualUserRecord.multiFactor.enrolledFactors).to.deep.equal(enrolledFactors); - // Update list of second factors. - return admin.auth().updateUser(newUserUid, { - multiFactor: { - enrolledFactors: [enrolledFactors[0]], - }, - }); + let updateUser: admin.auth.UserRecord; + before(async () => { + updateUser = await createTestUser('UpdateUser'); + }); + + after(() => { + return safeDelete(updateUser.uid); + }); + + it('updates the user record with the given parameters', () => { + const updatedDisplayName = 'Updated User ' + updateUser.uid; + return admin.auth().updateUser(updateUser.uid, { + email: updatedEmail, + phoneNumber: updatedPhone, + emailVerified: true, + displayName: updatedDisplayName, }) - .then((userRecord) => { - expect(userRecord.multiFactor!.enrolledFactors.length).to.equal(1); - const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); - expect(actualUserRecord.multiFactor.enrolledFactors[0]).to.deep.equal(enrolledFactors[0]); - // Remove all second factors. - return admin.auth().updateUser(newUserUid, { - multiFactor: { - enrolledFactors: null, - }, + .then((userRecord) => { + expect(userRecord.emailVerified).to.be.true; + expect(userRecord.displayName).to.equal(updatedDisplayName); + // Confirm expected email. + expect(userRecord.email).to.equal(updatedEmail); + // Confirm expected phone number. + expect(userRecord.phoneNumber).to.equal(updatedPhone); }); + }); + + it('creates, updates, and removes second factors', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + + const now = new Date(1476235905000).toUTCString(); + // Update user with enrolled second factors. + const enrolledFactors = [ + { + uid: 'mfaUid1', + phoneNumber: '+16505550001', + displayName: 'Work phone number', + factorId: 'phone', + enrollmentTime: now, + }, + { + uid: 'mfaUid2', + phoneNumber: '+16505550002', + displayName: 'Personal phone number', + factorId: 'phone', + enrollmentTime: now, + }, + ]; + return admin.auth().updateUser(updateUser.uid, { + multiFactor: { + enrolledFactors, + }, }) - .then((userRecord) => { - // Confirm all second factors removed. - expect(userRecord.multiFactor).to.be.undefined; + .then((userRecord) => { + // Confirm second factors added to user. + const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); + expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); + expect(actualUserRecord.multiFactor.enrolledFactors).to.deep.equal(enrolledFactors); + // Update list of second factors. + return admin.auth().updateUser(updateUser.uid, { + multiFactor: { + enrolledFactors: [enrolledFactors[0]], + }, + }); + }) + .then((userRecord) => { + expect(userRecord.multiFactor!.enrolledFactors.length).to.equal(1); + const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); + expect(actualUserRecord.multiFactor.enrolledFactors[0]).to.deep.equal(enrolledFactors[0]); + // Remove all second factors. + return admin.auth().updateUser(updateUser.uid, { + multiFactor: { + enrolledFactors: null, + }, + }); + }) + .then((userRecord) => { + // Confirm all second factors removed. + expect(userRecord.multiFactor).to.be.undefined; + }); + }); + + it('can link/unlink with a federated provider', async () => { + const googleFederatedUid = 'google_uid_' + generateRandomString(10); + let userRecord = await admin.auth().updateUser(updateUser.uid, { + providerToLink: { + providerId: 'google.com', + uid: googleFederatedUid, + }, + }); + + let providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); + let providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); + expect(providerUids).to.deep.include(googleFederatedUid); + expect(providerIds).to.deep.include('google.com'); + + userRecord = await admin.auth().updateUser(updateUser.uid, { + providersToUnlink: ['google.com'], + }); + + providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); + providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); + expect(providerUids).to.not.deep.include(googleFederatedUid); + expect(providerIds).to.not.deep.include('google.com'); + }); + + it('can unlink multiple providers at once, incl a non-federated provider', async () => { + await deletePhoneNumberUser('+15555550001'); + + const googleFederatedUid = 'google_uid_' + generateRandomString(10); + const facebookFederatedUid = 'facebook_uid_' + generateRandomString(10); + + let userRecord = await admin.auth().updateUser(updateUser.uid, { + phoneNumber: '+15555550001', + providerToLink: { + providerId: 'google.com', + uid: googleFederatedUid, + }, + }); + userRecord = await admin.auth().updateUser(updateUser.uid, { + providerToLink: { + providerId: 'facebook.com', + uid: facebookFederatedUid, + }, + }); + + let providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); + let providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); + expect(providerUids).to.deep.include.members([googleFederatedUid, facebookFederatedUid, '+15555550001']); + expect(providerIds).to.deep.include.members(['google.com', 'facebook.com', 'phone']); + + userRecord = await admin.auth().updateUser(updateUser.uid, { + providersToUnlink: ['google.com', 'facebook.com', 'phone'], }); + + providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); + providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); + expect(providerUids).to.not.deep.include.members([googleFederatedUid, facebookFederatedUid, '+15555550001']); + expect(providerIds).to.not.deep.include.members(['google.com', 'facebook.com', 'phone']); + }); + + it('noops successfully when given an empty providersToUnlink list', async () => { + const userRecord = await createTestUser('NoopWithEmptyProvidersToDeleteUser'); + try { + const updatedUserRecord = await admin.auth().updateUser(userRecord.uid, { + providersToUnlink: [], + }); + + expect(updatedUserRecord).to.deep.equal(userRecord); + } finally { + safeDelete(userRecord.uid); + } + }); }); it('getUser() fails when called with a non-existing UID', () => { @@ -2208,8 +2306,8 @@ function testImportAndSignInUser( /** * Helper function that deletes the user with the specified phone number * if it exists. - * @param {string} phoneNumber The phone number of the user to delete. - * @return {Promise} A promise that resolves when the user is deleted + * @param phoneNumber The phone number of the user to delete. + * @return A promise that resolves when the user is deleted * or is found not to exist. */ function deletePhoneNumberUser(phoneNumber: string): Promise { diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 4d134e2949..71957bc4b4 100644 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -241,6 +241,8 @@ function getSAMLConfigServerResponse(providerId: string): SAMLConfigServerRespon } +const INVALID_PROVIDER_IDS = [ + undefined, null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; const TENANT_ID = 'tenantId'; const AUTH_CONFIGS: AuthTest[] = [ { @@ -1628,6 +1630,10 @@ AUTH_CONFIGS.forEach((testConfig) => { emailVerified: expectedUserRecord.emailVerified, password: 'password', phoneNumber: expectedUserRecord.phoneNumber, + providerToLink: { + providerId: 'google.com', + uid: 'google_uid', + }, }; // Stubs used to simulate underlying api calls. let stubs: sinon.SinonStub[] = []; @@ -1671,8 +1677,193 @@ AUTH_CONFIGS.forEach((testConfig) => { }) .catch((error) => { expect(error).to.have.property('code', 'auth/argument-error'); - expect(validator.isNonNullObject).to.have.been.calledOnce.and.calledWith(null); + expect(validator.isNonNullObject).to.have.been.calledWith(null); + }); + }); + + const invalidUpdateRequests: UpdateRequest[] = [ + { providerToLink: { uid: 'google_uid' } }, + { providerToLink: { providerId: 'google.com' } }, + { providerToLink: { providerId: 'google.com', uid: '' } }, + { providerToLink: { providerId: '', uid: 'google_uid' } }, + ]; + invalidUpdateRequests.forEach((invalidUpdateRequest) => { + it('should be rejected given an UpdateRequest with an invalid providerToLink parameter', () => { + expect(() => { + auth.updateUser(uid, invalidUpdateRequest); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); + }); + + it('should rename providerToLink property to linkProviderUserInfo', async () => { + const invokeRequestHandlerStub = sinon.stub(testConfig.RequestHandler.prototype, 'invokeRequestHandler') + .resolves({ + localId: uid, + }); + + // Stub getAccountInfoByUid to return a valid result (unchecked; we + // just need it to be valid so as to not crash.) + const getUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .resolves(expectedGetAccountInfoResult); + + stubs.push(invokeRequestHandlerStub); + stubs.push(getUserStub); + + await auth.updateUser(uid, { + providerToLink: { + providerId: 'google.com', + uid: 'google_uid', + }, + }); + + expect(invokeRequestHandlerStub).to.have.been.calledOnce.and.calledWith( + sinon.match.any, sinon.match.any, { + localId: uid, + linkProviderUserInfo: { + providerId: 'google.com', + rawId: 'google_uid', + }, + }); + }); + + INVALID_PROVIDER_IDS.forEach((invalidProviderId) => { + it('should be rejected given a deleteProvider list with an invalid provider ID ' + + JSON.stringify(invalidProviderId), () => { + expect(() => { + auth.updateUser(uid, { + providersToUnlink: [ invalidProviderId as any ], + }); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); + }); + + it('should merge deletion of phone provider with the providersToUnlink list', async () => { + const invokeRequestHandlerStub = sinon.stub(testConfig.RequestHandler.prototype, 'invokeRequestHandler') + .resolves({ + localId: uid, + }); + + // Stub getAccountInfoByUid to return a valid result (unchecked; we + // just need it to be valid so as to not crash.) + const getUserStub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .resolves(expectedGetAccountInfoResult); + + stubs.push(invokeRequestHandlerStub); + stubs.push(getUserStub); + + await auth.updateUser(uid, { + phoneNumber: null, + providersToUnlink: [ 'google.com' ], + }); + + expect(invokeRequestHandlerStub).to.have.been.calledOnce.and.calledWith( + sinon.match.any, sinon.match.any, { + localId: uid, + deleteProvider: [ 'phone', 'google.com' ], + }); + }); + + describe('non-federated providers', () => { + let invokeRequestHandlerStub: sinon.SinonStub; + let getAccountInfoByUidStub: sinon.SinonStub; + beforeEach(() => { + invokeRequestHandlerStub = sinon.stub(testConfig.RequestHandler.prototype, 'invokeRequestHandler') + .resolves({ + // nothing here is checked; we just need enough to not crash. + users: [{ + localId: 1, + }], + }); + + getAccountInfoByUidStub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByUid') + .resolves({ + // nothing here is checked; we just need enough to not crash. + users: [{ + localId: 1, + }], + }); + }); + afterEach(() => { + invokeRequestHandlerStub.restore(); + getAccountInfoByUidStub.restore(); + }); + + it('specifying both email and providerId=email should be rejected', () => { + expect(() => { + auth.updateUser(uid, { + email: 'user@example.com', + providerToLink: { + providerId: 'email', + uid: 'user@example.com', + }, + }); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); + + it('specifying both phoneNumber and providerId=phone should be rejected', () => { + expect(() => { + auth.updateUser(uid, { + phoneNumber: '+15555550001', + providerToLink: { + providerId: 'phone', + uid: '+15555550001', + }, + }); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); + + it('email linking should use email field', async () => { + await auth.updateUser(uid, { + providerToLink: { + providerId: 'email', + uid: 'user@example.com', + }, + }); + expect(invokeRequestHandlerStub).to.have.been.calledOnce.and.calledWith( + sinon.match.any, sinon.match.any, { + localId: uid, + email: 'user@example.com', + }); + }); + + it('phone linking should use phoneNumber field', async () => { + await auth.updateUser(uid, { + providerToLink: { + providerId: 'phone', + uid: '+15555550001', + }, }); + expect(invokeRequestHandlerStub).to.have.been.calledOnce.and.calledWith( + sinon.match.any, sinon.match.any, { + localId: uid, + phoneNumber: '+15555550001', + }); + }); + + it('specifying both phoneNumber=null and providersToUnlink=phone should be rejected', () => { + expect(() => { + auth.updateUser(uid, { + phoneNumber: null, + providersToUnlink: ['phone'], + }); + }).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error'); + }); + + it('doesnt mutate the properties parameter', async () => { + const properties: UpdateRequest = { + providerToLink: { + providerId: 'email', + uid: 'user@example.com', + }, + }; + await auth.updateUser(uid, properties); + expect(properties).to.deep.equal({ + providerToLink: { + providerId: 'email', + uid: 'user@example.com', + }, + }); + }); }); it('should be rejected given an app which returns null access tokens', () => { @@ -2487,9 +2678,7 @@ AUTH_CONFIGS.forEach((testConfig) => { .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-provider-id'); }); - const invalidProviderIds = [ - undefined, null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop]; - invalidProviderIds.forEach((invalidProviderId) => { + INVALID_PROVIDER_IDS.forEach((invalidProviderId) => { it(`should be rejected given an invalid provider ID "${JSON.stringify(invalidProviderId)}"`, () => { return (auth as Auth).getProviderConfig(invalidProviderId as any) .then(() => { @@ -2860,15 +3049,16 @@ AUTH_CONFIGS.forEach((testConfig) => { .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-provider-id'); }); - it('should be rejected given an invalid provider ID', () => { - const invalidProviderId = ''; - return (auth as Auth).deleteProviderConfig(invalidProviderId) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-provider-id'); - }); + INVALID_PROVIDER_IDS.forEach((invalidProviderId) => { + it(`should be rejected given an invalid provider ID "${JSON.stringify(invalidProviderId)}"`, () => { + return (auth as Auth).deleteProviderConfig(invalidProviderId as any) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-provider-id'); + }); + }); }); it('should be rejected given an app which returns null access tokens', () => { @@ -2979,15 +3169,16 @@ AUTH_CONFIGS.forEach((testConfig) => { .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-provider-id'); }); - it('should be rejected given an invalid provider ID', () => { - const invalidProviderId = ''; - return (auth as Auth).updateProviderConfig(invalidProviderId, oidcConfigOptions) - .then(() => { - throw new Error('Unexpected success'); - }) - .catch((error) => { - expect(error).to.have.property('code', 'auth/invalid-provider-id'); - }); + INVALID_PROVIDER_IDS.forEach((invalidProviderId) => { + it(`should be rejected given an invalid provider ID "${JSON.stringify(invalidProviderId)}"`, () => { + return (auth as Auth).updateProviderConfig(invalidProviderId as any, oidcConfigOptions) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-provider-id'); + }); + }); }); it('should be rejected given no options', () => {