diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 7b113b3156..0b2b97d310 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -32,7 +32,7 @@ import { AuthProviderConfig, CreateTenantRequest, DeleteUsersResult, PhoneMultiFactorInfo, TenantAwareAuth, UpdatePhoneMultiFactorInfoRequest, UpdateTenantRequest, UserImportOptions, UserImportRecord, UserRecord, getAuth, UpdateProjectConfigRequest, UserMetadata, MultiFactorConfig, - PasswordPolicyConfig, SmsRegionConfig, + PasswordPolicyConfig, SmsRegionConfig, ActionCodeSettings, } from '../../lib/auth/index'; import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; @@ -75,10 +75,25 @@ const mockUserData = { photoURL: 'http://www.example.com/' + newUserUid + '/photo.png', disabled: false, }; -const actionCodeSettings = { +const actionCodeSettings : ActionCodeSettings = { url: 'http://localhost/?a=1&b=2#c=3', handleCodeInApp: false, }; +const actionCodeSettingsWithCustomDomain: ActionCodeSettings = { + url: 'http://localhost/?a=1&b=2#c=3', + handleCodeInApp: true, + linkDomain: 'kobayashimaru.testdomaindonotuse.com', + iOS: { + bundleId: 'testBundleId', + }, +} +const actionCodeSettingsForFdlLinks: ActionCodeSettings = { + url: 'http://localhost/?a=1&b=2#c=3', + handleCodeInApp: true, + iOS: { + bundleId: 'testBundleId', + }, +} let deleteQueue = Promise.resolve(); interface UserImportTest { @@ -1105,6 +1120,13 @@ describe('admin.auth', () => { // Create the test user before running this suite of tests. before(() => { + // Update project config to have HOSTING_DOMAIN as mobileLinksConfig after each test + const updateMobileLinksRequest: UpdateProjectConfigRequest = { + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', + }, + }; + getAuth().projectConfigManager().updateProjectConfig(updateMobileLinksRequest); return getAuth().createUser(userData); }); @@ -1199,6 +1221,162 @@ describe('admin.auth', () => { expect(result.user!.emailVerified).to.be.true; }); }); + + it('generateSignInWithEmailLink() with custom linkDomain should return error in case of invalid hosting domain', + function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + const actionCodeSettingsWithInvalidLinkDomain = deepCopy(actionCodeSettings); + actionCodeSettingsWithInvalidLinkDomain.linkDomain = 'invaliddomain.firebaseapp.com'; + return getAuth().generateSignInWithEmailLink(email, actionCodeSettingsWithInvalidLinkDomain) + .catch((error) => { + expect(error.code).to.equal('auth/invalid-hosting-link-domain'); + }); + }); + + it('generatePasswordResetLink() should return a password reset link with custom domain', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + // Ensure old password set on created user. + return getAuth().updateUser(uid, { password: 'password' }) + .then(() => { + return getAuth().generatePasswordResetLink(email, actionCodeSettingsWithCustomDomain); + }) + .then((link) => { + const code = getActionCode(link); + expect(getContinueUrlForInAppRequest(link)).equal(actionCodeSettings.url); + expect(getHostName(link)).equal(actionCodeSettingsWithCustomDomain.linkDomain); + return clientAuth().confirmPasswordReset(code, newPassword); + }) + .then(() => { + return clientAuth().signInWithEmailAndPassword(email, newPassword); + }) + .then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(email); + // Password reset also verifies the user's email. + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateEmailVerificationLink() should return a verification link with custom domain', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + // Ensure the user's email is unverified. + return getAuth().updateUser(uid, { password: '123456', emailVerified: false }) + .then((userRecord) => { + expect(userRecord.emailVerified).to.be.false; + return getAuth().generateEmailVerificationLink(email, actionCodeSettingsWithCustomDomain); + }) + .then((link) => { + const code = getActionCode(link); + expect(getContinueUrlForInAppRequest(link)).equal(actionCodeSettings.url); + expect(getHostName(link)).equal(actionCodeSettingsWithCustomDomain.linkDomain); + return clientAuth().applyActionCode(code); + }) + .then(() => { + return clientAuth().signInWithEmailAndPassword(email, userData.password); + }) + .then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(email); + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateSignInWithEmailLink() should return a sign-in link with custom domain', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + return getAuth().generateSignInWithEmailLink(email, actionCodeSettingsWithCustomDomain) + .then((link) => { + expect(getContinueUrlForInAppRequest(link)).equal(actionCodeSettingsWithCustomDomain.url); + expect(getHostName(link)).equal(actionCodeSettingsWithCustomDomain.linkDomain); + return clientAuth().signInWithEmailLink(email, link); + }) + .then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(email); + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateVerifyAndChangeEmailLink() should return a verification link with custom domain', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + // Ensure the user's email is verified. + return getAuth().updateUser(uid, { password: '123456', emailVerified: true }) + .then((userRecord) => { + expect(userRecord.emailVerified).to.be.true; + return getAuth().generateVerifyAndChangeEmailLink(email, newEmail, actionCodeSettingsWithCustomDomain); + }) + .then((link) => { + const code = getActionCode(link); + expect(getContinueUrlForInAppRequest(link)).equal(actionCodeSettings.url); + expect(getHostName(link)).equal(actionCodeSettingsWithCustomDomain.linkDomain); + return clientAuth().applyActionCode(code); + }) + .then(() => { + return clientAuth().signInWithEmailAndPassword(newEmail, 'password'); + }) + .then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(newEmail); + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateSignInWithEmailLink() should return a FDL sign-in' + + 'link with mobileLinksConfig set to FIREBASE_DYNAMIC_LINK_DOMAIN', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + + const updateMobileLinksRequest: UpdateProjectConfigRequest = { + mobileLinksConfig: { + domain: 'FIREBASE_DYNAMIC_LINK_DOMAIN', + } + }; + return getAuth().projectConfigManager().updateProjectConfig(updateMobileLinksRequest) + .then((projectConfig) => { + expect(projectConfig?.mobileLinksConfig?.domain).equal('FIREBASE_DYNAMIC_LINK_DOMAIN'); + return getAuth().generateSignInWithEmailLink(email, actionCodeSettingsForFdlLinks); + }).then((link) => { + expectFDLLink(link); + return clientAuth().signInWithEmailLink(email, link); + }).then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(email); + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateSignInWithEmailLink() should return a FDL sign-in link with empty mobileLinksConfig', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + + const updateMobileLinksRequest: UpdateProjectConfigRequest = { + mobileLinksConfig: { + } + }; + return getAuth().projectConfigManager().updateProjectConfig(updateMobileLinksRequest) + .then((projectConfig) => { + expect(projectConfig.mobileLinksConfig).is.empty; + return getAuth().generateSignInWithEmailLink(email, actionCodeSettingsForFdlLinks); + }).then((link) => { + expectFDLLink(link); + return clientAuth().signInWithEmailLink(email, link); + }).then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(email); + expect(result.user!.emailVerified).to.be.true; + }); + }); }); describe('Project config management operations', () => { @@ -1285,6 +1463,9 @@ describe('admin.auth', () => { }, emailPrivacyConfig: { enableImprovedEmailPrivacy: true, + }, + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', } }; const projectConfigOption2: UpdateProjectConfigRequest = { @@ -1318,6 +1499,9 @@ describe('admin.auth', () => { emailPrivacyConfig: { enableImprovedEmailPrivacy: true, }, + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', + }, }; const expectedProjectConfig2: any = { smsRegionConfig: smsRegionAllowlistOnlyConfig, @@ -1333,6 +1517,9 @@ describe('admin.auth', () => { ], }, emailPrivacyConfig: {}, + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', + }, }; const expectedProjectConfigSmsEnabledTotpDisabled: any = { smsRegionConfig: smsRegionAllowlistOnlyConfig, @@ -1348,6 +1535,9 @@ describe('admin.auth', () => { ], }, emailPrivacyConfig: {}, + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', + }, }; it('updateProjectConfig() should resolve with the updated project config', () => { @@ -3194,6 +3384,53 @@ function getContinueUrl(link: string): string { return continueUrl!; } +/** + * Returns the host name corresponding to the link. + * + * @param link The link to parse for hostname + * @returns Hostname in the link + */ +function getHostName(link: string): string { + const parsedUrl = new url.URL(link); + return parsedUrl.hostname; +} + +/** + * Returns continue URL for handling in app requests. + * URL will be of the form, http://abc/__/auth/link?link= + * Coninue URL will be part of action link url + * + * @param link The link to parse for continue url + * @returns + */ +function getContinueUrlForInAppRequest(link: string): string { + // Extract action url from link param + const parsedUrl = new url.URL(link); + const linkParam = parsedUrl.searchParams.get('link') ?? ''; + expect(linkParam).is.not.empty; + + // Extract continueUrl param from action url + const actionUrl = new url.URL(linkParam); + const continueUrl = actionUrl.searchParams.get('continueUrl'); + expect(continueUrl).to.exist; + return continueUrl!; +} + +/** + * Verify if the generated link is generated by FDL + * We leverage the params created by FDL to test whether a given link is FDL + * + * @param link Link to check whether it is FDL + */ +function expectFDLLink(link: string): void { + const parsedUrl = new url.URL(link); + // For ios, FDL creates a fallback url with param ifl + // We leverage that to test whether a given link is FDL link + // Note: This param does not exist when the link is generated for HOSTING_DOMAIN + const iflParam = parsedUrl.searchParams.get('ifl'); + expect(iflParam).is.not.null; +} + /** * Returns the tenant ID corresponding to the link. *