From d874772a54573b877775de83b42ab2ee16ca8571 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 01/11] 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 --- .../recaptcha/recaptcha_enterprise_verifier.ts | 3 +++ 1 file changed, 3 insertions(+) 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..b7282cd44c8 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts @@ -33,6 +33,9 @@ import { AuthErrorCode } from '../../core/errors'; import { StartPhoneMfaEnrollmentRequest } from '../../api/account_management/mfa'; import { StartPhoneMfaSignInRequest } from '../../api/authentication/mfa'; +const RECAPTCHA_ENTERPRISE_URL = + 'https://www.google.com/recaptcha/enterprise.js?render='; + export const RECAPTCHA_ENTERPRISE_VERIFIER_TYPE = 'recaptcha-enterprise'; export const FAKE_TOKEN = 'NO_RECAPTCHA'; From 14ef5dd8cdb79455bc53d34cbd6412076b5f57cb Mon Sep 17 00:00:00 2001 From: nhienlam Date: Wed, 22 Nov 2023 14:45:38 -0800 Subject: [PATCH 02/11] Implement reCAPTCHA Enterprise flow for phone provider --- common/api-review/auth.api.md | 8 +- packages/auth/src/core/credentials/email.ts | 12 +- .../src/core/strategies/email_and_password.ts | 12 +- .../auth/src/core/strategies/email_link.ts | 9 +- .../platform_browser/providers/phone.test.ts | 101 ++- .../src/platform_browser/providers/phone.ts | 2 +- .../recaptcha_enterprise_verifier.test.ts | 170 ++++- .../recaptcha_enterprise_verifier.ts | 128 +++- .../platform_browser/strategies/phone.test.ts | 595 ++++++++++++++++-- .../src/platform_browser/strategies/phone.ts | 292 ++++++++- 10 files changed, 1187 insertions(+), 142 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/packages/auth/src/core/credentials/email.ts b/packages/auth/src/core/credentials/email.ts index 4a3186ef2a4..ddff9ea39a4 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, + RecaptchaProvider +} 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, + RecaptchaProvider.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, + RecaptchaProvider.EMAIL_PASSWORD_PROVIDER ); case SignInMethod.EMAIL_LINK: return signInWithEmailLinkForLinking(auth, { diff --git a/packages/auth/src/core/strategies/email_and_password.ts b/packages/auth/src/core/strategies/email_and_password.ts index 473b3800eac..944234313cf 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, + RecaptchaProvider +} from '../../api'; import { _isFirebaseServerApp } from '@firebase/app'; /** @@ -116,7 +120,8 @@ export async function sendPasswordResetEmail( authInternal, request, RecaptchaActionName.GET_OOB_CODE, - authentication.sendPasswordResetEmail + authentication.sendPasswordResetEmail, + RecaptchaProvider.EMAIL_PASSWORD_PROVIDER ); } @@ -290,7 +295,8 @@ export async function createUserWithEmailAndPassword( authInternal, request, RecaptchaActionName.SIGN_UP_PASSWORD, - signUp + signUp, + RecaptchaProvider.EMAIL_PASSWORD_PROVIDER ); const response = await signUpResponse.catch(error => { if ( diff --git a/packages/auth/src/core/strategies/email_link.ts b/packages/auth/src/core/strategies/email_link.ts index 351583a6bb5..1201e8a234c 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, + RecaptchaProvider +} 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, + RecaptchaProvider.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..979946614fa 100644 --- a/packages/auth/src/platform_browser/providers/phone.test.ts +++ b/packages/auth/src/platform_browser/providers/phone.test.ts @@ -18,12 +18,23 @@ 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, + RecaptchaProvider, + 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'; describe('platform_browser/providers/phone', () => { let auth: TestAuth; @@ -39,26 +50,102 @@ 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: RecaptchaProvider.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( + const v2Verifier = new RecaptchaVerifier( auth, document.createElement('div'), {} ); sinon - .stub(verifier, 'verify') + .stub(v2Verifier, 'verify') .returns(Promise.resolve('verification-code')); 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: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('calls the server without appVerifier when recaptcha enterprise is enabled', async () => { + const recaptchaConfigResponseEnforce = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaProvider.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', - 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..485c643fa5b 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/recaptcha/recaptcha_enterprise_verifier.test.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.test.ts index 3b351a8fac7..ae04def5e5c 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 @@ -82,6 +82,22 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { ] }; const recaptchaConfigOff = new RecaptchaConfig(recaptchaConfigResponseOff); + const recaptchaConfigResponseAudit = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaProvider.EMAIL_PASSWORD_PROVIDER, + enforcementState: EnforcementState.AUDIT + }, + { + provider: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.AUDIT + } + ] + }; + const recaptchaConfigAudit = new RecaptchaConfig( + recaptchaConfigResponseAudit + ); const getRecaptchaConfigRequest = { clientType: RecaptchaClientType.WEB, @@ -150,7 +166,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { }); }); - context('handleRecaptchaFlow', () => { + context('#handleRecaptchaFlow', () => { let mockAuthInstance: AuthInternal; let mockRequest: any; let mockActionMethod: sinon.SinonStub; @@ -165,7 +181,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { 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; } @@ -181,14 +197,15 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockAuthInstance, mockRequest, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, - mockActionMethod + mockActionMethod, + RecaptchaProvider.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; } @@ -214,13 +231,14 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockAuthInstance, mockRequest, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, - mockActionMethod + mockActionMethod, + RecaptchaProvider.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; } @@ -247,13 +265,139 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockAuthInstance, mockRequest, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, - mockActionMethod + mockActionMethod, + RecaptchaProvider.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); + sinon + .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') + .resolves('recaptcha-response'); + mockRequest = { foo: 'bar' }; + mockActionMethod = sinon.stub().resolves('testResponse'); + const response = await handleRecaptchaFlow( + mockAuthInstance, + mockRequest, + RecaptchaActionName.SEND_VERIFICATION_CODE, + mockActionMethod, + RecaptchaProvider.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); + sinon + .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') + .resolves('recaptcha-response'); + mockRequest = { foo: 'bar' }; + 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, + RecaptchaProvider.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); + sinon + .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') + .resolves('recaptcha-response'); + mockRequest = { foo: 'bar' }; + 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, + RecaptchaProvider.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); + sinon + .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') + .resolves('recaptcha-response'); + mockRequest = { foo: 'bar' }; + 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, + RecaptchaProvider.PHONE_PROVIDER + ); + await expect(response).to.be.rejectedWith( + AuthErrorCode.INVALID_RECAPTCHA_TOKEN + ); + expect(mockActionMethod).to.have.been.calledOnce; + }); }); context('#injectRecaptchaFields', () => { @@ -337,7 +481,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 +495,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 +519,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 +533,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 b7282cd44c8..94183ca886f 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 + RecaptchaProvider, + EnforcementState } from '../../api'; import { Auth } from '../../model/public_types'; @@ -229,7 +230,7 @@ export async function injectRecaptchaFields( } type ActionMethod = ( - auth: Auth, + auth: AuthInternal, request: TRequest ) => Promise; @@ -237,37 +238,102 @@ export async function handleRecaptchaFlow( authInstance: AuthInternal, request: TRequest, actionName: RecaptchaActionName, - actionMethod: ActionMethod + actionMethod: ActionMethod, + recaptchaProvider: RecaptchaProvider ): 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 (recaptchaProvider === RecaptchaProvider.EMAIL_PASSWORD_PROVIDER) { + if ( + authInstance + ._getRecaptchaConfig() + ?.isProviderEnabled(RecaptchaProvider.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 (recaptchaProvider === RecaptchaProvider.PHONE_PROVIDER) { + if ( + authInstance + ._getRecaptchaConfig() + ?.isProviderEnabled(RecaptchaProvider.PHONE_PROVIDER) + ) { + const requestWithRecaptcha = await injectRecaptchaFields( + authInstance, + request, + actionName + ); + + return actionMethod(authInstance, requestWithRecaptcha).catch( + async error => { + if ( + authInstance + ._getRecaptchaConfig() + ?.getProviderEnforcementState( + RecaptchaProvider.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(recaptchaProvider + ' provider is not supported.'); } } diff --git a/packages/auth/src/platform_browser/strategies/phone.test.ts b/packages/auth/src/platform_browser/strategies/phone.test.ts index c545a84f11a..8c1195950e8 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, + RecaptchaProvider, + EnforcementState +} from '../../api'; import { MultiFactorInfoImpl } from '../../mfa/mfa_info'; import { MultiFactorSessionImpl } from '../../mfa/mfa_session'; import { multiFactor, MultiFactorUserImpl } from '../../mfa/mfa_user'; @@ -36,20 +46,87 @@ 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: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.ENFORCE + } + ] +}; +const recaptchaConfigResponseAudit = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaProvider.PHONE_PROVIDER, + enforcementState: EnforcementState.AUDIT + } + ] +}; +const recaptchaConfigResponseOff = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: RecaptchaProvider.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 () => { @@ -60,8 +137,11 @@ describe('platform_browser/strategies/phone', () => { 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 +150,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'); 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 +211,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 +236,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 +249,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 +319,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 +343,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_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({ - 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 () => { + 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 +411,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 +424,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 +441,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 +453,178 @@ 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('works without v2 verifier 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('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 +638,10 @@ 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' @@ -332,19 +651,53 @@ 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' + recaptchaToken: RECAPTCHA_V2_TOKEN, + captchaResponse: FAKE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE } }); }); - it('works when completing the sign in flow', async () => { + 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' + } + }); + 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', + captchaResponse: RECAPTCHA_ENTERPRISE_TOKEN, + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + } + }); + }); + + 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 +717,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 +795,26 @@ describe('platform_browser/strategies/phone', () => { }); it('resets the verifer after successful verification', async () => { - sinon.spy(verifier, '_reset'); - expect(await _verifyPhoneNumber(auth, 'number', verifier)).to.eq( + if (typeof window === 'undefined') { + return; + } + sinon.spy(v2Verifier, '_reset'); + expect(await _verifyPhoneNumber(auth, 'number', v2Verifier)).to.eq( 'session-info' ); - expect(verifier._reset).to.have.been.called; + expect(v2Verifier._reset).to.have.been.called; }); it('resets the verifer after a failed verification', async () => { - sinon.spy(verifier, '_reset'); - (verifier.verify as sinon.SinonStub).returns(Promise.resolve(123)); - - await expect(_verifyPhoneNumber(auth, 'number', verifier)).to.be.rejected; - expect(verifier._reset).to.have.been.called; + if (typeof window === 'undefined') { + return; + } + sinon.spy(v2Verifier, '_reset'); + (v2Verifier.verify as sinon.SinonStub).returns(Promise.resolve(123)); + + await expect(_verifyPhoneNumber(auth, 'number', v2Verifier)).to.be + .rejected; + expect(v2Verifier._reset).to.have.been.called; }); }); @@ -455,4 +862,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..51e63240ee8 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, + RecaptchaProvider +} from '../../api'; import { ApplicationVerifierInternal } from '../../model/application_verifier'; import { PhoneAuthCredential } from '../../core/credentials/phone'; import { AuthErrorCode } from '../../core/errors'; @@ -50,6 +67,12 @@ 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, + RECAPTCHA_ENTERPRISE_VERIFIER_TYPE, + FAKE_TOKEN, + handleRecaptchaFlow +} from '../recaptcha/recaptcha_enterprise_verifier'; import { _isFirebaseServerApp } from '@firebase/app'; interface OnConfirmationCallback { @@ -107,7 +130,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( @@ -140,7 +163,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); @@ -172,7 +195,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)) { @@ -190,6 +213,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. * @@ -197,22 +225,23 @@ export async function reauthenticateWithPhoneNumber( export async function _verifyPhoneNumber( auth: AuthInternal, options: PhoneInfoOptions | string, - verifier: ApplicationVerifierInternal + verifier?: ApplicationVerifierInternal ): Promise { - const recaptchaToken = await verifier.verify(); + const enterpriseVerifier = new RecaptchaEnterpriseVerifier(auth); + const recaptchaEnterpriseToken = await enterpriseVerifier.verify(); - try { - _assert( - typeof recaptchaToken === 'string', - auth, - AuthErrorCode.ARGUMENT_ERROR - ); - _assert( - verifier.type === RECAPTCHA_VERIFIER_TYPE, - auth, - AuthErrorCode.ARGUMENT_ERROR - ); + _assert( + typeof recaptchaEnterpriseToken === 'string', + auth, + AuthErrorCode.ARGUMENT_ERROR + ); + _assert( + enterpriseVerifier.type === RECAPTCHA_ENTERPRISE_VERIFIER_TYPE, + auth, + AuthErrorCode.ARGUMENT_ERROR + ); + try { let phoneInfoOptions: PhoneInfoOptions; if (typeof options === 'string') { @@ -232,13 +261,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, + RecaptchaProvider.PHONE_PROVIDER + ); + + const response = await startPhoneMfaEnrollmentResponse.catch(error => { + return Promise.reject(error); }); + return response.phoneSessionInfo.sessionInfo; } else { _assert( @@ -250,24 +323,115 @@ 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, + RecaptchaProvider.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, + RecaptchaProvider.PHONE_PROVIDER + ); + + const response = await sendPhoneVerificationCodeResponse.catch(error => { + return Promise.reject(error); }); - return sessionInfo; + + return response.sessionInfo; } } finally { - verifier._reset(); + verifier?._reset(); } } @@ -306,3 +470,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 ae619b0429c12c9df5f13a6eff7e05442fc2b43e Mon Sep 17 00:00:00 2001 From: nhienlam Date: Wed, 22 Nov 2023 15:43:40 -0800 Subject: [PATCH 03/11] Cleanup tests --- .../recaptcha_enterprise_verifier.test.ts | 33 +++---------------- 1 file changed, 4 insertions(+), 29 deletions(-) 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 ae04def5e5c..0316c77fca0 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 @@ -173,8 +173,11 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { beforeEach(async () => { mockAuthInstance = await testAuth(); - mockRequest = {}; + mockRequest = { foo: 'bar' }; mockActionMethod = sinon.stub(); + sinon + .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') + .resolves('recaptcha-response'); }); afterEach(() => { @@ -188,10 +191,6 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { 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, @@ -212,10 +211,6 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { 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++; @@ -245,10 +240,6 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { 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++; @@ -281,10 +272,6 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { 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, @@ -304,10 +291,6 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { sinon .stub(mockAuthInstance, '_getRecaptchaConfig') .returns(recaptchaConfigAudit); - sinon - .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') - .resolves('recaptcha-response'); - mockRequest = { foo: 'bar' }; let callCount = 0; mockActionMethod = sinon.stub().callsFake(() => { callCount++; @@ -337,10 +320,6 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { sinon .stub(mockAuthInstance, '_getRecaptchaConfig') .returns(recaptchaConfigAudit); - sinon - .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') - .resolves('recaptcha-response'); - mockRequest = { foo: 'bar' }; let callCount = 0; mockActionMethod = sinon.stub().callsFake(() => { callCount++; @@ -370,10 +349,6 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { sinon .stub(mockAuthInstance, '_getRecaptchaConfig') .returns(recaptchaConfigAudit); - sinon - .stub(RecaptchaEnterpriseVerifier.prototype, 'verify') - .resolves('recaptcha-response'); - mockRequest = { foo: 'bar' }; let callCount = 0; mockActionMethod = sinon.stub().callsFake(() => { callCount++; From b721ee73dcc93a1476aa212c63a1f0e891a2f2fb Mon Sep 17 00:00:00 2001 From: nhienlam Date: Mon, 4 Dec 2023 10:06:23 -0800 Subject: [PATCH 04/11] Make recaptchaEnterpriseVerifier.verify return a mock when appVerificationDisabledForTesting is true --- .../auth/src/core/credentials/email.test.ts | 1 + .../core/strategies/email_and_password.test.ts | 1 + .../auth/src/core/strategies/email_link.test.ts | 1 + .../platform_browser/providers/phone.test.ts | 1 + .../recaptcha_enterprise_verifier.test.ts | 1 + .../recaptcha/recaptcha_enterprise_verifier.ts | 7 +++++++ .../platform_browser/strategies/phone.test.ts | 1 + .../src/platform_browser/strategies/phone.ts | 17 ++++------------- 8 files changed, 17 insertions(+), 13 deletions(-) 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/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_link.test.ts b/packages/auth/src/core/strategies/email_link.test.ts index 7358b5a3512..7a3bbb7f346 100644 --- a/packages/auth/src/core/strategies/email_link.test.ts +++ b/packages/auth/src/core/strategies/email_link.test.ts @@ -58,6 +58,7 @@ describe('core/strategies/sendSignInLinkToEmail', () => { beforeEach(async () => { auth = await testAuth(); + auth.settings.appVerificationDisabledForTesting = false; mockFetch.setUp(); }); diff --git a/packages/auth/src/platform_browser/providers/phone.test.ts b/packages/auth/src/platform_browser/providers/phone.test.ts index 979946614fa..363f0a8638b 100644 --- a/packages/auth/src/platform_browser/providers/phone.test.ts +++ b/packages/auth/src/platform_browser/providers/phone.test.ts @@ -42,6 +42,7 @@ describe('platform_browser/providers/phone', () => { beforeEach(async () => { fetch.setUp(); auth = await testAuth(); + auth.settings.appVerificationDisabledForTesting = false; }); afterEach(() => { 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 0316c77fca0..85ad6fc5620 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 @@ -108,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(); 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 94183ca886f..32bfff8ddd4 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts @@ -33,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'; const RECAPTCHA_ENTERPRISE_URL = 'https://www.google.com/recaptcha/enterprise.js?render='; @@ -125,6 +126,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 => { diff --git a/packages/auth/src/platform_browser/strategies/phone.test.ts b/packages/auth/src/platform_browser/strategies/phone.test.ts index 8c1195950e8..9b46f41c761 100644 --- a/packages/auth/src/platform_browser/strategies/phone.test.ts +++ b/packages/auth/src/platform_browser/strategies/phone.test.ts @@ -131,6 +131,7 @@ describe('platform_browser/strategies/phone', () => { beforeEach(async () => { auth = await testAuth(); + auth.settings.appVerificationDisabledForTesting = false; fetch.setUp(); sendCodeEndpoint = mockEndpoint(Endpoint.SEND_VERIFICATION_CODE, { diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index 51e63240ee8..cadacb7c1ab 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -227,19 +227,10 @@ export async function _verifyPhoneNumber( options: PhoneInfoOptions | string, verifier?: ApplicationVerifierInternal ): Promise { - const enterpriseVerifier = new RecaptchaEnterpriseVerifier(auth); - const recaptchaEnterpriseToken = await enterpriseVerifier.verify(); - - _assert( - typeof recaptchaEnterpriseToken === 'string', - auth, - AuthErrorCode.ARGUMENT_ERROR - ); - _assert( - enterpriseVerifier.type === RECAPTCHA_ENTERPRISE_VERIFIER_TYPE, - auth, - AuthErrorCode.ARGUMENT_ERROR - ); + if (!auth._getRecaptchaConfig()) { + const enterpriseVerifier = new RecaptchaEnterpriseVerifier(auth); + await enterpriseVerifier.verify(); + } try { let phoneInfoOptions: PhoneInfoOptions; From 91886e07497a0ded768725cc595b633ee4749012 Mon Sep 17 00:00:00 2001 From: nhienlam Date: Mon, 4 Dec 2023 10:17:16 -0800 Subject: [PATCH 05/11] Lint fix --- packages/auth/src/platform_browser/strategies/phone.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index cadacb7c1ab..142b66a876f 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -69,7 +69,6 @@ import { getModularInstance } from '@firebase/util'; import { ProviderId } from '../../model/enums'; import { RecaptchaEnterpriseVerifier, - RECAPTCHA_ENTERPRISE_VERIFIER_TYPE, FAKE_TOKEN, handleRecaptchaFlow } from '../recaptcha/recaptcha_enterprise_verifier'; From 233c31f1b5a734181c2bbe14a77e15bc319aac39 Mon Sep 17 00:00:00 2001 From: nhienlam Date: Mon, 4 Dec 2023 10:29:28 -0800 Subject: [PATCH 06/11] yarn docgen devsite --- docs-devsite/auth.md | 6 +++--- docs-devsite/auth.phoneauthprovider.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 10f153f53fe..d4233afab64 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 44bd44b53ba..cd2a97f6d76 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 From 971870c91d38079a63f7bc501a3a12536250e7d3 Mon Sep 17 00:00:00 2001 From: nhienlam Date: Wed, 27 Mar 2024 17:43:04 -0700 Subject: [PATCH 07/11] Mark appVerifier param in Phone Auth APIs as required --- common/api-review/auth.api.md | 36 +++++++++------- docs-devsite/auth.md | 6 +-- docs-devsite/auth.phoneauthprovider.md | 2 +- .../platform_browser/providers/phone.test.ts | 43 ------------------- .../src/platform_browser/providers/phone.ts | 4 +- .../platform_browser/strategies/phone.test.ts | 17 +------- .../src/platform_browser/strategies/phone.ts | 16 +++---- 7 files changed, 36 insertions(+), 88 deletions(-) diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index b011b803774..89a71e26b7b 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -104,14 +104,14 @@ export class AuthCredential { protected constructor( providerId: string, signInMethod: string); - // Warning: (ae-forgotten-export) The symbol "AuthInternal" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "PhoneOrOauthTokenResponse" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "AuthInternal" needs to be exported by the entry point index.doc.d.ts + // Warning: (ae-forgotten-export) The symbol "PhoneOrOauthTokenResponse" needs to be exported by the entry point index.doc.d.ts // // @internal (undocumented) _getIdTokenResponse(_auth: AuthInternal): Promise; // @internal (undocumented) _getReauthenticationResolver(_auth: AuthInternal): Promise; - // Warning: (ae-forgotten-export) The symbol "IdTokenResponse" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "IdTokenResponse" needs to be exported by the entry point index.doc.d.ts // // @internal (undocumented) _linkToIdToken(_auth: AuthInternal, _idToken: string): Promise; @@ -293,6 +293,9 @@ export function connectAuthEmulator(auth: Auth, url: string, options?: { disableWarnings: boolean; }): void; +// @public +export const cordovaPopupRedirectResolver: PopupRedirectResolver; + // @public export function createUserWithEmailAndPassword(auth: Auth, email: string, password: string): Promise; @@ -356,7 +359,7 @@ export interface EmulatorConfig { export { ErrorFn } -// Warning: (ae-forgotten-export) The symbol "BaseOAuthProvider" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "BaseOAuthProvider" needs to be exported by the entry point index.doc.d.ts // // @public export class FacebookAuthProvider extends BaseOAuthProvider { @@ -392,6 +395,9 @@ export function getIdTokenResult(user: User, forceRefresh?: boolean): Promise; @@ -445,7 +451,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; @@ -504,7 +510,7 @@ export type NextOrObserver = NextFn | Observer; export class OAuthCredential extends AuthCredential { accessToken?: string; static fromJSON(json: string | object): OAuthCredential | null; - // Warning: (ae-forgotten-export) The symbol "OAuthCredentialParams" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "OAuthCredentialParams" needs to be exported by the entry point index.doc.d.ts // // @internal (undocumented) static _fromParams(params: OAuthCredentialParams): OAuthCredential; @@ -609,7 +615,7 @@ export class PhoneAuthCredential extends AuthCredential { _getReauthenticationResolver(auth: AuthInternal): Promise; // @internal (undocumented) _linkToIdToken(auth: AuthInternal, idToken: string): Promise; - // Warning: (ae-forgotten-export) The symbol "SignInWithPhoneNumberRequest" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "SignInWithPhoneNumberRequest" needs to be exported by the entry point index.doc.d.ts // // @internal (undocumented) _makeVerificationRequest(): SignInWithPhoneNumberRequest; @@ -625,7 +631,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 +698,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; @@ -706,13 +712,13 @@ export interface RecaptchaParameters { [key: string]: any; } -// Warning: (ae-forgotten-export) The symbol "ApplicationVerifierInternal" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "ApplicationVerifierInternal" needs to be exported by the entry point index.doc.d.ts // // @public export class RecaptchaVerifier implements ApplicationVerifierInternal { constructor(authExtern: Auth, containerOrId: HTMLElement | string, parameters?: RecaptchaParameters); clear(): void; - // Warning: (ae-forgotten-export) The symbol "ReCaptchaLoader" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "ReCaptchaLoader" needs to be exported by the entry point index.doc.d.ts // // @internal (undocumented) readonly _recaptchaLoader: ReCaptchaLoader; @@ -729,7 +735,7 @@ export function reload(user: User): Promise; // @public export function revokeAccessToken(auth: Auth, token: string): Promise; -// Warning: (ae-forgotten-export) The symbol "FederatedAuthProvider" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FederatedAuthProvider" needs to be exported by the entry point index.doc.d.ts // // @public export class SAMLAuthProvider extends FederatedAuthProvider { @@ -778,7 +784,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; @@ -810,13 +816,13 @@ export class TotpSecret { readonly codeIntervalSeconds: number; readonly codeLength: number; readonly enrollmentCompletionDeadline: string; - // Warning: (ae-forgotten-export) The symbol "StartTotpMfaEnrollmentResponse" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "StartTotpMfaEnrollmentResponse" needs to be exported by the entry point index.doc.d.ts // // @internal (undocumented) static _fromStartTotpMfaEnrollmentResponse(response: StartTotpMfaEnrollmentResponse, auth: AuthInternal): TotpSecret; generateQrCodeUrl(accountName?: string, issuer?: string): string; readonly hashingAlgorithm: string; - // Warning: (ae-forgotten-export) The symbol "TotpVerificationInfo" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "TotpVerificationInfo" needs to be exported by the entry point index.doc.d.ts // // @internal (undocumented) _makeTotpVerificationInfo(otp: string): TotpVerificationInfo; diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index d4233afab64..10f153f53fe 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 cd2a97f6d76..44bd44b53ba 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/platform_browser/providers/phone.test.ts b/packages/auth/src/platform_browser/providers/phone.test.ts index 363f0a8638b..c4cac90e3db 100644 --- a/packages/auth/src/platform_browser/providers/phone.test.ts +++ b/packages/auth/src/platform_browser/providers/phone.test.ts @@ -106,49 +106,6 @@ describe('platform_browser/providers/phone', () => { recaptchaVersion: RecaptchaVersion.ENTERPRISE }); }); - - it('calls the server without appVerifier when recaptcha enterprise is enabled', async () => { - const recaptchaConfigResponseEnforce = { - recaptchaKey: 'foo/bar/to/site-key', - recaptchaEnforcementState: [ - { - provider: RecaptchaProvider.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 - }); - }); }); context('.credential', () => { diff --git a/packages/auth/src/platform_browser/providers/phone.ts b/packages/auth/src/platform_browser/providers/phone.ts index 485c643fa5b..82b05385796 100644 --- a/packages/auth/src/platform_browser/providers/phone.ts +++ b/packages/auth/src/platform_browser/providers/phone.ts @@ -100,11 +100,11 @@ 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, - 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 9b46f41c761..3ccd7092711 100644 --- a/packages/auth/src/platform_browser/strategies/phone.test.ts +++ b/packages/auth/src/platform_browser/strategies/phone.test.ts @@ -171,7 +171,7 @@ describe('platform_browser/strategies/phone', () => { return; } mockRecaptchaEnterpriseEnablement(EnforcementState.ENFORCE); - await signInWithPhoneNumber(auth, '+15105550000'); + await signInWithPhoneNumber(auth, '+15105550000', v2Verifier); expect(sendCodeEndpoint.calls[0].request).to.eql({ phoneNumber: '+15105550000', @@ -504,21 +504,6 @@ describe('platform_browser/strategies/phone', () => { }); }); - it('works without v2 verifier 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('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 142b66a876f..bf00abf7ded 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 ); @@ -336,7 +336,7 @@ export async function _verifyPhoneNumber( request.phoneSignInInfo.captchaResponse === FAKE_TOKEN ) { _assert( - verifier?.type === RECAPTCHA_VERIFIER_TYPE, + verifier.type === RECAPTCHA_VERIFIER_TYPE, authInstance, AuthErrorCode.ARGUMENT_ERROR ); @@ -387,7 +387,7 @@ export async function _verifyPhoneNumber( 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 124fbb3500c735be4d99bf69f6208549cd8e4379 Mon Sep 17 00:00:00 2001 From: NhienLam Date: Thu, 28 Mar 2024 01:00:25 +0000 Subject: [PATCH 08/11] Update API reports --- common/api-review/auth.api.md | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index 89a71e26b7b..8e915daf731 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -104,14 +104,14 @@ export class AuthCredential { protected constructor( providerId: string, signInMethod: string); - // Warning: (ae-forgotten-export) The symbol "AuthInternal" needs to be exported by the entry point index.doc.d.ts - // Warning: (ae-forgotten-export) The symbol "PhoneOrOauthTokenResponse" needs to be exported by the entry point index.doc.d.ts + // Warning: (ae-forgotten-export) The symbol "AuthInternal" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "PhoneOrOauthTokenResponse" needs to be exported by the entry point index.d.ts // // @internal (undocumented) _getIdTokenResponse(_auth: AuthInternal): Promise; // @internal (undocumented) _getReauthenticationResolver(_auth: AuthInternal): Promise; - // Warning: (ae-forgotten-export) The symbol "IdTokenResponse" needs to be exported by the entry point index.doc.d.ts + // Warning: (ae-forgotten-export) The symbol "IdTokenResponse" needs to be exported by the entry point index.d.ts // // @internal (undocumented) _linkToIdToken(_auth: AuthInternal, _idToken: string): Promise; @@ -293,9 +293,6 @@ export function connectAuthEmulator(auth: Auth, url: string, options?: { disableWarnings: boolean; }): void; -// @public -export const cordovaPopupRedirectResolver: PopupRedirectResolver; - // @public export function createUserWithEmailAndPassword(auth: Auth, email: string, password: string): Promise; @@ -359,7 +356,7 @@ export interface EmulatorConfig { export { ErrorFn } -// Warning: (ae-forgotten-export) The symbol "BaseOAuthProvider" needs to be exported by the entry point index.doc.d.ts +// Warning: (ae-forgotten-export) The symbol "BaseOAuthProvider" needs to be exported by the entry point index.d.ts // // @public export class FacebookAuthProvider extends BaseOAuthProvider { @@ -395,9 +392,6 @@ export function getIdTokenResult(user: User, forceRefresh?: boolean): Promise; @@ -510,7 +504,7 @@ export type NextOrObserver = NextFn | Observer; export class OAuthCredential extends AuthCredential { accessToken?: string; static fromJSON(json: string | object): OAuthCredential | null; - // Warning: (ae-forgotten-export) The symbol "OAuthCredentialParams" needs to be exported by the entry point index.doc.d.ts + // Warning: (ae-forgotten-export) The symbol "OAuthCredentialParams" needs to be exported by the entry point index.d.ts // // @internal (undocumented) static _fromParams(params: OAuthCredentialParams): OAuthCredential; @@ -615,7 +609,7 @@ export class PhoneAuthCredential extends AuthCredential { _getReauthenticationResolver(auth: AuthInternal): Promise; // @internal (undocumented) _linkToIdToken(auth: AuthInternal, idToken: string): Promise; - // Warning: (ae-forgotten-export) The symbol "SignInWithPhoneNumberRequest" needs to be exported by the entry point index.doc.d.ts + // Warning: (ae-forgotten-export) The symbol "SignInWithPhoneNumberRequest" needs to be exported by the entry point index.d.ts // // @internal (undocumented) _makeVerificationRequest(): SignInWithPhoneNumberRequest; @@ -712,13 +706,13 @@ export interface RecaptchaParameters { [key: string]: any; } -// Warning: (ae-forgotten-export) The symbol "ApplicationVerifierInternal" needs to be exported by the entry point index.doc.d.ts +// Warning: (ae-forgotten-export) The symbol "ApplicationVerifierInternal" needs to be exported by the entry point index.d.ts // // @public export class RecaptchaVerifier implements ApplicationVerifierInternal { constructor(authExtern: Auth, containerOrId: HTMLElement | string, parameters?: RecaptchaParameters); clear(): void; - // Warning: (ae-forgotten-export) The symbol "ReCaptchaLoader" needs to be exported by the entry point index.doc.d.ts + // Warning: (ae-forgotten-export) The symbol "ReCaptchaLoader" needs to be exported by the entry point index.d.ts // // @internal (undocumented) readonly _recaptchaLoader: ReCaptchaLoader; @@ -735,7 +729,7 @@ export function reload(user: User): Promise; // @public export function revokeAccessToken(auth: Auth, token: string): Promise; -// Warning: (ae-forgotten-export) The symbol "FederatedAuthProvider" needs to be exported by the entry point index.doc.d.ts +// Warning: (ae-forgotten-export) The symbol "FederatedAuthProvider" needs to be exported by the entry point index.d.ts // // @public export class SAMLAuthProvider extends FederatedAuthProvider { @@ -816,13 +810,13 @@ export class TotpSecret { readonly codeIntervalSeconds: number; readonly codeLength: number; readonly enrollmentCompletionDeadline: string; - // Warning: (ae-forgotten-export) The symbol "StartTotpMfaEnrollmentResponse" needs to be exported by the entry point index.doc.d.ts + // Warning: (ae-forgotten-export) The symbol "StartTotpMfaEnrollmentResponse" needs to be exported by the entry point index.d.ts // // @internal (undocumented) static _fromStartTotpMfaEnrollmentResponse(response: StartTotpMfaEnrollmentResponse, auth: AuthInternal): TotpSecret; generateQrCodeUrl(accountName?: string, issuer?: string): string; readonly hashingAlgorithm: string; - // Warning: (ae-forgotten-export) The symbol "TotpVerificationInfo" needs to be exported by the entry point index.doc.d.ts + // Warning: (ae-forgotten-export) The symbol "TotpVerificationInfo" needs to be exported by the entry point index.d.ts // // @internal (undocumented) _makeTotpVerificationInfo(otp: string): TotpVerificationInfo; From dfcbe7d30e7ab3b9cc85c113facf5cd801fb2da7 Mon Sep 17 00:00:00 2001 From: nhienlam Date: Thu, 28 Mar 2024 12:14:58 -0700 Subject: [PATCH 09/11] Change RecaptchaProvider to RecaptchaAuthProvider --- packages/auth/src/api/index.ts | 2 +- packages/auth/src/core/credentials/email.ts | 6 ++-- .../src/core/strategies/email_and_password.ts | 6 ++-- .../auth/src/core/strategies/email_link.ts | 4 +-- .../platform_browser/providers/phone.test.ts | 4 +-- .../recaptcha/recaptcha.test.ts | 32 +++++++++---------- .../platform_browser/recaptcha/recaptcha.ts | 6 ++-- .../recaptcha_enterprise_verifier.test.ts | 28 ++++++++-------- .../recaptcha_enterprise_verifier.ts | 21 ++++++------ .../platform_browser/strategies/phone.test.ts | 8 ++--- .../src/platform_browser/strategies/phone.ts | 8 ++--- 11 files changed, 62 insertions(+), 63 deletions(-) diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index f61ba74d1c6..d1cce3161f4 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -100,7 +100,7 @@ export const enum EnforcementState { } // Providers that have reCAPTCHA Enterprise support. -export const enum RecaptchaProvider { +export const enum RecaptchaAuthProvider { EMAIL_PASSWORD_PROVIDER = 'EMAIL_PASSWORD_PROVIDER', PHONE_PROVIDER = 'PHONE_PROVIDER' } diff --git a/packages/auth/src/core/credentials/email.ts b/packages/auth/src/core/credentials/email.ts index ddff9ea39a4..9399296a59d 100644 --- a/packages/auth/src/core/credentials/email.ts +++ b/packages/auth/src/core/credentials/email.ts @@ -35,7 +35,7 @@ import { handleRecaptchaFlow } from '../../platform_browser/recaptcha/recaptcha_ import { RecaptchaActionName, RecaptchaClientType, - RecaptchaProvider + RecaptchaAuthProvider } from '../../api'; import { SignUpRequest } from '../../api/authentication/sign_up'; /** @@ -133,7 +133,7 @@ export class EmailAuthCredential extends AuthCredential { request, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, signInWithPassword, - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); case SignInMethod.EMAIL_LINK: return signInWithEmailLink(auth, { @@ -164,7 +164,7 @@ export class EmailAuthCredential extends AuthCredential { request, RecaptchaActionName.SIGN_UP_PASSWORD, linkEmailPassword, - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); case SignInMethod.EMAIL_LINK: return signInWithEmailLinkForLinking(auth, { diff --git a/packages/auth/src/core/strategies/email_and_password.ts b/packages/auth/src/core/strategies/email_and_password.ts index 944234313cf..7279e65dc6c 100644 --- a/packages/auth/src/core/strategies/email_and_password.ts +++ b/packages/auth/src/core/strategies/email_and_password.ts @@ -44,7 +44,7 @@ import { IdTokenResponse } from '../../model/id_token'; import { RecaptchaActionName, RecaptchaClientType, - RecaptchaProvider + RecaptchaAuthProvider } from '../../api'; import { _isFirebaseServerApp } from '@firebase/app'; @@ -121,7 +121,7 @@ export async function sendPasswordResetEmail( request, RecaptchaActionName.GET_OOB_CODE, authentication.sendPasswordResetEmail, - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); } @@ -296,7 +296,7 @@ export async function createUserWithEmailAndPassword( request, RecaptchaActionName.SIGN_UP_PASSWORD, signUp, - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); const response = await signUpResponse.catch(error => { if ( diff --git a/packages/auth/src/core/strategies/email_link.ts b/packages/auth/src/core/strategies/email_link.ts index 1201e8a234c..0049f1ef95e 100644 --- a/packages/auth/src/core/strategies/email_link.ts +++ b/packages/auth/src/core/strategies/email_link.ts @@ -36,7 +36,7 @@ import { handleRecaptchaFlow } from '../../platform_browser/recaptcha/recaptcha_ import { RecaptchaActionName, RecaptchaClientType, - RecaptchaProvider + RecaptchaAuthProvider } from '../../api'; import { _isFirebaseServerApp } from '@firebase/app'; import { _serverAppCurrentUserOperationNotSupportedError } from '../../core/util/assert'; @@ -113,7 +113,7 @@ export async function sendSignInLinkToEmail( request, RecaptchaActionName.GET_OOB_CODE, api.sendSignInLinkToEmail, - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + 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 c4cac90e3db..5db4b9f2787 100644 --- a/packages/auth/src/platform_browser/providers/phone.test.ts +++ b/packages/auth/src/platform_browser/providers/phone.test.ts @@ -28,7 +28,7 @@ import { Endpoint, RecaptchaClientType, RecaptchaVersion, - RecaptchaProvider, + RecaptchaAuthProvider, EnforcementState } from '../../api'; import { RecaptchaVerifier } from '../../platform_browser/recaptcha/recaptcha_verifier'; @@ -56,7 +56,7 @@ describe('platform_browser/providers/phone', () => { recaptchaKey: 'foo/bar/to/site-key', recaptchaEnforcementState: [ { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.OFF } ] 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 85ad6fc5620..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,11 +72,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.OFF }, { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.OFF } ] @@ -86,11 +86,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.AUDIT }, { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.AUDIT } ] @@ -198,7 +198,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockRequest, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, mockActionMethod, - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); expect(mockActionMethod).to.have.been.calledOnce; expect(response).to.equal('testResponse'); @@ -228,7 +228,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockRequest, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, mockActionMethod, - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); expect(mockActionMethod).to.have.been.calledTwice; expect(response).to.equal('testResponse'); @@ -258,7 +258,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockRequest, RecaptchaActionName.SIGN_IN_WITH_PASSWORD, mockActionMethod, - RecaptchaProvider.EMAIL_PASSWORD_PROVIDER + RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER ); await expect(response).to.be.rejectedWith( AuthErrorCode.RECAPTCHA_NOT_ENABLED @@ -279,7 +279,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockRequest, RecaptchaActionName.SEND_VERIFICATION_CODE, mockActionMethod, - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ); expect(mockActionMethod).to.have.been.calledOnce; expect(response).to.equal('testResponse'); @@ -308,7 +308,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockRequest, RecaptchaActionName.SEND_VERIFICATION_CODE, mockActionMethod, - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ); expect(mockActionMethod).to.have.been.calledTwice; expect(response).to.equal('testResponse'); @@ -337,7 +337,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockRequest, RecaptchaActionName.SEND_VERIFICATION_CODE, mockActionMethod, - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ); expect(mockActionMethod).to.have.been.calledTwice; expect(response).to.equal('testResponse'); @@ -367,7 +367,7 @@ describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { mockRequest, RecaptchaActionName.SEND_VERIFICATION_CODE, mockActionMethod, - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ); await expect(response).to.be.rejectedWith( AuthErrorCode.INVALID_RECAPTCHA_TOKEN 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 32bfff8ddd4..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,7 @@ import { RecaptchaClientType, RecaptchaVersion, RecaptchaActionName, - RecaptchaProvider, + RecaptchaAuthProvider, EnforcementState } from '../../api'; @@ -35,9 +35,6 @@ import { StartPhoneMfaEnrollmentRequest } from '../../api/account_management/mfa import { StartPhoneMfaSignInRequest } from '../../api/authentication/mfa'; import { MockGreCAPTCHATopLevel } from './recaptcha_mock'; -const RECAPTCHA_ENTERPRISE_URL = - 'https://www.google.com/recaptcha/enterprise.js?render='; - export const RECAPTCHA_ENTERPRISE_VERIFIER_TYPE = 'recaptcha-enterprise'; export const FAKE_TOKEN = 'NO_RECAPTCHA'; @@ -246,13 +243,13 @@ export async function handleRecaptchaFlow( request: TRequest, actionName: RecaptchaActionName, actionMethod: ActionMethod, - recaptchaProvider: RecaptchaProvider + recaptchaAuthProvider: RecaptchaAuthProvider ): Promise { - if (recaptchaProvider === RecaptchaProvider.EMAIL_PASSWORD_PROVIDER) { + if (recaptchaAuthProvider === RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER) { if ( authInstance ._getRecaptchaConfig() - ?.isProviderEnabled(RecaptchaProvider.EMAIL_PASSWORD_PROVIDER) + ?.isProviderEnabled(RecaptchaAuthProvider.EMAIL_PASSWORD_PROVIDER) ) { const requestWithRecaptcha = await injectRecaptchaFields( authInstance, @@ -279,11 +276,11 @@ export async function handleRecaptchaFlow( } }); } - } else if (recaptchaProvider === RecaptchaProvider.PHONE_PROVIDER) { + } else if (recaptchaAuthProvider === RecaptchaAuthProvider.PHONE_PROVIDER) { if ( authInstance ._getRecaptchaConfig() - ?.isProviderEnabled(RecaptchaProvider.PHONE_PROVIDER) + ?.isProviderEnabled(RecaptchaAuthProvider.PHONE_PROVIDER) ) { const requestWithRecaptcha = await injectRecaptchaFields( authInstance, @@ -297,7 +294,7 @@ export async function handleRecaptchaFlow( authInstance ._getRecaptchaConfig() ?.getProviderEnforcementState( - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ) === EnforcementState.AUDIT ) { // AUDIT mode @@ -340,7 +337,9 @@ export async function handleRecaptchaFlow( return actionMethod(authInstance, requestWithRecaptchaFields); } } else { - return Promise.reject(recaptchaProvider + ' provider is not supported.'); + 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 3ccd7092711..96d887613d0 100644 --- a/packages/auth/src/platform_browser/strategies/phone.test.ts +++ b/packages/auth/src/platform_browser/strategies/phone.test.ts @@ -35,7 +35,7 @@ import { Endpoint, RecaptchaClientType, RecaptchaVersion, - RecaptchaProvider, + RecaptchaAuthProvider, EnforcementState } from '../../api'; import { MultiFactorInfoImpl } from '../../mfa/mfa_info'; @@ -68,7 +68,7 @@ const recaptchaConfigResponseEnforce = { recaptchaKey: 'foo/bar/to/site-key', recaptchaEnforcementState: [ { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.ENFORCE } ] @@ -77,7 +77,7 @@ const recaptchaConfigResponseAudit = { recaptchaKey: 'foo/bar/to/site-key', recaptchaEnforcementState: [ { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.AUDIT } ] @@ -86,7 +86,7 @@ const recaptchaConfigResponseOff = { recaptchaKey: 'foo/bar/to/site-key', recaptchaEnforcementState: [ { - provider: RecaptchaProvider.PHONE_PROVIDER, + provider: RecaptchaAuthProvider.PHONE_PROVIDER, enforcementState: EnforcementState.OFF } ] diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index bf00abf7ded..a074eca9e7e 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -42,7 +42,7 @@ import { import { RecaptchaActionName, RecaptchaClientType, - RecaptchaProvider + RecaptchaAuthProvider } from '../../api'; import { ApplicationVerifierInternal } from '../../model/application_verifier'; import { PhoneAuthCredential } from '../../core/credentials/phone'; @@ -295,7 +295,7 @@ export async function _verifyPhoneNumber( startPhoneMfaEnrollmentRequest, RecaptchaActionName.MFA_SMS_ENROLLMENT, startEnrollPhoneMfaActionCallback, - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ); const response = await startPhoneMfaEnrollmentResponse.catch(error => { @@ -357,7 +357,7 @@ export async function _verifyPhoneNumber( startPhoneMfaSignInRequest, RecaptchaActionName.MFA_SMS_SIGNIN, startSignInPhoneMfaActionCallback, - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ); const response = await startPhoneMfaSignInResponse.catch(error => { @@ -411,7 +411,7 @@ export async function _verifyPhoneNumber( sendPhoneVerificationCodeRequest, RecaptchaActionName.SEND_VERIFICATION_CODE, sendPhoneVerificationCodeActionCallback, - RecaptchaProvider.PHONE_PROVIDER + RecaptchaAuthProvider.PHONE_PROVIDER ); const response = await sendPhoneVerificationCodeResponse.catch(error => { From 6ce3f348266ec2fa24c473ae00ca1e164c77a57f Mon Sep 17 00:00:00 2001 From: nhienlam Date: Thu, 28 Mar 2024 12:33:38 -0700 Subject: [PATCH 10/11] Fix reference docs --- docs-devsite/auth.phoneauthprovider.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 3af4d66a4e035ff3d4427bd6637e0964787c5108 Mon Sep 17 00:00:00 2001 From: nhienlam Date: Mon, 8 Apr 2024 15:38:06 -0700 Subject: [PATCH 11/11] Add more unit tests --- .../platform_browser/providers/phone.test.ts | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/auth/src/platform_browser/providers/phone.test.ts b/packages/auth/src/platform_browser/providers/phone.test.ts index 5db4b9f2787..8a75fa14871 100644 --- a/packages/auth/src/platform_browser/providers/phone.test.ts +++ b/packages/auth/src/platform_browser/providers/phone.test.ts @@ -35,14 +35,20 @@ import { RecaptchaVerifier } from '../../platform_browser/recaptcha/recaptcha_ve 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(() => { @@ -83,14 +89,52 @@ describe('platform_browser/providers/phone', () => { sessionInfo: 'verification-id' }); - const v2Verifier = 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(v2Verifier, '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( @@ -100,8 +144,7 @@ describe('platform_browser/providers/phone', () => { expect(result).to.eq('verification-id'); expect(route.calls[0].request).to.eql({ phoneNumber: '+15105550000', - recaptchaToken: 'verification-code', - captchaResponse: FAKE_TOKEN, + captchaResponse: 'enterprise-token', clientType: RecaptchaClientType.WEB, recaptchaVersion: RecaptchaVersion.ENTERPRISE });