From 56f904c7d1e5ab39d4b470c059da2a14566e03df Mon Sep 17 00:00:00 2001 From: "Nhien (Ricky) Lam" <62775270+NhienLam@users.noreply.github.com> Date: Wed, 10 Apr 2024 09:15:17 -0700 Subject: [PATCH] Implement reCAPTCHA Enterprise flow for phone provider (#7814) * Update injectRecaptchaFields to inject recaptcha enterprise fields into phone API requests (#7786) * Update injectRecaptchaFields to inject recaptcha fields into phone API requests * Fix lint * Rename captchaResp and fakeToken params * Format * Implement reCAPTCHA Enterprise flow for phone provider * Cleanup tests * Make recaptchaEnterpriseVerifier.verify return a mock when appVerificationDisabledForTesting is true * Lint fix * yarn docgen devsite * Mark appVerifier param in Phone Auth APIs as required * Update API reports * Change RecaptchaProvider to RecaptchaAuthProvider * Fix reference docs * Add more unit tests --------- Co-authored-by: NhienLam --- docs-devsite/auth.phoneauthprovider.md | 2 +- packages/auth/src/api/index.ts | 2 +- .../auth/src/core/credentials/email.test.ts | 1 + packages/auth/src/core/credentials/email.ts | 12 +- .../strategies/email_and_password.test.ts | 1 + .../src/core/strategies/email_and_password.ts | 12 +- .../src/core/strategies/email_link.test.ts | 1 + .../auth/src/core/strategies/email_link.ts | 9 +- .../platform_browser/providers/phone.test.ts | 110 +++- .../src/platform_browser/providers/phone.ts | 2 +- .../recaptcha/recaptcha.test.ts | 32 +- .../platform_browser/recaptcha/recaptcha.ts | 6 +- .../recaptcha_enterprise_verifier.test.ts | 182 +++++- .../recaptcha_enterprise_verifier.ts | 137 ++++- .../platform_browser/strategies/phone.test.ts | 581 ++++++++++++++++-- .../src/platform_browser/strategies/phone.ts | 272 +++++++- 16 files changed, 1186 insertions(+), 176 deletions(-) diff --git a/docs-devsite/auth.phoneauthprovider.md b/docs-devsite/auth.phoneauthprovider.md index 44bd44b53ba..940e8e5442f 100644 --- a/docs-devsite/auth.phoneauthprovider.md +++ b/docs-devsite/auth.phoneauthprovider.md @@ -217,7 +217,7 @@ verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier: Promise<string> -A Promise for a verification ID that can be passed to [PhoneAuthProvider.credential()](./auth.phoneauthprovider.md#phoneauthprovidercredential) to identify this flow.. +A Promise for a verification ID that can be passed to [PhoneAuthProvider.credential()](./auth.phoneauthprovider.md#phoneauthprovidercredential) to identify this flow. ### Example 1 diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index f61ba74d1c6..d1cce3161f4 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -100,7 +100,7 @@ export const enum EnforcementState { } // Providers that have reCAPTCHA Enterprise support. -export const enum RecaptchaProvider { +export const enum RecaptchaAuthProvider { EMAIL_PASSWORD_PROVIDER = 'EMAIL_PASSWORD_PROVIDER', PHONE_PROVIDER = 'PHONE_PROVIDER' } diff --git a/packages/auth/src/core/credentials/email.test.ts b/packages/auth/src/core/credentials/email.test.ts index 3ed3cc5a81f..c18958460fa 100644 --- a/packages/auth/src/core/credentials/email.test.ts +++ b/packages/auth/src/core/credentials/email.test.ts @@ -137,6 +137,7 @@ describe('core/credentials/email', () => { beforeEach(async () => { auth = await testAuth(); + auth.settings.appVerificationDisabledForTesting = false; }); context('email & password', () => { diff --git a/packages/auth/src/core/credentials/email.ts b/packages/auth/src/core/credentials/email.ts index 4a3186ef2a4..9399296a59d 100644 --- a/packages/auth/src/core/credentials/email.ts +++ b/packages/auth/src/core/credentials/email.ts @@ -32,7 +32,11 @@ import { AuthErrorCode } from '../errors'; import { _fail } from '../util/assert'; import { AuthCredential } from './auth_credential'; import { handleRecaptchaFlow } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; -import { RecaptchaActionName, RecaptchaClientType } from '../../api'; +import { + RecaptchaActionName, + RecaptchaClientType, + RecaptchaAuthProvider +} from '../../api'; import { SignUpRequest } from '../../api/authentication/sign_up'; /** * Interface that represents the credentials returned by {@link EmailAuthProvider} for @@ -128,7 +132,8 @@ export class EmailAuthCredential extends AuthCredential { auth, request, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, - signInWithPassword + signInWithPassword, + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); case SignInMethod.EMAIL_LINK: return signInWithEmailLink(auth, { @@ -158,7 +163,8 @@ export class EmailAuthCredential extends AuthCredential { auth, request, RecaptchaActionName.SIGN_UP_PASSWORD, - linkEmailPassword + linkEmailPassword, + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); case SignInMethod.EMAIL_LINK: return signInWithEmailLinkForLinking(auth, { diff --git a/packages/auth/src/core/strategies/email_and_password.test.ts b/packages/auth/src/core/strategies/email_and_password.test.ts index 95fe8c8c06c..047e86dc17f 100644 --- a/packages/auth/src/core/strategies/email_and_password.test.ts +++ b/packages/auth/src/core/strategies/email_and_password.test.ts @@ -74,6 +74,7 @@ describe('core/strategies/sendPasswordResetEmail', () => { beforeEach(async () => { auth = await testAuth(); + auth.settings.appVerificationDisabledForTesting = false; mockFetch.setUp(); }); diff --git a/packages/auth/src/core/strategies/email_and_password.ts b/packages/auth/src/core/strategies/email_and_password.ts index f98ef683a92..fbfa871bc7c 100644 --- a/packages/auth/src/core/strategies/email_and_password.ts +++ b/packages/auth/src/core/strategies/email_and_password.ts @@ -41,7 +41,11 @@ import { getModularInstance } from '@firebase/util'; import { OperationType } from '../../model/enums'; import { handleRecaptchaFlow } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; import { IdTokenResponse } from '../../model/id_token'; -import { RecaptchaActionName, RecaptchaClientType } from '../../api'; +import { + RecaptchaActionName, + RecaptchaClientType, + RecaptchaAuthProvider +} from '../../api'; import { _isFirebaseServerApp } from '@firebase/app'; /** @@ -117,7 +121,8 @@ export async function sendPasswordResetEmail( authInternal, request, RecaptchaActionName.GET_OOB_CODE, - authentication.sendPasswordResetEmail + authentication.sendPasswordResetEmail, + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); } @@ -291,7 +296,8 @@ export async function createUserWithEmailAndPassword( authInternal, request, RecaptchaActionName.SIGN_UP_PASSWORD, - signUp + signUp, + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); const response = await signUpResponse.catch(error => { if ( diff --git a/packages/auth/src/core/strategies/email_link.test.ts b/packages/auth/src/core/strategies/email_link.test.ts index 7358b5a3512..7a3bbb7f346 100644 --- a/packages/auth/src/core/strategies/email_link.test.ts +++ b/packages/auth/src/core/strategies/email_link.test.ts @@ -58,6 +58,7 @@ describe('core/strategies/sendSignInLinkToEmail', () => { beforeEach(async () => { auth = await testAuth(); + auth.settings.appVerificationDisabledForTesting = false; mockFetch.setUp(); }); diff --git a/packages/auth/src/core/strategies/email_link.ts b/packages/auth/src/core/strategies/email_link.ts index 351583a6bb5..0049f1ef95e 100644 --- a/packages/auth/src/core/strategies/email_link.ts +++ b/packages/auth/src/core/strategies/email_link.ts @@ -33,7 +33,11 @@ import { _assert } from '../util/assert'; import { getModularInstance } from '@firebase/util'; import { _castAuth } from '../auth/auth_impl'; import { handleRecaptchaFlow } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; -import { RecaptchaActionName, RecaptchaClientType } from '../../api'; +import { + RecaptchaActionName, + RecaptchaClientType, + RecaptchaAuthProvider +} from '../../api'; import { _isFirebaseServerApp } from '@firebase/app'; import { _serverAppCurrentUserOperationNotSupportedError } from '../../core/util/assert'; @@ -108,7 +112,8 @@ export async function sendSignInLinkToEmail( authInternal, request, RecaptchaActionName.GET_OOB_CODE, - api.sendSignInLinkToEmail + api.sendSignInLinkToEmail, + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); } diff --git a/packages/auth/src/platform_browser/providers/phone.test.ts b/packages/auth/src/platform_browser/providers/phone.test.ts index 9293b5e4ee6..8a75fa14871 100644 --- a/packages/auth/src/platform_browser/providers/phone.test.ts +++ b/packages/auth/src/platform_browser/providers/phone.test.ts @@ -18,19 +18,37 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; -import { mockEndpoint } from '../../../test/helpers/api/helper'; +import { + mockEndpoint, + mockEndpointWithParams +} from '../../../test/helpers/api/helper'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; import * as fetch from '../../../test/helpers/mock_fetch'; -import { Endpoint } from '../../api'; +import { + Endpoint, + RecaptchaClientType, + RecaptchaVersion, + RecaptchaAuthProvider, + EnforcementState +} from '../../api'; import { RecaptchaVerifier } from '../../platform_browser/recaptcha/recaptcha_verifier'; import { PhoneAuthProvider } from './phone'; +import { FAKE_TOKEN } from '../recaptcha/recaptcha_enterprise_verifier'; +import { MockGreCAPTCHATopLevel } from '../recaptcha/recaptcha_mock'; +import { ApplicationVerifierInternal } from '../../model/application_verifier'; describe('platform_browser/providers/phone', () => { let auth: TestAuth; + let v2Verifier: ApplicationVerifierInternal; beforeEach(async () => { fetch.setUp(); auth = await testAuth(); + auth.settings.appVerificationDisabledForTesting = false; + v2Verifier = new RecaptchaVerifier(auth, document.createElement('div'), {}); + sinon + .stub(v2Verifier, 'verify') + .returns(Promise.resolve('verification-code')); }); afterEach(() => { @@ -39,26 +57,96 @@ describe('platform_browser/providers/phone', () => { }); context('#verifyPhoneNumber', () => { - it('calls verify on the appVerifier and then calls the server', async () => { + it('calls verify on the appVerifier and then calls the server when recaptcha enterprise is disabled', async () => { + const recaptchaConfigResponseOff = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaAuthProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.OFF + } + ] + }; + const recaptcha = new MockGreCAPTCHATopLevel(); + if (typeof window === 'undefined') { + return; + } + window.grecaptcha = recaptcha; + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('enterprise-token')); + + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseOff + ); + const route = mockEndpoint(Endpoint.SEND_VERIFICATION_CODE, { sessionInfo: 'verification-id' }); - const verifier = new RecaptchaVerifier( - auth, - document.createElement('div'), - {} + const provider = new PhoneAuthProvider(auth); + const result = await provider.verifyPhoneNumber( + '+15105550000', + v2Verifier ); + expect(result).to.eq('verification-id'); + expect(route.calls[0].request).to.eql({ + phoneNumber: '+15105550000', + recaptchaToken: 'verification-code', + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('calls the server when recaptcha enterprise is enabled', async () => { + const recaptchaConfigResponseEnforce = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaAuthProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.ENFORCE + } + ] + }; + const recaptcha = new MockGreCAPTCHATopLevel(); + if (typeof window === 'undefined') { + return; + } + window.grecaptcha = recaptcha; sinon - .stub(verifier, 'verify') - .returns(Promise.resolve('verification-code')); + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('enterprise-token')); + + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + + const route = mockEndpoint(Endpoint.SEND_VERIFICATION_CODE, { + sessionInfo: 'verification-id' + }); const provider = new PhoneAuthProvider(auth); - const result = await provider.verifyPhoneNumber('+15105550000', verifier); + const result = await provider.verifyPhoneNumber( + '+15105550000', + v2Verifier + ); expect(result).to.eq('verification-id'); expect(route.calls[0].request).to.eql({ phoneNumber: '+15105550000', - recaptchaToken: 'verification-code' + captchaResponse: 'enterprise-token', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }); }); }); diff --git a/packages/auth/src/platform_browser/providers/phone.ts b/packages/auth/src/platform_browser/providers/phone.ts index 2b5c0874b70..82b05385796 100644 --- a/packages/auth/src/platform_browser/providers/phone.ts +++ b/packages/auth/src/platform_browser/providers/phone.ts @@ -100,7 +100,7 @@ export class PhoneAuthProvider { * {@link RecaptchaVerifier}. * * @returns A Promise for a verification ID that can be passed to - * {@link PhoneAuthProvider.credential} to identify this flow.. + * {@link PhoneAuthProvider.credential} to identify this flow. */ verifyPhoneNumber( phoneOptions: PhoneInfoOptions | string, diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts index b3c97d0716f..1fd4de730d0 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts @@ -29,7 +29,7 @@ import { import { isV2, isEnterprise, RecaptchaConfig } from './recaptcha'; import { GetRecaptchaConfigResponse } from '../../api/authentication/recaptcha'; -import { EnforcementState, RecaptchaProvider } from '../../api/index'; +import { EnforcementState, RecaptchaAuthProvider } from '../../api/index'; use(chaiAsPromised); use(sinonChai); @@ -46,11 +46,11 @@ describe('platform_browser/recaptcha/recaptcha', () => { recaptchaKey: 'projects/testproj/keys/' + TEST_SITE_KEY, recaptchaEnforcementState: [ { - provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + provider: RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER, enforcementState: EnforcementState.ENFORCE }, { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.AUDIT } ] @@ -60,11 +60,11 @@ describe('platform_browser/recaptcha/recaptcha', () => { recaptchaKey: 'projects/testproj/keys/' + TEST_SITE_KEY, recaptchaEnforcementState: [ { - provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + provider: RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER, enforcementState: EnforcementState.OFF }, { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.OFF } ] @@ -75,11 +75,11 @@ describe('platform_browser/recaptcha/recaptcha', () => { recaptchaKey: 'projects/testproj/keys/' + TEST_SITE_KEY, recaptchaEnforcementState: [ { - provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + provider: RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER, enforcementState: EnforcementState.ENFORCE }, { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.OFF } ] @@ -120,15 +120,15 @@ describe('platform_browser/recaptcha/recaptcha', () => { it('should construct the recaptcha config from the backend response', () => { expect(recaptchaConfig.siteKey).to.eq(TEST_SITE_KEY); expect(recaptchaConfig.recaptchaEnforcementState[0]).to.eql({ - provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + provider: RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER, enforcementState: EnforcementState.ENFORCE }); expect(recaptchaConfig.recaptchaEnforcementState[1]).to.eql({ - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.AUDIT }); expect(recaptchaConfigEnforceAndOff.recaptchaEnforcementState[1]).to.eql({ - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.OFF }); }); @@ -136,17 +136,17 @@ describe('platform_browser/recaptcha/recaptcha', () => { it('#getProviderEnforcementState should return the correct enforcement state of the provider', () => { expect( recaptchaConfig.getProviderEnforcementState( - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ) ).to.eq(EnforcementState.ENFORCE); expect( recaptchaConfig.getProviderEnforcementState( - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ) ).to.eq(EnforcementState.AUDIT); expect( recaptchaConfigEnforceAndOff.getProviderEnforcementState( - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ) ).to.eq(EnforcementState.OFF); expect(recaptchaConfig.getProviderEnforcementState('invalid-provider')).to @@ -156,15 +156,15 @@ describe('platform_browser/recaptcha/recaptcha', () => { it('#isProviderEnabled should return the enablement state of the provider', () => { expect( recaptchaConfig.isProviderEnabled( - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ) ).to.be.true; expect( - recaptchaConfig.isProviderEnabled(RecaptchaProvider.PHONE_PROVIDER) + recaptchaConfig.isProviderEnabled(RecaptchaAuthProvider.PHONE_PROVIDER) ).to.be.true; expect( recaptchaConfigEnforceAndOff.isProviderEnabled( - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ) ).to.be.false; expect(recaptchaConfig.isProviderEnabled('invalid-provider')).to.be.false; diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha.ts index 2cc47f8a0cd..c84f25d139f 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha.ts @@ -22,7 +22,7 @@ import { } from '../../api/authentication/recaptcha'; import { EnforcementState, - RecaptchaProvider, + RecaptchaAuthProvider, _parseEnforcementState } from '../../api/index'; @@ -148,8 +148,8 @@ export class RecaptchaConfig { */ isAnyProviderEnabled(): boolean { return ( - this.isProviderEnabled(RecaptchaProvider.EMAIL_PASSWORD_PROVIDER) || - this.isProviderEnabled(RecaptchaProvider.PHONE_PROVIDER) + this.isProviderEnabled(RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER) || + this.isProviderEnabled(RecaptchaAuthProvider.PHONE_PROVIDER) ); } } diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.test.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.test.ts index 3b351a8fac7..b2510d34929 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.test.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.test.ts @@ -25,7 +25,7 @@ import { RecaptchaClientType, RecaptchaVersion, RecaptchaActionName, - RecaptchaProvider, + RecaptchaAuthProvider, EnforcementState } from '../../api'; import { mockEndpointWithParams } from '../../../test/helpers/api/helper'; @@ -56,11 +56,11 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { recaptchaKey: 'foo/bar/to/site-key', recaptchaEnforcementState: [ { - provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + provider: RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER, enforcementState: EnforcementState.ENFORCE }, { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.ENFORCE } ] @@ -72,16 +72,32 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { recaptchaKey: 'foo/bar/to/site-key', recaptchaEnforcementState: [ { - provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + provider: RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER, enforcementState: EnforcementState.OFF }, { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.OFF } ] }; const recaptchaConfigOff = new RecaptchaConfig(recaptchaConfigResponseOff); + const recaptchaConfigResponseAudit = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER, + enforcementState: EnforcementState.AUDIT + }, + { + provider: RecaptchaAuthProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.AUDIT + } + ] + }; + const recaptchaConfigAudit = new RecaptchaConfig( + recaptchaConfigResponseAudit + ); const getRecaptchaConfigRequest = { clientType: RecaptchaClientType.WEB, @@ -92,6 +108,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { beforeEach(async () => { auth = await testAuth(); + auth.settings.appVerificationDisabledForTesting = false; mockFetch.setUp(); verifier = new RecaptchaEnterpriseVerifier(auth); recaptcha = new MockGreCAPTCHATopLevel(); @@ -150,55 +167,51 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { }); }); - context('handleRecaptchaFlow', () => { + context('#handleRecaptchaFlow', () => { let mockAuthInstance: AuthInternal; let mockRequest: any; let mockActionMethod: sinon.SinonStub; beforeEach(async () => { mockAuthInstance = await testAuth(); - mockRequest = {}; + mockRequest = { foo: 'bar' }; mockActionMethod = sinon.stub(); + sinon + .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') + .resolves('recaptcha-response'); }); afterEach(() => { sinon.restore(); }); - it('should call actionMethod with request if emailPasswordEnabled is true', async () => { + it('EMAIL_PASSWORD_PROVIDER - should call actionMethod with request if recaptcha enterprise is enabled', async () => { if (typeof window === 'undefined') { return; } sinon .stub(mockAuthInstance, '_getRecaptchaConfig') .returns(recaptchaConfigEnforce); - sinon - .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') - .resolves('recaptcha-response'); - mockRequest = { foo: 'bar' }; mockActionMethod = sinon.stub().resolves('testResponse'); const response = await handleRecaptchaFlow( mockAuthInstance, mockRequest, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, - mockActionMethod + mockActionMethod, + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); expect(mockActionMethod).to.have.been.calledOnce; expect(response).to.equal('testResponse'); }); - // "Errors like "MISSING_RECAPTCHA_TOKEN" will be handled irrespective of the enablement status of "emailPasswordEnabled", but this test verifies the more likely scenario where emailPasswordEnabled is false" - it('should handle MISSING_RECAPTCHA_TOKEN error when emailPasswordEnabled is false', async () => { + // "Errors like "MISSING_RECAPTCHA_TOKEN" will be handled irrespective of the enablement status of EMAIL_PASSWORD_PROVIDER, but this test verifies the more likely scenario where EMAIL_PASSWORD_PROVIDER is disabled" + it('EMAIL_PASSWORD_PROVIDER - should handle MISSING_RECAPTCHA_TOKEN error when recaptcha enterprise is disabled', async () => { if (typeof window === 'undefined') { return; } sinon .stub(mockAuthInstance, '_getRecaptchaConfig') .returns(recaptchaConfigOff); - sinon - .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') - .resolves('recaptcha-response'); - mockRequest = { foo: 'bar' }; let callCount = 0; mockActionMethod = sinon.stub().callsFake(() => { callCount++; @@ -214,23 +227,20 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockAuthInstance, mockRequest, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, - mockActionMethod + mockActionMethod, + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); expect(mockActionMethod).to.have.been.calledTwice; expect(response).to.equal('testResponse'); }); - it('should handle non MISSING_RECAPTCHA_TOKEN error when emailPasswordEnabled is false', async () => { + it('EMAIL_PASSWORD_PROVIDER - should handle non MISSING_RECAPTCHA_TOKEN error when recaptcha enterprise is disabled', async () => { if (typeof window === 'undefined') { return; } sinon .stub(mockAuthInstance, '_getRecaptchaConfig') .returns(recaptchaConfigOff); - sinon - .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') - .resolves('recaptcha-response'); - mockRequest = { foo: 'bar' }; let callCount = 0; mockActionMethod = sinon.stub().callsFake(() => { callCount++; @@ -247,13 +257,123 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockAuthInstance, mockRequest, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, - mockActionMethod + mockActionMethod, + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); await expect(response).to.be.rejectedWith( AuthErrorCode.RECAPTCHA_NOT_ENABLED ); expect(mockActionMethod).to.have.been.calledOnce; }); + + it('PHONE_PROVIDER - should call actionMethod with request if recaptcha enterprise is enabled', async () => { + if (typeof window === 'undefined') { + return; + } + sinon + .stub(mockAuthInstance, '_getRecaptchaConfig') + .returns(recaptchaConfigEnforce); + mockActionMethod = sinon.stub().resolves('testResponse'); + const response = await handleRecaptchaFlow( + mockAuthInstance, + mockRequest, + RecaptchaActionName.SEND_VERIFICATION_CODE, + mockActionMethod, + RecaptchaAuthProvider.PHONE_PROVIDER + ); + expect(mockActionMethod).to.have.been.calledOnce; + expect(response).to.equal('testResponse'); + }); + + it('PHONE_PROVIDER - should handle MISSING_RECAPTCHA_TOKEN error when the enforcement state is audit', async () => { + if (typeof window === 'undefined') { + return; + } + sinon + .stub(mockAuthInstance, '_getRecaptchaConfig') + .returns(recaptchaConfigAudit); + let callCount = 0; + mockActionMethod = sinon.stub().callsFake(() => { + callCount++; + if (callCount === 1) { + return Promise.reject( + _createError(AuthErrorCode.MISSING_RECAPTCHA_TOKEN) + ); + } else { + return Promise.resolve('testResponse'); + } + }); + const response = await handleRecaptchaFlow( + mockAuthInstance, + mockRequest, + RecaptchaActionName.SEND_VERIFICATION_CODE, + mockActionMethod, + RecaptchaAuthProvider.PHONE_PROVIDER + ); + expect(mockActionMethod).to.have.been.calledTwice; + expect(response).to.equal('testResponse'); + }); + + it('PHONE_PROVIDER - should handle INVALID_APP_CREDENTIAL error when the enforcement state is audit', async () => { + if (typeof window === 'undefined') { + return; + } + sinon + .stub(mockAuthInstance, '_getRecaptchaConfig') + .returns(recaptchaConfigAudit); + let callCount = 0; + mockActionMethod = sinon.stub().callsFake(() => { + callCount++; + if (callCount === 1) { + return Promise.reject( + _createError(AuthErrorCode.INVALID_APP_CREDENTIAL) + ); + } else { + return Promise.resolve('testResponse'); + } + }); + const response = await handleRecaptchaFlow( + mockAuthInstance, + mockRequest, + RecaptchaActionName.SEND_VERIFICATION_CODE, + mockActionMethod, + RecaptchaAuthProvider.PHONE_PROVIDER + ); + expect(mockActionMethod).to.have.been.calledTwice; + expect(response).to.equal('testResponse'); + }); + + it('PHONE_PROVIDER - should handle non MISSING_RECAPTCHA_TOKEN and non INVALID_APP_CREDENTIAL error', async () => { + if (typeof window === 'undefined') { + return; + } + sinon + .stub(mockAuthInstance, '_getRecaptchaConfig') + .returns(recaptchaConfigAudit); + let callCount = 0; + mockActionMethod = sinon.stub().callsFake(() => { + callCount++; + if (callCount === 1) { + return Promise.reject( + _createError(AuthErrorCode.INVALID_RECAPTCHA_TOKEN) + ); + } else { + return Promise.resolve('testResponse'); + } + }); + + const response = handleRecaptchaFlow( + mockAuthInstance, + mockRequest, + RecaptchaActionName.SEND_VERIFICATION_CODE, + mockActionMethod, + RecaptchaAuthProvider.PHONE_PROVIDER + ); + await expect(response).to.be.rejectedWith( + AuthErrorCode.INVALID_RECAPTCHA_TOKEN + ); + expect(mockActionMethod).to.have.been.calledOnce; + }); }); context('#injectRecaptchaFields', () => { @@ -337,7 +457,8 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { idToken: 'idToken', phoneEnrollmentInfo: { phoneNumber: '123456', - recaptchaToken: 'recaptchaToken' + recaptchaToken: 'recaptcha-token', + clientType: RecaptchaClientType.WEB } }; const requestWithRecaptcha = await injectRecaptchaFields( @@ -350,7 +471,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { idToken: 'idToken', phoneEnrollmentInfo: { phoneNumber: '123456', - recaptchaToken: 'recaptchaToken', + recaptchaToken: 'recaptcha-token', captchaResponse: 'recaptcha-response', clientType: RecaptchaClientType.WEB, recaptchaVersion: RecaptchaVersion.ENTERPRISE @@ -374,7 +495,8 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mfaPendingCredential: 'mfaPendingCredential', mfaEnrollmentId: 'mfaEnrollmentId', phoneSignInInfo: { - recaptchaToken: 'recaptchaToken' + recaptchaToken: 'recaptcha-token', + clientType: RecaptchaClientType.WEB } }; const requestWithRecaptcha = await injectRecaptchaFields( @@ -387,7 +509,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mfaPendingCredential: 'mfaPendingCredential', mfaEnrollmentId: 'mfaEnrollmentId', phoneSignInInfo: { - recaptchaToken: 'recaptchaToken', + recaptchaToken: 'recaptcha-token', captchaResponse: 'recaptcha-response', clientType: RecaptchaClientType.WEB, recaptchaVersion: RecaptchaVersion.ENTERPRISE diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts index 46b28f01582..d6074775fef 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts @@ -22,7 +22,8 @@ import { RecaptchaClientType, RecaptchaVersion, RecaptchaActionName, - RecaptchaProvider + RecaptchaAuthProvider, + EnforcementState } from '../../api'; import { Auth } from '../../model/public_types'; @@ -32,6 +33,7 @@ import * as jsHelpers from '../load_js'; import { AuthErrorCode } from '../../core/errors'; import { StartPhoneMfaEnrollmentRequest } from '../../api/account_management/mfa'; import { StartPhoneMfaSignInRequest } from '../../api/authentication/mfa'; +import { MockGreCAPTCHATopLevel } from './recaptcha_mock'; export const RECAPTCHA_ENTERPRISE_VERIFIER_TYPE = 'recaptcha-enterprise'; export const FAKE_TOKEN = 'NO_RECAPTCHA'; @@ -121,6 +123,12 @@ export class RecaptchaEnterpriseVerifier { } } + // Returns Promise for a mock token when appVerificationDisabledForTesting is true. + if (this.auth.settings.appVerificationDisabledForTesting) { + const mockRecaptcha = new MockGreCAPTCHATopLevel(); + return mockRecaptcha.execute('siteKey', { action: 'verify' }); + } + return new Promise((resolve, reject) => { retrieveSiteKey(this.auth) .then(siteKey => { @@ -226,7 +234,7 @@ export async function injectRecaptchaFields( } type ActionMethod = ( - auth: Auth, + auth: AuthInternal, request: TRequest ) => Promise; @@ -234,37 +242,104 @@ export async function handleRecaptchaFlow( authInstance: AuthInternal, request: TRequest, actionName: RecaptchaActionName, - actionMethod: ActionMethod + actionMethod: ActionMethod, + recaptchaAuthProvider: RecaptchaAuthProvider ): Promise { - if ( - authInstance - ._getRecaptchaConfig() - ?.isProviderEnabled(RecaptchaProvider.EMAIL_PASSWORD_PROVIDER) - ) { - const requestWithRecaptcha = await injectRecaptchaFields( - authInstance, - request, - actionName, - actionName === RecaptchaActionName.GET_OOB_CODE - ); - return actionMethod(authInstance, requestWithRecaptcha); + if (recaptchaAuthProvider === RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER) { + if ( + authInstance + ._getRecaptchaConfig() + ?.isProviderEnabled(RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER) + ) { + const requestWithRecaptcha = await injectRecaptchaFields( + authInstance, + request, + actionName, + actionName === RecaptchaActionName.GET_OOB_CODE + ); + return actionMethod(authInstance, requestWithRecaptcha); + } else { + return actionMethod(authInstance, request).catch(async error => { + if (error.code === `auth/${AuthErrorCode.MISSING_RECAPTCHA_TOKEN}`) { + console.log( + `${actionName} is protected by reCAPTCHA Enterprise for this project. Automatically triggering the reCAPTCHA flow and restarting the flow.` + ); + const requestWithRecaptcha = await injectRecaptchaFields( + authInstance, + request, + actionName, + actionName === RecaptchaActionName.GET_OOB_CODE + ); + return actionMethod(authInstance, requestWithRecaptcha); + } else { + return Promise.reject(error); + } + }); + } + } else if (recaptchaAuthProvider === RecaptchaAuthProvider.PHONE_PROVIDER) { + if ( + authInstance + ._getRecaptchaConfig() + ?.isProviderEnabled(RecaptchaAuthProvider.PHONE_PROVIDER) + ) { + const requestWithRecaptcha = await injectRecaptchaFields( + authInstance, + request, + actionName + ); + + return actionMethod(authInstance, requestWithRecaptcha).catch( + async error => { + if ( + authInstance + ._getRecaptchaConfig() + ?.getProviderEnforcementState( + RecaptchaAuthProvider.PHONE_PROVIDER + ) === EnforcementState.AUDIT + ) { + // AUDIT mode + if ( + error.code === `auth/${AuthErrorCode.MISSING_RECAPTCHA_TOKEN}` || + error.code === `auth/${AuthErrorCode.INVALID_APP_CREDENTIAL}` + ) { + console.log( + `Failed to verify with reCAPTCHA Enterprise. Automatically triggering the reCAPTCHA v2 flow to complete the ${actionName} flow.` + ); + // reCAPTCHA Enterprise token is missing or reCAPTCHA Enterprise token + // check fails. + // Fallback to reCAPTCHA v2 flow. + const requestWithRecaptchaFields = await injectRecaptchaFields( + authInstance, + request, + actionName, + false, // isCaptchaResp + true // isFakeToken + ); + // This will call the PhoneApiCaller to fetch and inject reCAPTCHA v2 token. + return actionMethod(authInstance, requestWithRecaptchaFields); + } + } + // ENFORCE mode or AUDIT mode with any other error. + return Promise.reject(error); + } + ); + } else { + // Do reCAPTCHA v2 flow. + const requestWithRecaptchaFields = await injectRecaptchaFields( + authInstance, + request, + actionName, + false, // isCaptchaResp + true // isFakeToken + ); + + // This will call the PhoneApiCaller to fetch and inject v2 token. + return actionMethod(authInstance, requestWithRecaptchaFields); + } } else { - return actionMethod(authInstance, request).catch(async error => { - if (error.code === `auth/${AuthErrorCode.MISSING_RECAPTCHA_TOKEN}`) { - console.log( - `${actionName} is protected by reCAPTCHA Enterprise for this project. Automatically triggering the reCAPTCHA flow and restarting the flow.` - ); - const requestWithRecaptcha = await injectRecaptchaFields( - authInstance, - request, - actionName, - actionName === RecaptchaActionName.GET_OOB_CODE - ); - return actionMethod(authInstance, requestWithRecaptcha); - } else { - return Promise.reject(error); - } - }); + return Promise.reject( + recaptchaAuthProvider + ' provider is not supported.' + ); } } diff --git a/packages/auth/src/platform_browser/strategies/phone.test.ts b/packages/auth/src/platform_browser/strategies/phone.test.ts index c545a84f11a..96d887613d0 100644 --- a/packages/auth/src/platform_browser/strategies/phone.test.ts +++ b/packages/auth/src/platform_browser/strategies/phone.test.ts @@ -23,11 +23,21 @@ import sinonChai from 'sinon-chai'; import { OperationType, ProviderId } from '../../model/enums'; import { FirebaseError } from '@firebase/util'; -import { mockEndpoint } from '../../../test/helpers/api/helper'; +import { + mockEndpoint, + mockEndpointWithParams +} from '../../../test/helpers/api/helper'; import { makeJWT } from '../../../test/helpers/jwt'; import { testAuth, testUser, TestAuth } from '../../../test/helpers/mock_auth'; import * as fetch from '../../../test/helpers/mock_fetch'; -import { Endpoint } from '../../api'; +import { ServerError } from '../../api/errors'; +import { + Endpoint, + RecaptchaClientType, + RecaptchaVersion, + RecaptchaAuthProvider, + EnforcementState +} from '../../api'; import { MultiFactorInfoImpl } from '../../mfa/mfa_info'; import { MultiFactorSessionImpl } from '../../mfa/mfa_session'; import { multiFactor, MultiFactorUserImpl } from '../../mfa/mfa_user'; @@ -36,32 +46,103 @@ import { IdTokenResponse, IdTokenResponseKind } from '../../model/id_token'; import { UserInternal } from '../../model/user'; import { RecaptchaVerifier } from '../../platform_browser/recaptcha/recaptcha_verifier'; import { PhoneAuthCredential } from '../../core/credentials/phone'; +import { FAKE_TOKEN } from '../recaptcha/recaptcha_enterprise_verifier'; +import { MockGreCAPTCHATopLevel } from '../../platform_browser/recaptcha/recaptcha_mock'; + import { _verifyPhoneNumber, linkWithPhoneNumber, reauthenticateWithPhoneNumber, signInWithPhoneNumber, - updatePhoneNumber + updatePhoneNumber, + injectRecaptchaV2Token } from './phone'; use(chaiAsPromised); use(sinonChai); +const RECAPTCHA_V2_TOKEN = 'v2-token'; +const RECAPTCHA_ENTERPRISE_TOKEN = 'enterprise-token'; + +const recaptchaConfigResponseEnforce = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaAuthProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.ENFORCE + } + ] +}; +const recaptchaConfigResponseAudit = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaAuthProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.AUDIT + } + ] +}; +const recaptchaConfigResponseOff = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaAuthProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.OFF + } + ] +}; + +function mockRecaptchaEnterpriseEnablement( + enablementState: EnforcementState +): fetch.Route | undefined { + if (typeof window === 'undefined') { + return; + } + + let recaptchaConfigResponse = {}; + if (enablementState === EnforcementState.ENFORCE) { + recaptchaConfigResponse = recaptchaConfigResponseEnforce; + } else if (enablementState === EnforcementState.AUDIT) { + recaptchaConfigResponse = recaptchaConfigResponseAudit; + } else { + recaptchaConfigResponse = recaptchaConfigResponseOff; + } + + const recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve(RECAPTCHA_ENTERPRISE_TOKEN)); + + return mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponse + ); +} + describe('platform_browser/strategies/phone', () => { let auth: TestAuth; - let verifier: ApplicationVerifierInternal; + let v2Verifier: ApplicationVerifierInternal; let sendCodeEndpoint: fetch.Route; beforeEach(async () => { auth = await testAuth(); + auth.settings.appVerificationDisabledForTesting = false; fetch.setUp(); sendCodeEndpoint = mockEndpoint(Endpoint.SEND_VERIFICATION_CODE, { sessionInfo: 'session-info' }); - verifier = new RecaptchaVerifier(auth, document.createElement('div'), {}); - sinon.stub(verifier, 'verify').returns(Promise.resolve('recaptcha-token')); + v2Verifier = new RecaptchaVerifier(auth, document.createElement('div'), {}); + sinon + .stub(v2Verifier, 'verify') + .returns(Promise.resolve(RECAPTCHA_V2_TOKEN)); + mockRecaptchaEnterpriseEnablement(EnforcementState.OFF); }); afterEach(() => { @@ -70,22 +151,49 @@ describe('platform_browser/strategies/phone', () => { }); describe('signInWithPhoneNumber', () => { - it('calls verify phone number', async () => { - await signInWithPhoneNumber(auth, '+15105550000', verifier); + it('calls verify phone number when recaptcha enterprise is disabled', async () => { + if (typeof window === 'undefined') { + return; + } + await signInWithPhoneNumber(auth, '+15105550000', v2Verifier); + + expect(sendCodeEndpoint.calls[0].request).to.eql({ + recaptchaToken: RECAPTCHA_V2_TOKEN, + phoneNumber: '+15105550000', + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('calls verify phone number when recaptcha enterprise is enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.ENFORCE); + await signInWithPhoneNumber(auth, '+15105550000', v2Verifier); expect(sendCodeEndpoint.calls[0].request).to.eql({ - recaptchaToken: 'recaptcha-token', - phoneNumber: '+15105550000' + phoneNumber: '+15105550000', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }); }); context('ConfirmationResult', () => { it('result contains verification id baked in', async () => { - const result = await signInWithPhoneNumber(auth, 'number', verifier); + if (typeof window === 'undefined') { + return; + } + const result = await signInWithPhoneNumber(auth, 'number', v2Verifier); expect(result.verificationId).to.eq('session-info'); }); it('calling #confirm finishes the sign in flow', async () => { + if (typeof window === 'undefined') { + return; + } const idTokenResponse: IdTokenResponse = { idToken: 'my-id-token', refreshToken: 'my-refresh-token', @@ -104,7 +212,7 @@ describe('platform_browser/strategies/phone', () => { users: [{ localId: 'uid' }] }); - const result = await signInWithPhoneNumber(auth, 'number', verifier); + const result = await signInWithPhoneNumber(auth, 'number', v2Verifier); const userCred = await result.confirm('6789'); expect(userCred.user.uid).to.eq('uid'); expect(userCred.operationType).to.eq(OperationType.SIGN_IN); @@ -129,6 +237,9 @@ describe('platform_browser/strategies/phone', () => { }); it('rejects if a phone provider is already linked', async () => { + if (typeof window === 'undefined') { + return; + } getAccountInfoEndpoint.response = { users: [ { @@ -139,29 +250,56 @@ describe('platform_browser/strategies/phone', () => { }; await expect( - linkWithPhoneNumber(user, 'number', verifier) + linkWithPhoneNumber(user, 'number', v2Verifier) ).to.be.rejectedWith( FirebaseError, 'Firebase: User can only be linked to one identity for the given provider. (auth/provider-already-linked).' ); }); - it('calls verify phone number', async () => { - await linkWithPhoneNumber(user, '+15105550000', verifier); + it('calls verify phone number when recaptcha enterprise is disabled', async () => { + if (typeof window === 'undefined') { + return; + } + await linkWithPhoneNumber(user, '+15105550000', v2Verifier); expect(sendCodeEndpoint.calls[0].request).to.eql({ - recaptchaToken: 'recaptcha-token', - phoneNumber: '+15105550000' + recaptchaToken: RECAPTCHA_V2_TOKEN, + phoneNumber: '+15105550000', + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('calls verify phone number when recaptcha enterprise is enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.ENFORCE); + await linkWithPhoneNumber(user, '+15105550000', v2Verifier); + + expect(sendCodeEndpoint.calls[0].request).to.eql({ + phoneNumber: '+15105550000', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }); }); context('ConfirmationResult', () => { it('result contains verification id baked in', async () => { - const result = await linkWithPhoneNumber(user, 'number', verifier); + if (typeof window === 'undefined') { + return; + } + const result = await linkWithPhoneNumber(user, 'number', v2Verifier); expect(result.verificationId).to.eq('session-info'); }); it('calling #confirm finishes the sign in flow', async () => { + if (typeof window === 'undefined') { + return; + } const idTokenResponse: IdTokenResponse = { idToken: 'my-id-token', refreshToken: 'my-refresh-token', @@ -182,7 +320,7 @@ describe('platform_browser/strategies/phone', () => { const initialIdToken = await user.getIdToken(); - const result = await linkWithPhoneNumber(user, 'number', verifier); + const result = await linkWithPhoneNumber(user, 'number', v2Verifier); const userCred = await result.confirm('6789'); expect(userCred.user.uid).to.eq('uid'); expect(userCred.operationType).to.eq(OperationType.LINK); @@ -206,26 +344,53 @@ describe('platform_browser/strategies/phone', () => { user = testUser(auth, 'uid', 'email', true); }); - it('calls verify phone number', async () => { - await reauthenticateWithPhoneNumber(user, '+15105550000', verifier); + it('calls verify phone number when recaptcha enterprise is disabled', async () => { + if (typeof window === 'undefined') { + return; + } + await reauthenticateWithPhoneNumber(user, '+15105550000', v2Verifier); expect(sendCodeEndpoint.calls[0].request).to.eql({ - recaptchaToken: 'recaptcha-token', - phoneNumber: '+15105550000' + recaptchaToken: RECAPTCHA_V2_TOKEN, + phoneNumber: '+15105550000', + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('calls verify phone number when recaptcha enterprise is enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.ENFORCE); + await reauthenticateWithPhoneNumber(user, '+15105550000', v2Verifier); + + expect(sendCodeEndpoint.calls[0].request).to.eql({ + phoneNumber: '+15105550000', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }); }); context('ConfirmationResult', () => { it('result contains verification id baked in', async () => { + if (typeof window === 'undefined') { + return; + } const result = await reauthenticateWithPhoneNumber( user, 'number', - verifier + v2Verifier ); expect(result.verificationId).to.eq('session-info'); }); it('calling #confirm finishes the sign in flow', async () => { + if (typeof window === 'undefined') { + return; + } const idTokenResponse: IdTokenResponse = { idToken: makeJWT({ 'sub': 'uid' }), refreshToken: 'my-refresh-token', @@ -247,7 +412,7 @@ describe('platform_browser/strategies/phone', () => { const result = await reauthenticateWithPhoneNumber( user, 'number', - verifier + v2Verifier ); const userCred = await result.confirm('6789'); expect(userCred.user.uid).to.eq('uid'); @@ -260,6 +425,9 @@ describe('platform_browser/strategies/phone', () => { }); it('rejects if the uid mismatches', async () => { + if (typeof window === 'undefined') { + return; + } const idTokenResponse: IdTokenResponse = { idToken: makeJWT({ 'sub': 'different-uid' }), refreshToken: 'my-refresh-token', @@ -274,7 +442,7 @@ describe('platform_browser/strategies/phone', () => { const result = await reauthenticateWithPhoneNumber( user, 'number', - verifier + v2Verifier ); await expect(result.confirm('code')).to.be.rejectedWith( FirebaseError, @@ -286,29 +454,163 @@ describe('platform_browser/strategies/phone', () => { describe('_verifyPhoneNumber', () => { it('works with a string phone number', async () => { - const sessionInfo = await _verifyPhoneNumber(auth, 'number', verifier); + if (typeof window === 'undefined') { + return; + } + const sessionInfo = await _verifyPhoneNumber(auth, 'number', v2Verifier); expect(sessionInfo).to.eq('session-info'); expect(sendCodeEndpoint.calls[0].request).to.eql({ - recaptchaToken: 'recaptcha-token', - phoneNumber: 'number' + recaptchaToken: RECAPTCHA_V2_TOKEN, + phoneNumber: 'number', + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }); }); it('works with an options object', async () => { + if (typeof window === 'undefined') { + return; + } const sessionInfo = await _verifyPhoneNumber( auth, { phoneNumber: 'number' }, - verifier + v2Verifier ); expect(sessionInfo).to.eq('session-info'); expect(sendCodeEndpoint.calls[0].request).to.eql({ - recaptchaToken: 'recaptcha-token', - phoneNumber: 'number' + recaptchaToken: RECAPTCHA_V2_TOKEN, + phoneNumber: 'number', + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('works when recaptcha enterprise is enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.AUDIT); + const sessionInfo = await _verifyPhoneNumber(auth, 'number', v2Verifier); + expect(sessionInfo).to.eq('session-info'); + expect(sendCodeEndpoint.calls[0].request).to.eql({ + phoneNumber: 'number', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }); }); + it('calls fallback to recaptcha v2 flow when receiving MISSING_RECAPTCHA_TOKEN error in recaptcha enterprise audit mode', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.AUDIT); + const failureMock = mockEndpoint( + Endpoint.SEND_VERIFICATION_CODE, + { + error: { + code: 400, + message: ServerError.MISSING_RECAPTCHA_TOKEN + } + }, + 400 + ); + await expect( + _verifyPhoneNumber(auth, 'number', v2Verifier) + ).to.be.rejectedWith( + 'Firebase: The reCAPTCHA token is missing when sending request to the backend. (auth/missing-recaptcha-token).' + ); + expect(failureMock.calls.length).to.eq(2); + // First call should have a recaptcha enterprise token + expect(failureMock.calls[0].request).to.eql({ + phoneNumber: 'number', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + // Second call should have a recaptcha v2 token + expect(failureMock.calls[1].request).to.eql({ + recaptchaToken: RECAPTCHA_V2_TOKEN, + phoneNumber: 'number', + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('calls fallback to recaptcha v2 flow when receiving INVALID_APP_CREDENTIAL error in recaptcha enterprise audit mode', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.AUDIT); + const failureMock = mockEndpoint( + Endpoint.SEND_VERIFICATION_CODE, + { + error: { + code: 400, + message: ServerError.INVALID_APP_CREDENTIAL + } + }, + 400 + ); + await expect( + _verifyPhoneNumber(auth, 'number', v2Verifier) + ).to.be.rejectedWith( + 'Firebase: The phone verification request contains an invalid application verifier. The reCAPTCHA token response is either invalid or expired. (auth/invalid-app-credential).' + ); + expect(failureMock.calls.length).to.eq(2); + // First call should have a recaptcha enterprise token + expect(failureMock.calls[0].request).to.eql({ + phoneNumber: 'number', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + // Second call should have a recaptcha v2 token + expect(failureMock.calls[1].request).to.eql({ + recaptchaToken: RECAPTCHA_V2_TOKEN, + phoneNumber: 'number', + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('does not call fallback to recaptcha v2 flow when receiving other errors in recaptcha enterprise audit mode', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.AUDIT); + const failureMock = mockEndpoint( + Endpoint.SEND_VERIFICATION_CODE, + { + error: { + code: 400, + message: ServerError.INVALID_RECAPTCHA_TOKEN + } + }, + 400 + ); + await expect( + _verifyPhoneNumber(auth, 'number', v2Verifier) + ).to.be.rejectedWith( + 'Firebase: The reCAPTCHA token is invalid when sending request to the backend. (auth/invalid-recaptcha-token).' + ); + // First call should have a recaptcha enterprise token + expect(failureMock.calls[0].request).to.eql({ + phoneNumber: 'number', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + // No fallback to recaptcha v2 flow + expect(failureMock.calls.length).to.eq(1); + }); + context('MFA', () => { let user: UserInternal; let mfaUser: MultiFactorUserImpl; @@ -322,7 +624,39 @@ describe('platform_browser/strategies/phone', () => { mfaUser = multiFactor(user) as MultiFactorUserImpl; }); - it('works with an enrollment flow', async () => { + it('works with an enrollment flow when recaptcha enterprise is disabled', async () => { + if (typeof window === 'undefined') { + return; + } + const endpoint = mockEndpoint(Endpoint.START_MFA_ENROLLMENT, { + phoneSessionInfo: { + sessionInfo: 'session-info' + } + }); + const session = (await mfaUser.getSession()) as MultiFactorSessionImpl; + const sessionInfo = await _verifyPhoneNumber( + auth, + { phoneNumber: 'number', session }, + v2Verifier + ); + expect(sessionInfo).to.eq('session-info'); + expect(endpoint.calls[0].request).to.eql({ + idToken: session.credential, + phoneEnrollmentInfo: { + phoneNumber: 'number', + recaptchaToken: RECAPTCHA_V2_TOKEN, + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + } + }); + }); + + it('works with an enrollment flow when recaptcha enterprise is enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.ENFORCE); const endpoint = mockEndpoint(Endpoint.START_MFA_ENROLLMENT, { phoneSessionInfo: { sessionInfo: 'session-info' @@ -332,19 +666,24 @@ describe('platform_browser/strategies/phone', () => { const sessionInfo = await _verifyPhoneNumber( auth, { phoneNumber: 'number', session }, - verifier + v2Verifier ); expect(sessionInfo).to.eq('session-info'); expect(endpoint.calls[0].request).to.eql({ idToken: session.credential, phoneEnrollmentInfo: { phoneNumber: 'number', - recaptchaToken: 'recaptcha-token' + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE } }); }); - it('works when completing the sign in flow', async () => { + it('works when completing the sign in flow and recaptcha enterprise is disabled', async () => { + if (typeof window === 'undefined') { + return; + } const endpoint = mockEndpoint(Endpoint.START_MFA_SIGN_IN, { phoneResponseInfo: { sessionInfo: 'session-info' @@ -364,30 +703,77 @@ describe('platform_browser/strategies/phone', () => { session, multiFactorHint: mfaInfo }, - verifier + v2Verifier ); expect(sessionInfo).to.eq('session-info'); expect(endpoint.calls[0].request).to.eql({ mfaPendingCredential: 'mfa-pending-credential', mfaEnrollmentId: 'mfa-enrollment-id', phoneSignInInfo: { - recaptchaToken: 'recaptcha-token' + recaptchaToken: RECAPTCHA_V2_TOKEN, + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + } + }); + }); + + it('works when completing the sign in flow and recaptcha enterprise is enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.ENFORCE); + const endpoint = mockEndpoint(Endpoint.START_MFA_SIGN_IN, { + phoneResponseInfo: { + sessionInfo: 'session-info' + } + }); + const session = MultiFactorSessionImpl._fromMfaPendingCredential( + 'mfa-pending-credential' + ); + const mfaInfo = MultiFactorInfoImpl._fromServerResponse(auth, { + mfaEnrollmentId: 'mfa-enrollment-id', + enrolledAt: Date.now(), + phoneInfo: 'phone-number-from-enrollment' + }); + const sessionInfo = await _verifyPhoneNumber( + auth, + { + session, + multiFactorHint: mfaInfo + }, + v2Verifier + ); + expect(sessionInfo).to.eq('session-info'); + expect(endpoint.calls[0].request).to.eql({ + mfaPendingCredential: 'mfa-pending-credential', + mfaEnrollmentId: 'mfa-enrollment-id', + phoneSignInInfo: { + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE } }); }); }); - it('throws if the verifier does not return a string', async () => { - (verifier.verify as sinon.SinonStub).returns(Promise.resolve(123)); + it('throws if the v2Verifier does not return a string', async () => { + if (typeof window === 'undefined') { + return; + } + (v2Verifier.verify as sinon.SinonStub).returns(Promise.resolve(123)); await expect( - _verifyPhoneNumber(auth, 'number', verifier) + _verifyPhoneNumber(auth, 'number', v2Verifier) ).to.be.rejectedWith(FirebaseError, 'auth/argument-error'); }); - it('throws if the verifier type is not recaptcha', async () => { + it('throws if the v2Verifier type is not recaptcha', async () => { + if (typeof window === 'undefined') { + return; + } const mutVerifier: { -readonly [K in keyof ApplicationVerifierInternal]: ApplicationVerifierInternal[K]; - } = verifier; + } = v2Verifier; mutVerifier.type = 'not-recaptcha-thats-for-sure'; await expect( _verifyPhoneNumber(auth, 'number', mutVerifier) @@ -395,19 +781,26 @@ describe('platform_browser/strategies/phone', () => { }); it('resets the verifer after successful verification', async () => { - sinon.spy(verifier, '_reset'); - expect(await _verifyPhoneNumber(auth, 'number', verifier)).to.eq( + if (typeof window === 'undefined') { + return; + } + sinon.spy(v2Verifier, '_reset'); + expect(await _verifyPhoneNumber(auth, 'number', v2Verifier)).to.eq( 'session-info' ); - expect(verifier._reset).to.have.been.called; + expect(v2Verifier._reset).to.have.been.called; }); it('resets the verifer after a failed verification', async () => { - sinon.spy(verifier, '_reset'); - (verifier.verify as sinon.SinonStub).returns(Promise.resolve(123)); - - await expect(_verifyPhoneNumber(auth, 'number', verifier)).to.be.rejected; - expect(verifier._reset).to.have.been.called; + if (typeof window === 'undefined') { + return; + } + sinon.spy(v2Verifier, '_reset'); + (v2Verifier.verify as sinon.SinonStub).returns(Promise.resolve(123)); + + await expect(_verifyPhoneNumber(auth, 'number', v2Verifier)).to.be + .rejected; + expect(v2Verifier._reset).to.have.been.called; }); }); @@ -455,4 +848,90 @@ describe('platform_browser/strategies/phone', () => { expect(reloadMock.calls.length).to.eq(1); }); }); + + describe('#injectRecaptchaV2Token', () => { + it('injects recaptcha v2 token into SendPhoneVerificationCode request', async () => { + const request = { + phoneNumber: '123456', + clientType: RecaptchaClientType.WEB, + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }; + + const requestWithV2Token = await injectRecaptchaV2Token( + auth, + request, + v2Verifier + ); + + const expectedRequest = { + phoneNumber: '123456', + recaptchaToken: RECAPTCHA_V2_TOKEN, + clientType: RecaptchaClientType.WEB, + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }; + expect(requestWithV2Token).to.eql(expectedRequest); + }); + + it('injects recaptcha v2 token into StartPhoneMfaEnrollment request', async () => { + const request = { + idToken: 'idToken', + phoneEnrollmentInfo: { + phoneNumber: '123456', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + } + }; + + const requestWithRecaptcha = await injectRecaptchaV2Token( + auth, + request, + v2Verifier + ); + + const expectedRequest = { + idToken: 'idToken', + phoneEnrollmentInfo: { + phoneNumber: '123456', + recaptchaToken: RECAPTCHA_V2_TOKEN, + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + } + }; + expect(requestWithRecaptcha).to.eql(expectedRequest); + }); + + it('injects recaptcha enterprise fields into StartPhoneMfaSignInRequest request', async () => { + const request = { + mfaPendingCredential: 'mfaPendingCredential', + mfaEnrollmentId: 'mfaEnrollmentId', + phoneSignInInfo: { + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + } + }; + + const requestWithRecaptcha = await injectRecaptchaV2Token( + auth, + request, + v2Verifier + ); + + const expectedRequest = { + mfaPendingCredential: 'mfaPendingCredential', + mfaEnrollmentId: 'mfaEnrollmentId', + phoneSignInInfo: { + recaptchaToken: RECAPTCHA_V2_TOKEN, + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + } + }; + expect(requestWithRecaptcha).to.eql(expectedRequest); + }); + }); }); diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index 9e0c34d7058..a074eca9e7e 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -24,9 +24,26 @@ import { UserCredential } from '../../model/public_types'; -import { startEnrollPhoneMfa } from '../../api/account_management/mfa'; -import { startSignInPhoneMfa } from '../../api/authentication/mfa'; -import { sendPhoneVerificationCode } from '../../api/authentication/sms'; +import { + startEnrollPhoneMfa, + StartPhoneMfaEnrollmentRequest, + StartPhoneMfaEnrollmentResponse +} from '../../api/account_management/mfa'; +import { + startSignInPhoneMfa, + StartPhoneMfaSignInRequest, + StartPhoneMfaSignInResponse +} from '../../api/authentication/mfa'; +import { + sendPhoneVerificationCode, + SendPhoneVerificationCodeRequest, + SendPhoneVerificationCodeResponse +} from '../../api/authentication/sms'; +import { + RecaptchaActionName, + RecaptchaClientType, + RecaptchaAuthProvider +} from '../../api'; import { ApplicationVerifierInternal } from '../../model/application_verifier'; import { PhoneAuthCredential } from '../../core/credentials/phone'; import { AuthErrorCode } from '../../core/errors'; @@ -50,6 +67,11 @@ import { RECAPTCHA_VERIFIER_TYPE } from '../recaptcha/recaptcha_verifier'; import { _castAuth } from '../../core/auth/auth_impl'; import { getModularInstance } from '@firebase/util'; import { ProviderId } from '../../model/enums'; +import { + RecaptchaEnterpriseVerifier, + FAKE_TOKEN, + handleRecaptchaFlow +} from '../recaptcha/recaptcha_enterprise_verifier'; import { _isFirebaseServerApp } from '@firebase/app'; interface OnConfirmationCallback { @@ -190,6 +212,11 @@ export async function reauthenticateWithPhoneNumber( ); } +type PhoneApiCaller = ( + auth: AuthInternal, + request: TRequest +) => Promise; + /** * Returns a verification ID to be used in conjunction with the SMS code that is sent. * @@ -199,20 +226,12 @@ export async function _verifyPhoneNumber( options: PhoneInfoOptions | string, verifier: ApplicationVerifierInternal ): Promise { - const recaptchaToken = await verifier.verify(); + if (!auth._getRecaptchaConfig()) { + const enterpriseVerifier = new RecaptchaEnterpriseVerifier(auth); + await enterpriseVerifier.verify(); + } try { - _assert( - typeof recaptchaToken === 'string', - auth, - AuthErrorCode.ARGUMENT_ERROR - ); - _assert( - verifier.type === RECAPTCHA_VERIFIER_TYPE, - auth, - AuthErrorCode.ARGUMENT_ERROR - ); - let phoneInfoOptions: PhoneInfoOptions; if (typeof options === 'string') { @@ -232,13 +251,57 @@ export async function _verifyPhoneNumber( auth, AuthErrorCode.INTERNAL_ERROR ); - const response = await startEnrollPhoneMfa(auth, { + + const startPhoneMfaEnrollmentRequest: StartPhoneMfaEnrollmentRequest = { idToken: session.credential, phoneEnrollmentInfo: { phoneNumber: phoneInfoOptions.phoneNumber, - recaptchaToken + clientType: RecaptchaClientType.WEB } + }; + + const startEnrollPhoneMfaActionCallback: PhoneApiCaller< + StartPhoneMfaEnrollmentRequest, + StartPhoneMfaEnrollmentResponse + > = async ( + authInstance: AuthInternal, + request: StartPhoneMfaEnrollmentRequest + ) => { + // If reCAPTCHA Enterprise token is empty or "NO_RECAPTCHA", fetch reCAPTCHA v2 token and inject into request. + if ( + !request.phoneEnrollmentInfo.captchaResponse || + request.phoneEnrollmentInfo.captchaResponse.length === 0 || + request.phoneEnrollmentInfo.captchaResponse === FAKE_TOKEN + ) { + _assert( + verifier.type === RECAPTCHA_VERIFIER_TYPE, + authInstance, + AuthErrorCode.ARGUMENT_ERROR + ); + + const requestWithRecaptchaV2 = await injectRecaptchaV2Token( + authInstance, + request, + verifier + ); + return startEnrollPhoneMfa(authInstance, requestWithRecaptchaV2); + } + return startEnrollPhoneMfa(authInstance, request); + }; + + const startPhoneMfaEnrollmentResponse: Promise = + handleRecaptchaFlow( + auth, + startPhoneMfaEnrollmentRequest, + RecaptchaActionName.MFA_SMS_ENROLLMENT, + startEnrollPhoneMfaActionCallback, + RecaptchaAuthProvider.PHONE_PROVIDER + ); + + const response = await startPhoneMfaEnrollmentResponse.catch(error => { + return Promise.reject(error); }); + return response.phoneSessionInfo.sessionInfo; } else { _assert( @@ -250,21 +313,112 @@ export async function _verifyPhoneNumber( phoneInfoOptions.multiFactorHint?.uid || phoneInfoOptions.multiFactorUid; _assert(mfaEnrollmentId, auth, AuthErrorCode.MISSING_MFA_INFO); - const response = await startSignInPhoneMfa(auth, { + + const startPhoneMfaSignInRequest: StartPhoneMfaSignInRequest = { mfaPendingCredential: session.credential, mfaEnrollmentId, phoneSignInInfo: { - recaptchaToken + clientType: RecaptchaClientType.WEB } + }; + + const startSignInPhoneMfaActionCallback: PhoneApiCaller< + StartPhoneMfaSignInRequest, + StartPhoneMfaSignInResponse + > = async ( + authInstance: AuthInternal, + request: StartPhoneMfaSignInRequest + ) => { + // If reCAPTCHA Enterprise token is empty or "NO_RECAPTCHA", fetch v2 token and inject into request. + if ( + !request.phoneSignInInfo.captchaResponse || + request.phoneSignInInfo.captchaResponse.length === 0 || + request.phoneSignInInfo.captchaResponse === FAKE_TOKEN + ) { + _assert( + verifier.type === RECAPTCHA_VERIFIER_TYPE, + authInstance, + AuthErrorCode.ARGUMENT_ERROR + ); + + const requestWithRecaptchaV2 = await injectRecaptchaV2Token( + authInstance, + request, + verifier + ); + return startSignInPhoneMfa(authInstance, requestWithRecaptchaV2); + } + return startSignInPhoneMfa(authInstance, request); + }; + + const startPhoneMfaSignInResponse: Promise = + handleRecaptchaFlow( + auth, + startPhoneMfaSignInRequest, + RecaptchaActionName.MFA_SMS_SIGNIN, + startSignInPhoneMfaActionCallback, + RecaptchaAuthProvider.PHONE_PROVIDER + ); + + const response = await startPhoneMfaSignInResponse.catch(error => { + return Promise.reject(error); }); + return response.phoneResponseInfo.sessionInfo; } } else { - const { sessionInfo } = await sendPhoneVerificationCode(auth, { - phoneNumber: phoneInfoOptions.phoneNumber, - recaptchaToken + const sendPhoneVerificationCodeRequest: SendPhoneVerificationCodeRequest = + { + phoneNumber: phoneInfoOptions.phoneNumber, + clientType: RecaptchaClientType.WEB + }; + + const sendPhoneVerificationCodeActionCallback: PhoneApiCaller< + SendPhoneVerificationCodeRequest, + SendPhoneVerificationCodeResponse + > = async ( + authInstance: AuthInternal, + request: SendPhoneVerificationCodeRequest + ) => { + // If reCAPTCHA Enterprise token is empty or "NO_RECAPTCHA", fetch v2 token and inject into request. + if ( + !request.captchaResponse || + request.captchaResponse.length === 0 || + request.captchaResponse === FAKE_TOKEN + ) { + _assert( + verifier.type === RECAPTCHA_VERIFIER_TYPE, + authInstance, + AuthErrorCode.ARGUMENT_ERROR + ); + + const requestWithRecaptchaV2 = await injectRecaptchaV2Token( + authInstance, + request, + verifier + ); + return sendPhoneVerificationCode( + authInstance, + requestWithRecaptchaV2 + ); + } + return sendPhoneVerificationCode(authInstance, request); + }; + + const sendPhoneVerificationCodeResponse: Promise = + handleRecaptchaFlow( + auth, + sendPhoneVerificationCodeRequest, + RecaptchaActionName.SEND_VERIFICATION_CODE, + sendPhoneVerificationCodeActionCallback, + RecaptchaAuthProvider.PHONE_PROVIDER + ); + + const response = await sendPhoneVerificationCodeResponse.catch(error => { + return Promise.reject(error); }); - return sessionInfo; + + return response.sessionInfo; } } finally { verifier._reset(); @@ -306,3 +460,75 @@ export async function updatePhoneNumber( } await _link(userInternal, credential); } + +// Helper function that fetches and injects a reCAPTCHA v2 token into the request. +export async function injectRecaptchaV2Token( + auth: AuthInternal, + request: T, + recaptchaV2Verifier: ApplicationVerifierInternal +): Promise { + _assert( + recaptchaV2Verifier.type === RECAPTCHA_VERIFIER_TYPE, + auth, + AuthErrorCode.ARGUMENT_ERROR + ); + + const recaptchaV2Token = await recaptchaV2Verifier.verify(); + + _assert( + typeof recaptchaV2Token === 'string', + auth, + AuthErrorCode.ARGUMENT_ERROR + ); + + const newRequest = { ...request }; + + if ('phoneEnrollmentInfo' in newRequest) { + const phoneNumber = ( + newRequest as unknown as StartPhoneMfaEnrollmentRequest + ).phoneEnrollmentInfo.phoneNumber; + const captchaResponse = ( + newRequest as unknown as StartPhoneMfaEnrollmentRequest + ).phoneEnrollmentInfo.captchaResponse; + const clientType = (newRequest as unknown as StartPhoneMfaEnrollmentRequest) + .phoneEnrollmentInfo.clientType; + const recaptchaVersion = ( + newRequest as unknown as StartPhoneMfaEnrollmentRequest + ).phoneEnrollmentInfo.recaptchaVersion; + + Object.assign(newRequest, { + 'phoneEnrollmentInfo': { + phoneNumber, + recaptchaToken: recaptchaV2Token, + captchaResponse, + clientType, + recaptchaVersion + } + }); + + return newRequest; + } else if ('phoneSignInInfo' in newRequest) { + const captchaResponse = ( + newRequest as unknown as StartPhoneMfaSignInRequest + ).phoneSignInInfo.captchaResponse; + const clientType = (newRequest as unknown as StartPhoneMfaSignInRequest) + .phoneSignInInfo.clientType; + const recaptchaVersion = ( + newRequest as unknown as StartPhoneMfaSignInRequest + ).phoneSignInInfo.recaptchaVersion; + + Object.assign(newRequest, { + 'phoneSignInInfo': { + recaptchaToken: recaptchaV2Token, + captchaResponse, + clientType, + recaptchaVersion + } + }); + + return newRequest; + } else { + Object.assign(newRequest, { 'recaptchaToken': recaptchaV2Token }); + return newRequest; + } +}