From d96f2d00f6e6f619f892c919b3cc98f9fda52ef2 Mon Sep 17 00:00:00 2001 From: "Nhien (Ricky) Lam" <62775270+NhienLam@users.noreply.github.com> Date: Thu, 16 Nov 2023 22:26:54 -0800 Subject: [PATCH 1/9] 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 --- .../src/api/account_management/mfa.test.ts | 12 +- .../auth/src/api/account_management/mfa.ts | 9 +- .../auth/src/api/authentication/mfa.test.ts | 12 +- packages/auth/src/api/authentication/mfa.ts | 9 +- .../auth/src/api/authentication/sms.test.ts | 12 +- packages/auth/src/api/authentication/sms.ts | 9 +- packages/auth/src/api/index.ts | 8 +- .../recaptcha/recaptcha.test.ts | 100 +++++++-- .../platform_browser/recaptcha/recaptcha.ts | 19 +- .../recaptcha_enterprise_verifier.test.ts | 191 ++++++++++++++++-- .../recaptcha_enterprise_verifier.ts | 61 +++++- 11 files changed, 391 insertions(+), 51 deletions(-) diff --git a/packages/auth/src/api/account_management/mfa.test.ts b/packages/auth/src/api/account_management/mfa.test.ts index 9f036471087..152f1fb53d3 100644 --- a/packages/auth/src/api/account_management/mfa.test.ts +++ b/packages/auth/src/api/account_management/mfa.test.ts @@ -20,7 +20,12 @@ import chaiAsPromised from 'chai-as-promised'; import { FirebaseError } from '@firebase/util'; -import { Endpoint, HttpHeader } from '../'; +import { + Endpoint, + HttpHeader, + RecaptchaClientType, + RecaptchaVersion +} from '../'; import { mockEndpoint } from '../../../test/helpers/api/helper'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; import * as mockFetch from '../../../test/helpers/mock_fetch'; @@ -40,7 +45,10 @@ describe('api/account_management/startEnrollPhoneMfa', () => { idToken: 'id-token', phoneEnrollmentInfo: { phoneNumber: 'phone-number', - recaptchaToken: 'captcha-token' + recaptchaToken: 'captcha-token', + captchaResponse: 'captcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE } }; diff --git a/packages/auth/src/api/account_management/mfa.ts b/packages/auth/src/api/account_management/mfa.ts index db9a4120d3d..f43217e8abf 100644 --- a/packages/auth/src/api/account_management/mfa.ts +++ b/packages/auth/src/api/account_management/mfa.ts @@ -18,6 +18,8 @@ import { Endpoint, HttpMethod, + RecaptchaClientType, + RecaptchaVersion, _addTidIfNecessary, _performApiRequest } from '../index'; @@ -55,7 +57,12 @@ export interface StartPhoneMfaEnrollmentRequest { idToken: string; phoneEnrollmentInfo: { phoneNumber: string; - recaptchaToken: string; + // reCAPTCHA v2 token + recaptchaToken?: string; + // reCAPTCHA Enterprise token + captchaResponse?: string; + clientType?: RecaptchaClientType; + recaptchaVersion?: RecaptchaVersion; }; tenantId?: string; } diff --git a/packages/auth/src/api/authentication/mfa.test.ts b/packages/auth/src/api/authentication/mfa.test.ts index fd7d811ec36..5574136f999 100644 --- a/packages/auth/src/api/authentication/mfa.test.ts +++ b/packages/auth/src/api/authentication/mfa.test.ts @@ -20,7 +20,12 @@ import chaiAsPromised from 'chai-as-promised'; import { FirebaseError } from '@firebase/util'; -import { Endpoint, HttpHeader } from '../'; +import { + Endpoint, + HttpHeader, + RecaptchaClientType, + RecaptchaVersion +} from '../'; import { mockEndpoint } from '../../../test/helpers/api/helper'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; import * as mockFetch from '../../../test/helpers/mock_fetch'; @@ -34,7 +39,10 @@ describe('api/authentication/startSignInPhoneMfa', () => { mfaPendingCredential: 'my-creds', mfaEnrollmentId: 'my-enrollment-id', phoneSignInInfo: { - recaptchaToken: 'captcha-token' + recaptchaToken: 'captcha-token', + captchaResponse: 'captcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE } }; diff --git a/packages/auth/src/api/authentication/mfa.ts b/packages/auth/src/api/authentication/mfa.ts index 0ad85a7ef82..c6dc6854e98 100644 --- a/packages/auth/src/api/authentication/mfa.ts +++ b/packages/auth/src/api/authentication/mfa.ts @@ -19,6 +19,8 @@ import { _performApiRequest, Endpoint, HttpMethod, + RecaptchaClientType, + RecaptchaVersion, _addTidIfNecessary } from '../index'; import { Auth } from '../../model/public_types'; @@ -47,7 +49,12 @@ export interface StartPhoneMfaSignInRequest { mfaPendingCredential: string; mfaEnrollmentId: string; phoneSignInInfo: { - recaptchaToken: string; + // reCAPTCHA v2 token + recaptchaToken?: string; + // reCAPTCHA Enterprise token + captchaResponse?: string; + clientType?: RecaptchaClientType; + recaptchaVersion?: RecaptchaVersion; }; tenantId?: string; } diff --git a/packages/auth/src/api/authentication/sms.test.ts b/packages/auth/src/api/authentication/sms.test.ts index 420797b1c97..d72e7973077 100644 --- a/packages/auth/src/api/authentication/sms.test.ts +++ b/packages/auth/src/api/authentication/sms.test.ts @@ -21,7 +21,12 @@ import chaiAsPromised from 'chai-as-promised'; import { ProviderId } from '../../model/enums'; import { FirebaseError } from '@firebase/util'; -import { Endpoint, HttpHeader } from '../'; +import { + Endpoint, + HttpHeader, + RecaptchaClientType, + RecaptchaVersion +} from '../'; import { mockEndpoint } from '../../../test/helpers/api/helper'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; import * as mockFetch from '../../../test/helpers/mock_fetch'; @@ -38,7 +43,10 @@ use(chaiAsPromised); describe('api/authentication/sendPhoneVerificationCode', () => { const request = { phoneNumber: '123456789', - recaptchaToken: 'captchad' + recaptchaToken: 'captchad', + captchaResponse: 'captcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }; let auth: TestAuth; diff --git a/packages/auth/src/api/authentication/sms.ts b/packages/auth/src/api/authentication/sms.ts index 93a8a2e5abf..26db0634288 100644 --- a/packages/auth/src/api/authentication/sms.ts +++ b/packages/auth/src/api/authentication/sms.ts @@ -18,6 +18,8 @@ import { Endpoint, HttpMethod, + RecaptchaClientType, + RecaptchaVersion, _addTidIfNecessary, _makeTaggedError, _performApiRequest, @@ -30,8 +32,13 @@ import { Auth } from '../../model/public_types'; export interface SendPhoneVerificationCodeRequest { phoneNumber: string; - recaptchaToken: string; + // reCAPTCHA v2 token + recaptchaToken?: string; tenantId?: string; + // reCAPTCHA Enterprise token + captchaResponse?: string; + clientType?: RecaptchaClientType; + recaptchaVersion?: RecaptchaVersion; } export interface SendPhoneVerificationCodeResponse { diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index 12d89b2bd7d..e2e4bd6540f 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -86,7 +86,10 @@ export const enum RecaptchaVersion { export const enum RecaptchaActionName { SIGN_IN_WITH_PASSWORD = 'signInWithPassword', GET_OOB_CODE = 'getOobCode', - SIGN_UP_PASSWORD = 'signUpPassword' + SIGN_UP_PASSWORD = 'signUpPassword', + SEND_VERIFICATION_CODE = 'sendVerificationCode', + MFA_SMS_ENROLLMENT = 'mfaSmsEnrollment', + MFA_SMS_SIGNIN = 'mfaSmsSignin' } export const enum EnforcementState { @@ -98,7 +101,8 @@ export const enum EnforcementState { // Providers that have reCAPTCHA Enterprise support. export const enum RecaptchaProvider { - EMAIL_PASSWORD_PROVIDER = 'EMAIL_PASSWORD_PROVIDER' + EMAIL_PASSWORD_PROVIDER = 'EMAIL_PASSWORD_PROVIDER', + PHONE_PROVIDER = 'PHONE_PROVIDER' } export const DEFAULT_API_TIMEOUT_MS = new Delay(30_000, 60_000); diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts index 42a758840f6..b3c97d0716f 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 } from '../../api/index'; +import { EnforcementState, RecaptchaProvider } from '../../api/index'; use(chaiAsPromised); use(sinonChai); @@ -39,17 +39,60 @@ describe('platform_browser/recaptcha/recaptcha', () => { let recaptchaV2: MockReCaptcha; let recaptchaV3: MockGreCAPTCHA; let recaptchaEnterprise: MockGreCAPTCHATopLevel; - let recaptchaConfig: RecaptchaConfig; const TEST_SITE_KEY = 'test-site-key'; const GET_RECAPTCHA_CONFIG_RESPONSE: GetRecaptchaConfigResponse = { recaptchaKey: 'projects/testproj/keys/' + TEST_SITE_KEY, recaptchaEnforcementState: [ - { provider: 'EMAIL_PASSWORD_PROVIDER', enforcementState: 'ENFORCE' } + { + provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + enforcementState: EnforcementState.ENFORCE + }, + { + provider: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.AUDIT + } ] }; + const GET_RECAPTCHA_CONFIG_RESPONSE_OFF: GetRecaptchaConfigResponse = { + recaptchaKey: 'projects/testproj/keys/' + TEST_SITE_KEY, + recaptchaEnforcementState: [ + { + provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + enforcementState: EnforcementState.OFF + }, + { + provider: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.OFF + } + ] + }; + + const GET_RECAPTCHA_CONFIG_RESPONSE_ENFORCE_AND_OFF: GetRecaptchaConfigResponse = + { + recaptchaKey: 'projects/testproj/keys/' + TEST_SITE_KEY, + recaptchaEnforcementState: [ + { + provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + enforcementState: EnforcementState.ENFORCE + }, + { + provider: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.OFF + } + ] + }; + + const recaptchaConfig = new RecaptchaConfig(GET_RECAPTCHA_CONFIG_RESPONSE); + const recaptchaConfigOff = new RecaptchaConfig( + GET_RECAPTCHA_CONFIG_RESPONSE_OFF + ); + const recaptchaConfigEnforceAndOff = new RecaptchaConfig( + GET_RECAPTCHA_CONFIG_RESPONSE_ENFORCE_AND_OFF + ); + context('#verify', () => { beforeEach(async () => { auth = await testAuth(); @@ -74,30 +117,63 @@ describe('platform_browser/recaptcha/recaptcha', () => { }); context('#RecaptchaConfig', () => { - beforeEach(async () => { - recaptchaConfig = new RecaptchaConfig(GET_RECAPTCHA_CONFIG_RESPONSE); - }); - 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: 'EMAIL_PASSWORD_PROVIDER', - enforcementState: 'ENFORCE' + provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + enforcementState: EnforcementState.ENFORCE + }); + expect(recaptchaConfig.recaptchaEnforcementState[1]).to.eql({ + provider: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.AUDIT + }); + expect(recaptchaConfigEnforceAndOff.recaptchaEnforcementState[1]).to.eql({ + provider: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.OFF }); }); it('#getProviderEnforcementState should return the correct enforcement state of the provider', () => { expect( - recaptchaConfig.getProviderEnforcementState('EMAIL_PASSWORD_PROVIDER') + recaptchaConfig.getProviderEnforcementState( + RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + ) ).to.eq(EnforcementState.ENFORCE); + expect( + recaptchaConfig.getProviderEnforcementState( + RecaptchaProvider.PHONE_PROVIDER + ) + ).to.eq(EnforcementState.AUDIT); + expect( + recaptchaConfigEnforceAndOff.getProviderEnforcementState( + RecaptchaProvider.PHONE_PROVIDER + ) + ).to.eq(EnforcementState.OFF); expect(recaptchaConfig.getProviderEnforcementState('invalid-provider')).to .be.null; }); it('#isProviderEnabled should return the enablement state of the provider', () => { - expect(recaptchaConfig.isProviderEnabled('EMAIL_PASSWORD_PROVIDER')).to.be - .true; + expect( + recaptchaConfig.isProviderEnabled( + RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + ) + ).to.be.true; + expect( + recaptchaConfig.isProviderEnabled(RecaptchaProvider.PHONE_PROVIDER) + ).to.be.true; + expect( + recaptchaConfigEnforceAndOff.isProviderEnabled( + RecaptchaProvider.PHONE_PROVIDER + ) + ).to.be.false; expect(recaptchaConfig.isProviderEnabled('invalid-provider')).to.be.false; }); + + it('#isAnyProviderEnabled should return true if at least one provider is enabled', () => { + expect(recaptchaConfig.isAnyProviderEnabled()).to.be.true; + expect(recaptchaConfigEnforceAndOff.isAnyProviderEnabled()).to.be.true; + expect(recaptchaConfigOff.isAnyProviderEnabled()).to.be.false; + }); }); }); diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha.ts index bb1b79895c0..2cc47f8a0cd 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha.ts @@ -20,7 +20,11 @@ import { GetRecaptchaConfigResponse, RecaptchaEnforcementProviderState } from '../../api/authentication/recaptcha'; -import { EnforcementState, _parseEnforcementState } from '../../api/index'; +import { + EnforcementState, + RecaptchaProvider, + _parseEnforcementState +} from '../../api/index'; // reCAPTCHA v2 interface export interface Recaptcha { @@ -135,4 +139,17 @@ export class RecaptchaConfig { this.getProviderEnforcementState(providerStr) === EnforcementState.AUDIT ); } + + /** + * Returns true if reCAPTCHA Enterprise protection is enabled in at least one provider, otherwise + * returns false. + * + * @returns Whether or not reCAPTCHA Enterprise protection is enabled for at least one provider. + */ + isAnyProviderEnabled(): boolean { + return ( + this.isProviderEnabled(RecaptchaProvider.EMAIL_PASSWORD_PROVIDER) || + this.isProviderEnabled(RecaptchaProvider.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 ecbe6c0232d..3b351a8fac7 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 @@ -24,7 +24,9 @@ import { Endpoint, RecaptchaClientType, RecaptchaVersion, - RecaptchaActionName + RecaptchaActionName, + RecaptchaProvider, + EnforcementState } from '../../api'; import { mockEndpointWithParams } from '../../../test/helpers/api/helper'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; @@ -36,7 +38,8 @@ import { MockGreCAPTCHATopLevel } from './recaptcha_mock'; import { RecaptchaEnterpriseVerifier, FAKE_TOKEN, - handleRecaptchaFlow + handleRecaptchaFlow, + injectRecaptchaFields } from './recaptcha_enterprise_verifier'; import { RecaptchaConfig } from './recaptcha'; import { AuthErrorCode } from '../../core/errors'; @@ -53,8 +56,12 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { recaptchaKey: 'foo/bar/to/site-key', recaptchaEnforcementState: [ { - provider: 'EMAIL_PASSWORD_PROVIDER', - enforcementState: 'ENFORCE' + provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + enforcementState: EnforcementState.ENFORCE + }, + { + provider: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.ENFORCE } ] }; @@ -65,17 +72,30 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { recaptchaKey: 'foo/bar/to/site-key', recaptchaEnforcementState: [ { - provider: 'EMAIL_PASSWORD_PROVIDER', - enforcementState: 'OFF' + provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + enforcementState: EnforcementState.OFF + }, + { + provider: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.OFF } ] }; const recaptchaConfigOff = new RecaptchaConfig(recaptchaConfigResponseOff); + const getRecaptchaConfigRequest = { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }; + + let recaptcha: MockGreCAPTCHATopLevel; + beforeEach(async () => { auth = await testAuth(); mockFetch.setUp(); verifier = new RecaptchaEnterpriseVerifier(auth); + recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; }); afterEach(() => { @@ -84,21 +104,10 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { }); context('#verify', () => { - const request = { - clientType: RecaptchaClientType.WEB, - version: RecaptchaVersion.ENTERPRISE - }; - - let recaptcha: MockGreCAPTCHATopLevel; - beforeEach(() => { - recaptcha = new MockGreCAPTCHATopLevel(); - window.grecaptcha = recaptcha; - }); - it('returns if response is available', async () => { mockEndpointWithParams( Endpoint.GET_RECAPTCHA_CONFIG, - request, + getRecaptchaConfigRequest, recaptchaConfigResponseEnforce ); sinon @@ -110,7 +119,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { it('reject if error is thrown when retrieve site key', async () => { mockEndpointWithParams( Endpoint.GET_RECAPTCHA_CONFIG, - request, + getRecaptchaConfigRequest, { error: { code: 400, @@ -131,7 +140,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { it('return fake recaptcha token if error is thrown when retrieve recaptcha token', async () => { mockEndpointWithParams( Endpoint.GET_RECAPTCHA_CONFIG, - request, + getRecaptchaConfigRequest, recaptchaConfigResponseEnforce ); sinon @@ -246,4 +255,146 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { expect(mockActionMethod).to.have.been.calledOnce; }); }); + + context('#injectRecaptchaFields', () => { + it('injects recaptcha enterprise fields into SignInWithPassword request', async () => { + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + getRecaptchaConfigRequest, + recaptchaConfigResponseEnforce + ); + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + + const request = { + returnSecureToken: true, + email: 'email', + password: 'password', + clientType: RecaptchaClientType.WEB + }; + const requestWithRecaptcha = await injectRecaptchaFields( + auth, + request, + RecaptchaActionName.SIGN_IN_WITH_PASSWORD, + false + ); + const expectedRequest = { + returnSecureToken: true, + email: 'email', + password: 'password', + clientType: RecaptchaClientType.WEB, + captchaResponse: 'recaptcha-response', + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }; + + expect(requestWithRecaptcha).to.eql(expectedRequest); + }); + + it('injects recaptcha enterprise fields when captchaResp is true', async () => { + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + getRecaptchaConfigRequest, + recaptchaConfigResponseEnforce + ); + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + + const request = { + requestType: 'requestType', + email: 'email', + clientType: RecaptchaClientType.WEB + }; + const requestWithRecaptcha = await injectRecaptchaFields( + auth, + request, + RecaptchaActionName.GET_OOB_CODE, + true + ); + const expectedRequest = { + requestType: 'requestType', + email: 'email', + clientType: RecaptchaClientType.WEB, + captchaResp: 'recaptcha-response', + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }; + + expect(requestWithRecaptcha).to.eql(expectedRequest); + }); + + it('injects recaptcha enterprise fields into StartPhoneMfaEnrollment request', async () => { + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + getRecaptchaConfigRequest, + recaptchaConfigResponseEnforce + ); + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + + const request = { + idToken: 'idToken', + phoneEnrollmentInfo: { + phoneNumber: '123456', + recaptchaToken: 'recaptchaToken' + } + }; + const requestWithRecaptcha = await injectRecaptchaFields( + auth, + request, + RecaptchaActionName.MFA_SMS_ENROLLMENT, + false + ); + const expectedRequest = { + idToken: 'idToken', + phoneEnrollmentInfo: { + phoneNumber: '123456', + recaptchaToken: 'recaptchaToken', + captchaResponse: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + } + }; + + expect(requestWithRecaptcha).to.eql(expectedRequest); + }); + + it('injects recaptcha enterprise fields into StartPhoneMfaSignInRequest request', async () => { + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + getRecaptchaConfigRequest, + recaptchaConfigResponseEnforce + ); + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + + const request = { + mfaPendingCredential: 'mfaPendingCredential', + mfaEnrollmentId: 'mfaEnrollmentId', + phoneSignInInfo: { + recaptchaToken: 'recaptchaToken' + } + }; + const requestWithRecaptcha = await injectRecaptchaFields( + auth, + request, + RecaptchaActionName.MFA_SMS_SIGNIN, + false + ); + const expectedRequest = { + mfaPendingCredential: 'mfaPendingCredential', + mfaEnrollmentId: 'mfaEnrollmentId', + phoneSignInInfo: { + recaptchaToken: 'recaptchaToken', + captchaResponse: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + } + }; + + expect(requestWithRecaptcha).to.eql(expectedRequest); + }); + }); }); 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 cccd737defc..46b28f01582 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts @@ -30,6 +30,8 @@ import { AuthInternal } from '../../model/auth'; import { _castAuth } from '../../core/auth/auth_impl'; import * as jsHelpers from '../load_js'; import { AuthErrorCode } from '../../core/errors'; +import { StartPhoneMfaEnrollmentRequest } from '../../api/account_management/mfa'; +import { StartPhoneMfaSignInRequest } from '../../api/authentication/mfa'; export const RECAPTCHA_ENTERPRISE_VERIFIER_TYPE = 'recaptcha-enterprise'; export const FAKE_TOKEN = 'NO_RECAPTCHA'; @@ -156,17 +158,62 @@ export async function injectRecaptchaFields( auth: AuthInternal, request: T, action: RecaptchaActionName, - captchaResp = false + isCaptchaResp = false, + isFakeToken = false ): Promise { const verifier = new RecaptchaEnterpriseVerifier(auth); let captchaResponse; - try { - captchaResponse = await verifier.verify(action); - } catch (error) { - captchaResponse = await verifier.verify(action, true); + + if (isFakeToken) { + captchaResponse = FAKE_TOKEN; + } else { + try { + captchaResponse = await verifier.verify(action); + } catch (error) { + captchaResponse = await verifier.verify(action, true); + } } + const newRequest = { ...request }; - if (!captchaResp) { + if ( + action === RecaptchaActionName.MFA_SMS_ENROLLMENT || + action === RecaptchaActionName.MFA_SMS_SIGNIN + ) { + if ('phoneEnrollmentInfo' in newRequest) { + const phoneNumber = ( + newRequest as unknown as StartPhoneMfaEnrollmentRequest + ).phoneEnrollmentInfo.phoneNumber; + const recaptchaToken = ( + newRequest as unknown as StartPhoneMfaEnrollmentRequest + ).phoneEnrollmentInfo.recaptchaToken; + + Object.assign(newRequest, { + 'phoneEnrollmentInfo': { + phoneNumber, + recaptchaToken, + captchaResponse, + 'clientType': RecaptchaClientType.WEB, + 'recaptchaVersion': RecaptchaVersion.ENTERPRISE + } + }); + } else if ('phoneSignInInfo' in newRequest) { + const recaptchaToken = ( + newRequest as unknown as StartPhoneMfaSignInRequest + ).phoneSignInInfo.recaptchaToken; + + Object.assign(newRequest, { + 'phoneSignInInfo': { + recaptchaToken, + captchaResponse, + 'clientType': RecaptchaClientType.WEB, + 'recaptchaVersion': RecaptchaVersion.ENTERPRISE + } + }); + } + return newRequest; + } + + if (!isCaptchaResp) { Object.assign(newRequest, { captchaResponse }); } else { Object.assign(newRequest, { 'captchaResp': captchaResponse }); @@ -236,7 +283,7 @@ export async function _initializeRecaptchaConfig(auth: Auth): Promise { authInternal._tenantRecaptchaConfigs[authInternal.tenantId] = config; } - if (config.isProviderEnabled(RecaptchaProvider.EMAIL_PASSWORD_PROVIDER)) { + if (config.isAnyProviderEnabled()) { const verifier = new RecaptchaEnterpriseVerifier(authInternal); void verifier.verify(); } From e529bef39a3aa935c1e4ec9a27ddf97fc1105ccf 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 2/9] 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 e2e4bd6540f..b68f5b04308 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 945da88e47b..97f8cdc7c7a 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 1290375a1d1..47f0b081c55 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 verifier 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 verifier 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; + } +} From 30940ddda902730d8ed8031a50a1c8431520f1ee Mon Sep 17 00:00:00 2001 From: "Nhien (Ricky) Lam" <62775270+NhienLam@users.noreply.github.com> Date: Wed, 31 Jul 2024 13:48:38 -0700 Subject: [PATCH 3/9] Make ApplicationVerifier params optional in Phone Auth APIs (#8366) * Make ApplicationVerifier params optional in Phone APIs * Add more unit tests for when ApplicationVerifier is not available --- common/api-review/auth.api.md | 8 +- docs-devsite/auth.md | 6 +- docs-devsite/auth.phoneauthprovider.md | 2 +- packages/auth/src/api/index.ts | 2 +- .../platform_browser/providers/phone.test.ts | 84 ++++++++++++++++++- .../src/platform_browser/providers/phone.ts | 2 +- .../platform_browser/strategies/phone.test.ts | 53 ++++++++++++ .../src/platform_browser/strategies/phone.ts | 20 ++--- 8 files changed, 156 insertions(+), 21 deletions(-) diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 8e915daf731..b011b803774 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -445,7 +445,7 @@ export function isSignInWithEmailLink(auth: Auth, emailLink: string): boolean; export function linkWithCredential(user: User, credential: AuthCredential): Promise; // @public -export function linkWithPhoneNumber(user: User, phoneNumber: string, appVerifier: ApplicationVerifier): Promise; +export function linkWithPhoneNumber(user: User, phoneNumber: string, appVerifier?: ApplicationVerifier): Promise; // @public export function linkWithPopup(user: User, provider: AuthProvider, resolver?: PopupRedirectResolver): Promise; @@ -625,7 +625,7 @@ export class PhoneAuthProvider { static readonly PHONE_SIGN_IN_METHOD: 'phone'; static readonly PROVIDER_ID: 'phone'; readonly providerId: "phone"; - verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier: ApplicationVerifier): Promise; + verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier?: ApplicationVerifier): Promise; } // @public @@ -692,7 +692,7 @@ export interface ReactNativeAsyncStorage { export function reauthenticateWithCredential(user: User, credential: AuthCredential): Promise; // @public -export function reauthenticateWithPhoneNumber(user: User, phoneNumber: string, appVerifier: ApplicationVerifier): Promise; +export function reauthenticateWithPhoneNumber(user: User, phoneNumber: string, appVerifier?: ApplicationVerifier): Promise; // @public export function reauthenticateWithPopup(user: User, provider: AuthProvider, resolver?: PopupRedirectResolver): Promise; @@ -778,7 +778,7 @@ export function signInWithEmailAndPassword(auth: Auth, email: string, password: export function signInWithEmailLink(auth: Auth, email: string, emailLink?: string): Promise; // @public -export function signInWithPhoneNumber(auth: Auth, phoneNumber: string, appVerifier: ApplicationVerifier): Promise; +export function signInWithPhoneNumber(auth: Auth, phoneNumber: string, appVerifier?: ApplicationVerifier): Promise; // @public export function signInWithPopup(auth: Auth, provider: AuthProvider, resolver?: PopupRedirectResolver): Promise; diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 43d23dc8931..308badfc946 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -930,7 +930,7 @@ This method does not work in a Node.js environment or with [Auth](./auth.auth.md Signature: ```typescript -export declare function signInWithPhoneNumber(auth: Auth, phoneNumber: string, appVerifier: ApplicationVerifier): Promise; +export declare function signInWithPhoneNumber(auth: Auth, phoneNumber: string, appVerifier?: ApplicationVerifier): Promise; ``` #### Parameters @@ -1304,7 +1304,7 @@ This method does not work in a Node.js environment. Signature: ```typescript -export declare function linkWithPhoneNumber(user: User, phoneNumber: string, appVerifier: ApplicationVerifier): Promise; +export declare function linkWithPhoneNumber(user: User, phoneNumber: string, appVerifier?: ApplicationVerifier): Promise; ``` #### Parameters @@ -1457,7 +1457,7 @@ This method does not work in a Node.js environment or on any [User](./auth.user. Signature: ```typescript -export declare function reauthenticateWithPhoneNumber(user: User, phoneNumber: string, appVerifier: ApplicationVerifier): Promise; +export declare function reauthenticateWithPhoneNumber(user: User, phoneNumber: string, appVerifier?: ApplicationVerifier): Promise; ``` #### Parameters diff --git a/docs-devsite/auth.phoneauthprovider.md b/docs-devsite/auth.phoneauthprovider.md index 940e8e5442f..e64f869303b 100644 --- a/docs-devsite/auth.phoneauthprovider.md +++ b/docs-devsite/auth.phoneauthprovider.md @@ -203,7 +203,7 @@ Starts a phone number authentication flow by sending a verification code to the Signature: ```typescript -verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier: ApplicationVerifier): Promise; +verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier?: ApplicationVerifier): Promise; ``` #### Parameters diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index b68f5b04308..d0b21252edb 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -89,7 +89,7 @@ export const enum RecaptchaActionName { SIGN_UP_PASSWORD = 'signUpPassword', SEND_VERIFICATION_CODE = 'sendVerificationCode', MFA_SMS_ENROLLMENT = 'mfaSmsEnrollment', - MFA_SMS_SIGNIN = 'mfaSmsSignin' + MFA_SMS_SIGNIN = 'mfaSmsSignIn' } export const enum EnforcementState { diff --git a/packages/auth/src/platform_browser/providers/phone.test.ts b/packages/auth/src/platform_browser/providers/phone.test.ts index 8a75fa14871..9f555920ef3 100644 --- a/packages/auth/src/platform_browser/providers/phone.test.ts +++ b/packages/auth/src/platform_browser/providers/phone.test.ts @@ -15,9 +15,12 @@ * limitations under the License. */ -import { expect } from 'chai'; +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; import * as sinon from 'sinon'; +import { FirebaseError } from '@firebase/util'; + import { mockEndpoint, mockEndpointWithParams @@ -37,6 +40,8 @@ import { FAKE_TOKEN } from '../recaptcha/recaptcha_enterprise_verifier'; import { MockGreCAPTCHATopLevel } from '../recaptcha/recaptcha_mock'; import { ApplicationVerifierInternal } from '../../model/application_verifier'; +use(chaiAsPromised); + describe('platform_browser/providers/phone', () => { let auth: TestAuth; let v2Verifier: ApplicationVerifierInternal; @@ -104,6 +109,83 @@ describe('platform_browser/providers/phone', () => { }); }); + it('throws an error if verify without appVerifier 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 provider = new PhoneAuthProvider(auth); + await expect( + provider.verifyPhoneNumber('+15105550000') + ).to.be.rejectedWith(FirebaseError, 'auth/argument-error'); + }); + + it('calls the server without appVerifier 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(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'); + expect(result).to.eq('verification-id'); + expect(route.calls[0].request).to.eql({ + phoneNumber: '+15105550000', + captchaResponse: 'enterprise-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', diff --git a/packages/auth/src/platform_browser/providers/phone.ts b/packages/auth/src/platform_browser/providers/phone.ts index 82b05385796..a9b2f253f8a 100644 --- a/packages/auth/src/platform_browser/providers/phone.ts +++ b/packages/auth/src/platform_browser/providers/phone.ts @@ -104,7 +104,7 @@ export class PhoneAuthProvider { */ verifyPhoneNumber( phoneOptions: PhoneInfoOptions | string, - applicationVerifier: ApplicationVerifier + applicationVerifier?: ApplicationVerifier ): Promise { return _verifyPhoneNumber( this.auth, diff --git a/packages/auth/src/platform_browser/strategies/phone.test.ts b/packages/auth/src/platform_browser/strategies/phone.test.ts index 47f0b081c55..b1fa4efebd3 100644 --- a/packages/auth/src/platform_browser/strategies/phone.test.ts +++ b/packages/auth/src/platform_browser/strategies/phone.test.ts @@ -181,6 +181,32 @@ describe('platform_browser/strategies/phone', () => { }); }); + it('calls verify phone number without a v2 RecaptchaVerifier when recaptcha enterprise is enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.ENFORCE); + await signInWithPhoneNumber(auth, '+15105550000'); + + expect(sendCodeEndpoint.calls[0].request).to.eql({ + phoneNumber: '+15105550000', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('throws an error if verify phone number without a v2 RecaptchaVerifier when recaptcha enterprise is disabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.OFF); + + await expect( + signInWithPhoneNumber(auth, '+15105550000') + ).to.be.rejectedWith(FirebaseError, 'auth/argument-error'); + }); + context('ConfirmationResult', () => { it('result contains verification id baked in', async () => { if (typeof window === 'undefined') { @@ -504,6 +530,33 @@ describe('platform_browser/strategies/phone', () => { }); }); + it('works without v2 RecaptchaVerifier when recaptcha enterprise is enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.ENFORCE); + const sessionInfo = await _verifyPhoneNumber(auth, 'number'); + 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('throws error if calls verify phone number without v2 RecaptchaVerifier when recaptcha enterprise is disabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockRecaptchaEnterpriseEnablement(EnforcementState.OFF); + + await expect(_verifyPhoneNumber(auth, 'number')).to.be.rejectedWith( + FirebaseError, + 'auth/argument-error' + ); + }); + it('calls fallback to recaptcha v2 flow when receiving MISSING_RECAPTCHA_TOKEN error in recaptcha enterprise audit mode', async () => { if (typeof window === 'undefined') { return; diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index a074eca9e7e..e661499fdaa 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -129,7 +129,7 @@ class ConfirmationResultImpl implements ConfirmationResult { export async function signInWithPhoneNumber( auth: Auth, phoneNumber: string, - appVerifier: ApplicationVerifier + appVerifier?: ApplicationVerifier ): Promise { if (_isFirebaseServerApp(auth.app)) { return Promise.reject( @@ -162,7 +162,7 @@ export async function signInWithPhoneNumber( export async function linkWithPhoneNumber( user: User, phoneNumber: string, - appVerifier: ApplicationVerifier + appVerifier?: ApplicationVerifier ): Promise { const userInternal = getModularInstance(user) as UserInternal; await _assertLinkedStatus(false, userInternal, ProviderId.PHONE); @@ -194,7 +194,7 @@ export async function linkWithPhoneNumber( export async function reauthenticateWithPhoneNumber( user: User, phoneNumber: string, - appVerifier: ApplicationVerifier + appVerifier?: ApplicationVerifier ): Promise { const userInternal = getModularInstance(user) as UserInternal; if (_isFirebaseServerApp(userInternal.auth.app)) { @@ -224,7 +224,7 @@ type PhoneApiCaller = ( export async function _verifyPhoneNumber( auth: AuthInternal, options: PhoneInfoOptions | string, - verifier: ApplicationVerifierInternal + verifier?: ApplicationVerifierInternal ): Promise { if (!auth._getRecaptchaConfig()) { const enterpriseVerifier = new RecaptchaEnterpriseVerifier(auth); @@ -274,7 +274,7 @@ export async function _verifyPhoneNumber( request.phoneEnrollmentInfo.captchaResponse === FAKE_TOKEN ) { _assert( - verifier.type === RECAPTCHA_VERIFIER_TYPE, + verifier?.type === RECAPTCHA_VERIFIER_TYPE, authInstance, AuthErrorCode.ARGUMENT_ERROR ); @@ -329,14 +329,14 @@ export async function _verifyPhoneNumber( authInstance: AuthInternal, request: StartPhoneMfaSignInRequest ) => { - // If reCAPTCHA Enterprise token is empty or "NO_RECAPTCHA", fetch v2 token and inject into request. + // If reCAPTCHA Enterprise token is empty or "NO_RECAPTCHA", fetch reCAPTCHA 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, + verifier?.type === RECAPTCHA_VERIFIER_TYPE, authInstance, AuthErrorCode.ARGUMENT_ERROR ); @@ -380,14 +380,14 @@ export async function _verifyPhoneNumber( authInstance: AuthInternal, request: SendPhoneVerificationCodeRequest ) => { - // If reCAPTCHA Enterprise token is empty or "NO_RECAPTCHA", fetch v2 token and inject into request. + // If reCAPTCHA Enterprise token is empty or "NO_RECAPTCHA", fetch reCAPTCHA v2 token and inject into request. if ( !request.captchaResponse || request.captchaResponse.length === 0 || request.captchaResponse === FAKE_TOKEN ) { _assert( - verifier.type === RECAPTCHA_VERIFIER_TYPE, + verifier?.type === RECAPTCHA_VERIFIER_TYPE, authInstance, AuthErrorCode.ARGUMENT_ERROR ); @@ -421,7 +421,7 @@ export async function _verifyPhoneNumber( return response.sessionInfo; } } finally { - verifier._reset(); + verifier?._reset(); } } From 4d7edd434388f8d236ecadb5c3d0a1abede0eef5 Mon Sep 17 00:00:00 2001 From: "Nhien (Ricky) Lam" <62775270+NhienLam@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:26:48 -0700 Subject: [PATCH 4/9] Only fetch reCAPTCHA v2 token when FAKE_TOKEN (#8493) --- .../src/platform_browser/strategies/phone.ts | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index e661499fdaa..904854c4dc8 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -267,12 +267,8 @@ export async function _verifyPhoneNumber( 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 - ) { + // If reCAPTCHA Enterprise token is FAKE_TOKEN, fetch reCAPTCHA v2 token and inject into request. + if (request.phoneEnrollmentInfo.captchaResponse === FAKE_TOKEN) { _assert( verifier?.type === RECAPTCHA_VERIFIER_TYPE, authInstance, @@ -329,12 +325,8 @@ export async function _verifyPhoneNumber( authInstance: AuthInternal, request: StartPhoneMfaSignInRequest ) => { - // If reCAPTCHA Enterprise token is empty or "NO_RECAPTCHA", fetch reCAPTCHA v2 token and inject into request. - if ( - !request.phoneSignInInfo.captchaResponse || - request.phoneSignInInfo.captchaResponse.length === 0 || - request.phoneSignInInfo.captchaResponse === FAKE_TOKEN - ) { + // If reCAPTCHA Enterprise token is FAKE_TOKEN, fetch reCAPTCHA v2 token and inject into request. + if (request.phoneSignInInfo.captchaResponse === FAKE_TOKEN) { _assert( verifier?.type === RECAPTCHA_VERIFIER_TYPE, authInstance, @@ -380,12 +372,8 @@ export async function _verifyPhoneNumber( authInstance: AuthInternal, request: SendPhoneVerificationCodeRequest ) => { - // If reCAPTCHA Enterprise token is empty or "NO_RECAPTCHA", fetch reCAPTCHA v2 token and inject into request. - if ( - !request.captchaResponse || - request.captchaResponse.length === 0 || - request.captchaResponse === FAKE_TOKEN - ) { + // If reCAPTCHA Enterprise token is FAKE_TOKEN, fetch reCAPTCHA v2 token and inject into request. + if (request.captchaResponse === FAKE_TOKEN) { _assert( verifier?.type === RECAPTCHA_VERIFIER_TYPE, authInstance, From 4aa9462ab7150cec841f23cb8d42c1dee5d9c285 Mon Sep 17 00:00:00 2001 From: "Nhien (Ricky) Lam" <62775270+NhienLam@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:14:41 -0700 Subject: [PATCH 5/9] Proceed to reCAPTCHA v2 if unable to get reCAPTCHA Enterprise enablement state (#8500) * Proceed to reCAPTCHA v2 if cannot get phone enablement state * nit: Add a missing period. --- .../platform_browser/providers/phone.test.ts | 4 ++-- .../platform_browser/strategies/phone.test.ts | 2 +- .../src/platform_browser/strategies/phone.ts | 17 +++++++++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/auth/src/platform_browser/providers/phone.test.ts b/packages/auth/src/platform_browser/providers/phone.test.ts index 9f555920ef3..752aa2a892d 100644 --- a/packages/auth/src/platform_browser/providers/phone.test.ts +++ b/packages/auth/src/platform_browser/providers/phone.test.ts @@ -64,7 +64,7 @@ describe('platform_browser/providers/phone', () => { context('#verifyPhoneNumber', () => { 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', + // no recaptcha key if no rCE provider is enabled recaptchaEnforcementState: [ { provider: RecaptchaAuthProvider.PHONE_PROVIDER, @@ -111,7 +111,7 @@ describe('platform_browser/providers/phone', () => { it('throws an error if verify without appVerifier when recaptcha enterprise is disabled', async () => { const recaptchaConfigResponseOff = { - recaptchaKey: 'foo/bar/to/site-key', + // no recaptcha key if no rCE provider is enabled recaptchaEnforcementState: [ { provider: RecaptchaAuthProvider.PHONE_PROVIDER, diff --git a/packages/auth/src/platform_browser/strategies/phone.test.ts b/packages/auth/src/platform_browser/strategies/phone.test.ts index b1fa4efebd3..58a5bdaf6ad 100644 --- a/packages/auth/src/platform_browser/strategies/phone.test.ts +++ b/packages/auth/src/platform_browser/strategies/phone.test.ts @@ -83,7 +83,7 @@ const recaptchaConfigResponseAudit = { ] }; const recaptchaConfigResponseOff = { - recaptchaKey: 'foo/bar/to/site-key', + // no recaptcha key if no rCE provider is enabled recaptchaEnforcementState: [ { provider: RecaptchaAuthProvider.PHONE_PROVIDER, diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index 904854c4dc8..bf4c1a35ee4 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -68,9 +68,9 @@ import { _castAuth } from '../../core/auth/auth_impl'; import { getModularInstance } from '@firebase/util'; import { ProviderId } from '../../model/enums'; import { - RecaptchaEnterpriseVerifier, FAKE_TOKEN, - handleRecaptchaFlow + handleRecaptchaFlow, + _initializeRecaptchaConfig } from '../recaptcha/recaptcha_enterprise_verifier'; import { _isFirebaseServerApp } from '@firebase/app'; @@ -227,8 +227,17 @@ export async function _verifyPhoneNumber( verifier?: ApplicationVerifierInternal ): Promise { if (!auth._getRecaptchaConfig()) { - const enterpriseVerifier = new RecaptchaEnterpriseVerifier(auth); - await enterpriseVerifier.verify(); + try { + await _initializeRecaptchaConfig(auth); + } catch (error) { + // If an error occurs while fetching the config, there is no way to know the enablement state + // of Phone provider, so we proceed with recaptcha V2 verification. + // The error is likely "recaptchaKey undefined", as reCAPTCHA Enterprise is not + // enabled for any provider. + console.log( + 'Failed to initialize reCAPTCHA Enterprise config. Triggering the reCAPTCHA v2 verification.' + ); + } } try { From f7223fca79016766b0ea942f9ffc641148af0dc5 Mon Sep 17 00:00:00 2001 From: "Nhien (Ricky) Lam" <62775270+NhienLam@users.noreply.github.com> Date: Tue, 1 Oct 2024 09:45:56 -0700 Subject: [PATCH 6/9] Add integration tests for rCE ENFORCE (#8538) * Add integration test for rCE ENFORCE * format --- packages/auth/README.md | 33 ++- packages/auth/karma.conf.js | 3 +- .../flows/recaptcha_enterprise.test.ts | 198 ++++++++++++++++++ 3 files changed, 232 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..394f9a9e9a5 --- /dev/null +++ b/packages/auth/test/integration/flows/recaptcha_enterprise.test.ts @@ -0,0 +1,198 @@ +/** + * @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 8a0f6b93473f7d30aee513b148e4a591df5f89c1 Mon Sep 17 00:00:00 2001 From: nhienlam Date: Mon, 14 Oct 2024 13:24:19 -0700 Subject: [PATCH 7/9] Add changeset and refdocs --- .changeset/rare-radios-leave.md | 5 +++++ docs-devsite/auth.md | 4 +++- docs-devsite/auth.phoneauthprovider.md | 2 +- packages/auth/src/platform_browser/providers/phone.ts | 7 ++++--- packages/auth/src/platform_browser/strategies/phone.ts | 7 +++++-- 5 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 .changeset/rare-radios-leave.md diff --git a/.changeset/rare-radios-leave.md b/.changeset/rare-radios-leave.md new file mode 100644 index 00000000000..8e24b95e7d9 --- /dev/null +++ b/.changeset/rare-radios-leave.md @@ -0,0 +1,5 @@ +--- +'@firebase/auth': minor +--- + +Added reCAPTCHA Enterprise support for app verification during phone authentication diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 308badfc946..e8d11fbfb0b 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -923,7 +923,9 @@ Asynchronously signs in using a phone number. This method sends a code via SMS to the given phone number, and returns a [ConfirmationResult](./auth.confirmationresult.md#confirmationresult_interface). After the user provides the code sent to their phone, call [ConfirmationResult.confirm()](./auth.confirmationresult.md#confirmationresultconfirm) with the code to sign the user in. -For abuse prevention, this method also requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes a reCAPTCHA-based implementation, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). This function can work on other platforms that do not support the [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class) (like React Native), but you need to use a third-party [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) implementation. +For abuse prevention with reCAPTCHA v2, this method requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes a reCAPTCHA-v2-based implementation, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). This function can work on other platforms that do not support the [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class) (like React Native), but you need to use a third-party [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) implementation. + +For abuse prevention with reCAPTCHA Enterprise, [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) is required in Audit mode but not in Enforce mode. This method does not work in a Node.js environment or with [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). diff --git a/docs-devsite/auth.phoneauthprovider.md b/docs-devsite/auth.phoneauthprovider.md index e64f869303b..125ae197276 100644 --- a/docs-devsite/auth.phoneauthprovider.md +++ b/docs-devsite/auth.phoneauthprovider.md @@ -211,7 +211,7 @@ verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier?: | Parameter | Type | Description | | --- | --- | --- | | phoneOptions | [PhoneInfoOptions](./auth.md#phoneinfooptions) \| string | | -| applicationVerifier | [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) | For abuse prevention, this method also requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes a reCAPTCHA-based implementation, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). | +| applicationVerifier | [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) | For abuse prevention with reCAPTCHA v2, this method requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes a reCAPTCHA-v2-based implementation, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). For abuse prevention with reCAPTCHA Enterprise, [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) is required in Audit mode but not in Enforce mode. | Returns: diff --git a/packages/auth/src/platform_browser/providers/phone.ts b/packages/auth/src/platform_browser/providers/phone.ts index a9b2f253f8a..5919680b8f5 100644 --- a/packages/auth/src/platform_browser/providers/phone.ts +++ b/packages/auth/src/platform_browser/providers/phone.ts @@ -95,9 +95,10 @@ export class PhoneAuthProvider { * * @param phoneInfoOptions - The user's {@link PhoneInfoOptions}. The phone number should be in * E.164 format (e.g. +16505550101). - * @param applicationVerifier - For abuse prevention, this method also requires a - * {@link ApplicationVerifier}. This SDK includes a reCAPTCHA-based implementation, - * {@link RecaptchaVerifier}. + * @param applicationVerifier - For abuse prevention with reCAPTCHA v2, this method requires a + * {@link ApplicationVerifier}. This SDK includes a reCAPTCHA-v2-based implementation, + * {@link RecaptchaVerifier}. For abuse prevention with reCAPTCHA Enterprise, + * {@link ApplicationVerifier} is required in Audit mode but not in Enforce mode. * * @returns A Promise for a verification ID that can be passed to * {@link PhoneAuthProvider.credential} to identify this flow. diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index bf4c1a35ee4..4dd0c5f3a33 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -102,12 +102,15 @@ class ConfirmationResultImpl implements ConfirmationResult { * provides the code sent to their phone, call {@link ConfirmationResult.confirm} * with the code to sign the user in. * - * For abuse prevention, this method also requires a {@link ApplicationVerifier}. - * This SDK includes a reCAPTCHA-based implementation, {@link RecaptchaVerifier}. + * For abuse prevention with reCAPTCHA v2, this method requires a {@link ApplicationVerifier}. + * This SDK includes a reCAPTCHA-v2-based implementation, {@link RecaptchaVerifier}. * This function can work on other platforms that do not support the * {@link RecaptchaVerifier} (like React Native), but you need to use a * third-party {@link ApplicationVerifier} implementation. * + * For abuse prevention with reCAPTCHA Enterprise, {@link ApplicationVerifier} is required in Audit + * mode but not in Enforce mode. + * * This method does not work in a Node.js environment or with {@link Auth} instances created with a * {@link @firebase/app#FirebaseServerApp}. * From 5708c72585902fe95ba8c8178ab7849fc3e0f8ca Mon Sep 17 00:00:00 2001 From: nhienlam Date: Mon, 14 Oct 2024 13:56:34 -0700 Subject: [PATCH 8/9] Bump main firebase package in changeset --- .changeset/rare-radios-leave.md | 5 ----- .changeset/shy-bikes-explain.md | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 .changeset/rare-radios-leave.md create mode 100644 .changeset/shy-bikes-explain.md diff --git a/.changeset/rare-radios-leave.md b/.changeset/rare-radios-leave.md deleted file mode 100644 index 8e24b95e7d9..00000000000 --- a/.changeset/rare-radios-leave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@firebase/auth': minor ---- - -Added reCAPTCHA Enterprise support for app verification during phone authentication diff --git a/.changeset/shy-bikes-explain.md b/.changeset/shy-bikes-explain.md new file mode 100644 index 00000000000..2bf372600ea --- /dev/null +++ b/.changeset/shy-bikes-explain.md @@ -0,0 +1,6 @@ +--- +'@firebase/auth': minor +'firebase': minor +--- + +[feature] Added reCAPTCHA Enterprise support for app verification during phone authentication. From 99e3475eca2cb227945da514d4051acc5ad5f03d Mon Sep 17 00:00:00 2001 From: nhienlam Date: Wed, 16 Oct 2024 15:14:09 -0700 Subject: [PATCH 9/9] Addressed refdocs feedback --- docs-devsite/auth.md | 4 ++-- docs-devsite/auth.phoneauthprovider.md | 2 +- packages/auth/src/platform_browser/providers/phone.ts | 9 +++++---- packages/auth/src/platform_browser/strategies/phone.ts | 8 ++++---- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index e8d11fbfb0b..c417da9110c 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -923,9 +923,9 @@ Asynchronously signs in using a phone number. This method sends a code via SMS to the given phone number, and returns a [ConfirmationResult](./auth.confirmationresult.md#confirmationresult_interface). After the user provides the code sent to their phone, call [ConfirmationResult.confirm()](./auth.confirmationresult.md#confirmationresultconfirm) with the code to sign the user in. -For abuse prevention with reCAPTCHA v2, this method requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes a reCAPTCHA-v2-based implementation, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). This function can work on other platforms that do not support the [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class) (like React Native), but you need to use a third-party [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) implementation. +For abuse prevention, this method requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes an implementation based on reCAPTCHA v2, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). This function can work on other platforms that do not support the [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class) (like React Native), but you need to use a third-party [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) implementation. -For abuse prevention with reCAPTCHA Enterprise, [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) is required in Audit mode but not in Enforce mode. +If you've enabled project-level reCAPTCHA Enterprise bot protection in Enforce mode, you can omit the [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This method does not work in a Node.js environment or with [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). diff --git a/docs-devsite/auth.phoneauthprovider.md b/docs-devsite/auth.phoneauthprovider.md index 125ae197276..e09e0b51caa 100644 --- a/docs-devsite/auth.phoneauthprovider.md +++ b/docs-devsite/auth.phoneauthprovider.md @@ -211,7 +211,7 @@ verifyPhoneNumber(phoneOptions: PhoneInfoOptions | string, applicationVerifier?: | Parameter | Type | Description | | --- | --- | --- | | phoneOptions | [PhoneInfoOptions](./auth.md#phoneinfooptions) \| string | | -| applicationVerifier | [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) | For abuse prevention with reCAPTCHA v2, this method requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes a reCAPTCHA-v2-based implementation, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). For abuse prevention with reCAPTCHA Enterprise, [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) is required in Audit mode but not in Enforce mode. | +| applicationVerifier | [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) | An [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface), which prevents requests from unauthorized clients. This SDK includes an implementation based on reCAPTCHA v2, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). If you've enabled reCAPTCHA Enterprise bot protection in Enforce mode, this parameter is optional; in all other configurations, the parameter is required. | Returns: diff --git a/packages/auth/src/platform_browser/providers/phone.ts b/packages/auth/src/platform_browser/providers/phone.ts index 5919680b8f5..f67d82e1792 100644 --- a/packages/auth/src/platform_browser/providers/phone.ts +++ b/packages/auth/src/platform_browser/providers/phone.ts @@ -95,10 +95,11 @@ export class PhoneAuthProvider { * * @param phoneInfoOptions - The user's {@link PhoneInfoOptions}. The phone number should be in * E.164 format (e.g. +16505550101). - * @param applicationVerifier - For abuse prevention with reCAPTCHA v2, this method requires a - * {@link ApplicationVerifier}. This SDK includes a reCAPTCHA-v2-based implementation, - * {@link RecaptchaVerifier}. For abuse prevention with reCAPTCHA Enterprise, - * {@link ApplicationVerifier} is required in Audit mode but not in Enforce mode. + * @param applicationVerifier - An {@link ApplicationVerifier}, which prevents + * requests from unauthorized clients. This SDK includes an implementation + * based on reCAPTCHA v2, {@link RecaptchaVerifier}. If you've enabled + * reCAPTCHA Enterprise bot protection in Enforce mode, this parameter is + * optional; in all other configurations, the parameter is required. * * @returns A Promise for a verification ID that can be passed to * {@link PhoneAuthProvider.credential} to identify this flow. diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index 4dd0c5f3a33..dc6bf1ff788 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -102,14 +102,14 @@ class ConfirmationResultImpl implements ConfirmationResult { * provides the code sent to their phone, call {@link ConfirmationResult.confirm} * with the code to sign the user in. * - * For abuse prevention with reCAPTCHA v2, this method requires a {@link ApplicationVerifier}. - * This SDK includes a reCAPTCHA-v2-based implementation, {@link RecaptchaVerifier}. + * For abuse prevention, this method requires a {@link ApplicationVerifier}. + * This SDK includes an implementation based on reCAPTCHA v2, {@link RecaptchaVerifier}. * This function can work on other platforms that do not support the * {@link RecaptchaVerifier} (like React Native), but you need to use a * third-party {@link ApplicationVerifier} implementation. * - * For abuse prevention with reCAPTCHA Enterprise, {@link ApplicationVerifier} is required in Audit - * mode but not in Enforce mode. + * If you've enabled project-level reCAPTCHA Enterprise bot protection in + * Enforce mode, you can omit the {@link ApplicationVerifier}. * * This method does not work in a Node.js environment or with {@link Auth} instances created with a * {@link @firebase/app#FirebaseServerApp}.