From 1745461652ad027db4e0768ccee1ec57302fac1c Mon Sep 17 00:00:00 2001 From: nhienlam Date: Thu, 26 Sep 2024 16:51:31 -0700 Subject: [PATCH 1/2] Add integration test for rCE ENFORCE --- packages/auth/README.md | 33 +++- packages/auth/karma.conf.js | 3 +- .../flows/recaptcha_enterprise.test.ts | 186 ++++++++++++++++++ 3 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 packages/auth/test/integration/flows/recaptcha_enterprise.test.ts diff --git a/packages/auth/README.md b/packages/auth/README.md index 979a35182d2..74e52968aa4 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -54,9 +54,12 @@ firebase emulators:exec --project foo-bar --only auth "yarn test:integration:loc ### Integration testing with the production backend -Currently, MFA TOTP and password policy tests only run against the production backend (since they are not supported on the emulator yet). +Currently, MFA TOTP, password policy, and reCAPTCHA Enterprise phone verification tests only run +against the production backend (since they are not supported on the emulator yet). Running against the backend also makes it a more reliable end-to-end test. +#### TOTP + The TOTP tests require the following email/password combination to exist in the project, so if you are running this test against your test project, please create this user: 'totpuser-donotdelete@test.com', 'password' @@ -71,6 +74,8 @@ curl -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Conten }' ``` +#### Password policy + The password policy tests require a tenant configured with a password policy that requires all options to exist in the project. If you are running this test against your test project, please create the tenant and configure the policy with the following curl command: @@ -98,6 +103,32 @@ curl -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Conten Replace the tenant ID `passpol-tenant-d7hha` in [test/integration/flows/password_policy.test.ts](https://github.com/firebase/firebase-js-sdk/blob/main/packages/auth/test/integration/flows/password_policy.test.ts) with the ID for the newly created tenant. The tenant ID can be found at the end of the `name` property in the response and is in the format `passpol-tenant-xxxxx`. +#### reCAPTCHA Enterprise phone verification + +The reCAPTCHA Enterprise phone verification tests require reCAPTCHA Enterprise to be enabled and +the following fictional phone number to be configured and in the project. + +If you are running this +test against your project, please [add this test phone number](https://firebase.google.com/docs/auth/web/phone-auth#create-fictional-phone-numbers-and-verification-codes): + +'+1 555-555-1000', SMS code: '123456' + +Follow [this guide](https://cloud.google.com/identity-platform/docs/recaptcha-enterprise) to enable reCAPTCHA +Enterprise, then use the following curl command to set reCAPTCHA Enterprise to ENFORCE for phone provider: + +``` +curl -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Content-Type: application/json" -H "X-Goog-User-Project: $ +{PROJECT_ID}" -X POST https://identitytoolkit.googleapis.com/v2/projects/${PROJECT_ID}/config?updateMask=recaptchaConfig.phoneEnforcementState,recaptchaConfig.useSmsBotScore,recaptchaConfig.useSmsTollFraudProtection -d ' +{ + "name": "projects/{PROJECT_ID}", + "recaptchaConfig": { + "phoneEnforcementState": "ENFORCE", + "useSmsBotScore": "true", + "useSmsTollFraudProtection": "true", + }, +}' +``` + ### Selenium Webdriver tests These tests assume that you have both Firefox and Chrome installed on your diff --git a/packages/auth/karma.conf.js b/packages/auth/karma.conf.js index 1d28c329f55..9ccb27ce6c4 100644 --- a/packages/auth/karma.conf.js +++ b/packages/auth/karma.conf.js @@ -51,7 +51,8 @@ function getTestFiles(argv) { if (argv.prodbackend) { return [ 'test/integration/flows/totp.test.ts', - 'test/integration/flows/password_policy.test.ts' + 'test/integration/flows/password_policy.test.ts', + 'test/integration/flows/recaptcha_enterprise.test.ts' ]; } return argv.local diff --git a/packages/auth/test/integration/flows/recaptcha_enterprise.test.ts b/packages/auth/test/integration/flows/recaptcha_enterprise.test.ts new file mode 100644 index 00000000000..9293c3e524f --- /dev/null +++ b/packages/auth/test/integration/flows/recaptcha_enterprise.test.ts @@ -0,0 +1,186 @@ +/** + * @license + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import { + linkWithPhoneNumber, + PhoneAuthProvider, + reauthenticateWithPhoneNumber, + signInAnonymously, + signInWithPhoneNumber, + unlink, + updatePhoneNumber, + Auth, + OperationType, + ProviderId, + } from '@firebase/auth'; +import { + cleanUpTestInstance, + getTestInstance, +} from '../../helpers/integration/helpers'; + +import { getEmulatorUrl } from '../../helpers/integration/settings'; + +use(chaiAsPromised); +use(sinonChai); + +let auth: Auth; +let emulatorUrl: string | null; + +// NOTE: These happy test cases don't use a real phone number. In order to run these tests +// you must allowlist the following phone number as "testing" numbers in the Auth console. +// https://console.firebase.google.com/u/0/project/_/authentication/providers +// • +1 (555) 555-1000, SMS code 123456 + +const FICTIONAL_PHONE = { + phoneNumber: '+15555551000', + code: '123456' + }; + +// This phone number is not allowlisted. It is used in error test cases to catch errors, as +// using fictional phone number always receives success response from the server. +// Note: Don't use this for happy cases because we want to avoid sending actual SMS message. +const NONFICTIONAL_PHONE = { + phoneNumber: '+15555553000', + }; + +// These tests are written when reCAPTCHA Enterprise is set to ENFORCE. In order to run these tests +// you must enable reCAPTCHA Enterprise in Cloud Console and set enforcement state for PHONE_PROVIDER +// to ENFORCE. +// The CI project has reCAPTCHA bot-score and toll fraud protection enabled. +describe('Integration test: phone auth with reCAPTCHA Enterprise ENFORCE mode', () => { + beforeEach(() => { + emulatorUrl = getEmulatorUrl(); + if(!emulatorUrl) { + auth = getTestInstance(); + // Sets to false to generate the real reCAPTCHA Enterprise token + auth.settings.appVerificationDisabledForTesting = false; + } + }); + + afterEach(async () => { + if (!emulatorUrl) { + await cleanUpTestInstance(auth); + } + }); + + it('allows user to sign in with phone number', async function () { + if (emulatorUrl) { + this.skip(); + } + + // This generates real recaptcha token and use it for verification + const confirmationResult = await signInWithPhoneNumber(auth, FICTIONAL_PHONE.phoneNumber); + expect(confirmationResult.verificationId).not.to.be.null; + + const userCred = await confirmationResult.confirm('123456'); + expect(auth.currentUser).to.eq(userCred.user); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + + const user = userCred.user; + expect(user.isAnonymous).to.be.false; + expect(user.uid).to.be.a('string'); + expect(user.phoneNumber).to.eq(FICTIONAL_PHONE.phoneNumber); + }); + + it('throws error if recaptcha token is invalid', async function () { + if (emulatorUrl) { + this.skip(); + } + // Simulates a fake token by setting this to true + auth.settings.appVerificationDisabledForTesting = true; + + // Use unallowlisted phone number to trigger real reCAPTCHA Enterprise verification + // Since it will throw an error, no SMS will be sent. + await expect(signInWithPhoneNumber(auth, NONFICTIONAL_PHONE.phoneNumber)).to.be.rejectedWith( + 'auth/invalid-recaptcha-token' + ); + }); + + it('anonymous users can upgrade using phone number', async function () { + if (emulatorUrl) { + this.skip(); + } + const { user } = await signInAnonymously(auth); + const { uid: anonId } = user; + + const provider = new PhoneAuthProvider(auth); + const verificationId = await provider.verifyPhoneNumber( + FICTIONAL_PHONE.phoneNumber, + ); + + await updatePhoneNumber( + user, + PhoneAuthProvider.credential(verificationId, FICTIONAL_PHONE.code) + ); + expect(user.phoneNumber).to.eq(FICTIONAL_PHONE.phoneNumber); + + await auth.signOut(); + + const cr = await signInWithPhoneNumber(auth, FICTIONAL_PHONE.phoneNumber); + const { user: secondSignIn } = await cr.confirm(FICTIONAL_PHONE.code); + + expect(secondSignIn.uid).to.eq(anonId); + expect(secondSignIn.isAnonymous).to.be.false; + expect(secondSignIn.providerData[0].phoneNumber).to.eq(FICTIONAL_PHONE.phoneNumber); + expect(secondSignIn.providerData[0].providerId).to.eq('phone'); + }); + + it('anonymous users can link (and unlink) phone number', async function () { + if (emulatorUrl) { + this.skip(); + } + const { user } = await signInAnonymously(auth); + const { uid: anonId } = user; + + const confirmationResult = await linkWithPhoneNumber(user, FICTIONAL_PHONE.phoneNumber); + const linkResult = await confirmationResult.confirm(FICTIONAL_PHONE.code); + expect(linkResult.operationType).to.eq(OperationType.LINK); + expect(linkResult.user.uid).to.eq(user.uid); + expect(linkResult.user.phoneNumber).to.eq(FICTIONAL_PHONE.phoneNumber); + + await unlink(user, ProviderId.PHONE); + expect(auth.currentUser!.uid).to.eq(anonId); + // Is anonymous stays false even after unlinking + expect(auth.currentUser!.isAnonymous).to.be.false; + expect(auth.currentUser!.phoneNumber).to.be.null; + }); + + it('allows the user to reauthenticate with phone number', async function () { + if (emulatorUrl) { + this.skip(); + } + // Create a phone user first + let confirmationResult = await signInWithPhoneNumber(auth, FICTIONAL_PHONE.phoneNumber); + const { user } = await confirmationResult.confirm(FICTIONAL_PHONE.code); + const oldToken = await user.getIdToken(); + + // Wait a bit to ensure the sign in time is different in the token + await new Promise((resolve): void => { + setTimeout(resolve, 1500); + }); + + confirmationResult = await reauthenticateWithPhoneNumber( + user, + FICTIONAL_PHONE.phoneNumber); + await confirmationResult.confirm(FICTIONAL_PHONE.code); + + expect(await user.getIdToken()).not.to.eq(oldToken); + }); +}); From ba52293a211bf4f62d86af078b7ea20118c474f0 Mon Sep 17 00:00:00 2001 From: nhienlam Date: Thu, 26 Sep 2024 18:49:50 -0700 Subject: [PATCH 2/2] format --- .../flows/recaptcha_enterprise.test.ts | 70 +++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/packages/auth/test/integration/flows/recaptcha_enterprise.test.ts b/packages/auth/test/integration/flows/recaptcha_enterprise.test.ts index 9293c3e524f..394f9a9e9a5 100644 --- a/packages/auth/test/integration/flows/recaptcha_enterprise.test.ts +++ b/packages/auth/test/integration/flows/recaptcha_enterprise.test.ts @@ -19,20 +19,20 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; import { - linkWithPhoneNumber, - PhoneAuthProvider, - reauthenticateWithPhoneNumber, - signInAnonymously, - signInWithPhoneNumber, - unlink, - updatePhoneNumber, - Auth, - OperationType, - ProviderId, - } from '@firebase/auth'; + linkWithPhoneNumber, + PhoneAuthProvider, + reauthenticateWithPhoneNumber, + signInAnonymously, + signInWithPhoneNumber, + unlink, + updatePhoneNumber, + Auth, + OperationType, + ProviderId +} from '@firebase/auth'; import { cleanUpTestInstance, - getTestInstance, + getTestInstance } from '../../helpers/integration/helpers'; import { getEmulatorUrl } from '../../helpers/integration/settings'; @@ -49,16 +49,16 @@ let emulatorUrl: string | null; // • +1 (555) 555-1000, SMS code 123456 const FICTIONAL_PHONE = { - phoneNumber: '+15555551000', - code: '123456' - }; - + phoneNumber: '+15555551000', + code: '123456' +}; + // This phone number is not allowlisted. It is used in error test cases to catch errors, as // using fictional phone number always receives success response from the server. // Note: Don't use this for happy cases because we want to avoid sending actual SMS message. const NONFICTIONAL_PHONE = { - phoneNumber: '+15555553000', - }; + phoneNumber: '+15555553000' +}; // These tests are written when reCAPTCHA Enterprise is set to ENFORCE. In order to run these tests // you must enable reCAPTCHA Enterprise in Cloud Console and set enforcement state for PHONE_PROVIDER @@ -67,7 +67,7 @@ const NONFICTIONAL_PHONE = { describe('Integration test: phone auth with reCAPTCHA Enterprise ENFORCE mode', () => { beforeEach(() => { emulatorUrl = getEmulatorUrl(); - if(!emulatorUrl) { + if (!emulatorUrl) { auth = getTestInstance(); // Sets to false to generate the real reCAPTCHA Enterprise token auth.settings.appVerificationDisabledForTesting = false; @@ -85,8 +85,11 @@ describe('Integration test: phone auth with reCAPTCHA Enterprise ENFORCE mode', this.skip(); } - // This generates real recaptcha token and use it for verification - const confirmationResult = await signInWithPhoneNumber(auth, FICTIONAL_PHONE.phoneNumber); + // This generates real recaptcha token and use it for verification + const confirmationResult = await signInWithPhoneNumber( + auth, + FICTIONAL_PHONE.phoneNumber + ); expect(confirmationResult.verificationId).not.to.be.null; const userCred = await confirmationResult.confirm('123456'); @@ -108,9 +111,9 @@ describe('Integration test: phone auth with reCAPTCHA Enterprise ENFORCE mode', // Use unallowlisted phone number to trigger real reCAPTCHA Enterprise verification // Since it will throw an error, no SMS will be sent. - await expect(signInWithPhoneNumber(auth, NONFICTIONAL_PHONE.phoneNumber)).to.be.rejectedWith( - 'auth/invalid-recaptcha-token' - ); + await expect( + signInWithPhoneNumber(auth, NONFICTIONAL_PHONE.phoneNumber) + ).to.be.rejectedWith('auth/invalid-recaptcha-token'); }); it('anonymous users can upgrade using phone number', async function () { @@ -122,7 +125,7 @@ describe('Integration test: phone auth with reCAPTCHA Enterprise ENFORCE mode', const provider = new PhoneAuthProvider(auth); const verificationId = await provider.verifyPhoneNumber( - FICTIONAL_PHONE.phoneNumber, + FICTIONAL_PHONE.phoneNumber ); await updatePhoneNumber( @@ -138,7 +141,9 @@ describe('Integration test: phone auth with reCAPTCHA Enterprise ENFORCE mode', expect(secondSignIn.uid).to.eq(anonId); expect(secondSignIn.isAnonymous).to.be.false; - expect(secondSignIn.providerData[0].phoneNumber).to.eq(FICTIONAL_PHONE.phoneNumber); + expect(secondSignIn.providerData[0].phoneNumber).to.eq( + FICTIONAL_PHONE.phoneNumber + ); expect(secondSignIn.providerData[0].providerId).to.eq('phone'); }); @@ -149,7 +154,10 @@ describe('Integration test: phone auth with reCAPTCHA Enterprise ENFORCE mode', const { user } = await signInAnonymously(auth); const { uid: anonId } = user; - const confirmationResult = await linkWithPhoneNumber(user, FICTIONAL_PHONE.phoneNumber); + const confirmationResult = await linkWithPhoneNumber( + user, + FICTIONAL_PHONE.phoneNumber + ); const linkResult = await confirmationResult.confirm(FICTIONAL_PHONE.code); expect(linkResult.operationType).to.eq(OperationType.LINK); expect(linkResult.user.uid).to.eq(user.uid); @@ -167,7 +175,10 @@ describe('Integration test: phone auth with reCAPTCHA Enterprise ENFORCE mode', this.skip(); } // Create a phone user first - let confirmationResult = await signInWithPhoneNumber(auth, FICTIONAL_PHONE.phoneNumber); + let confirmationResult = await signInWithPhoneNumber( + auth, + FICTIONAL_PHONE.phoneNumber + ); const { user } = await confirmationResult.confirm(FICTIONAL_PHONE.code); const oldToken = await user.getIdToken(); @@ -178,7 +189,8 @@ describe('Integration test: phone auth with reCAPTCHA Enterprise ENFORCE mode', confirmationResult = await reauthenticateWithPhoneNumber( user, - FICTIONAL_PHONE.phoneNumber); + FICTIONAL_PHONE.phoneNumber + ); await confirmationResult.confirm(FICTIONAL_PHONE.code); expect(await user.getIdToken()).not.to.eq(oldToken);