From 6b8e0c13daaf476c7e6ea034006250d1f33dd828 Mon Sep 17 00:00:00 2001 From: renkelvin Date: Thu, 13 Apr 2023 09:23:13 -0700 Subject: [PATCH] Recaptcha public preview (#7193) --- .changeset/smart-llamas-compete.md | 6 + common/api-review/auth.api.md | 11 + docs-devsite/auth.md | 43 +++ packages/auth/demo/public/index.html | 18 +- packages/auth/demo/src/index.js | 17 +- .../authentication/email_and_password.test.ts | 22 +- .../api/authentication/email_and_password.ts | 10 + .../src/api/authentication/recaptcha.test.ts | 69 +++- .../auth/src/api/authentication/recaptcha.ts | 41 +- .../src/api/authentication/sign_up.test.ts | 12 +- .../auth/src/api/authentication/sign_up.ts | 5 + packages/auth/src/api/errors.ts | 25 +- packages/auth/src/api/index.ts | 19 +- packages/auth/src/core/auth/auth_impl.test.ts | 97 +++++ packages/auth/src/core/auth/auth_impl.ts | 34 +- .../auth/src/core/credentials/email.test.ts | 231 +++++++++++- packages/auth/src/core/credentials/email.ts | 41 +- packages/auth/src/core/errors.ts | 37 +- packages/auth/src/core/index.ts | 32 ++ .../strategies/email_and_password.test.ts | 355 +++++++++++++++++- .../src/core/strategies/email_and_password.ts | 102 ++++- .../src/core/strategies/email_link.test.ts | 221 ++++++++++- .../auth/src/core/strategies/email_link.ts | 64 +++- packages/auth/src/model/auth.ts | 5 + .../auth/src/platform_browser/auth_window.ts | 4 +- .../recaptcha/recaptcha.test.ts | 63 ++++ .../platform_browser/recaptcha/recaptcha.ts | 72 ++++ .../recaptcha_enterprise_verifier.test.ts | 120 ++++++ .../recaptcha_enterprise_verifier.ts | 179 +++++++++ .../recaptcha/recaptcha_loader.ts | 10 +- .../recaptcha/recaptcha_mock.ts | 50 ++- packages/auth/test/helpers/api/helper.ts | 27 +- 32 files changed, 1970 insertions(+), 72 deletions(-) create mode 100644 .changeset/smart-llamas-compete.md create mode 100644 packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts create mode 100644 packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.test.ts create mode 100644 packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts diff --git a/.changeset/smart-llamas-compete.md b/.changeset/smart-llamas-compete.md new file mode 100644 index 00000000000..cb9150492cf --- /dev/null +++ b/.changeset/smart-llamas-compete.md @@ -0,0 +1,6 @@ +--- +'@firebase/auth': minor +'firebase': minor +--- + +[feature] Add reCAPTCHA enterprise support. diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index df634e12423..0a83cc77820 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -226,6 +226,14 @@ export const AuthErrorCodes: { readonly WEAK_PASSWORD: "auth/weak-password"; readonly WEB_STORAGE_UNSUPPORTED: "auth/web-storage-unsupported"; readonly ALREADY_INITIALIZED: "auth/already-initialized"; + readonly RECAPTCHA_NOT_ENABLED: "auth/recaptcha-not-enabled"; + readonly MISSING_RECAPTCHA_TOKEN: "auth/missing-recaptcha-token"; + readonly INVALID_RECAPTCHA_TOKEN: "auth/invalid-recaptcha-token"; + readonly INVALID_RECAPTCHA_ACTION: "auth/invalid-recaptcha-action"; + readonly MISSING_CLIENT_TYPE: "auth/missing-client-type"; + readonly MISSING_RECAPTCHA_VERSION: "auth/missing-recaptcha-version"; + readonly INVALID_RECAPTCHA_VERSION: "auth/invalid-recaptcha-version"; + readonly INVALID_REQ_TYPE: "auth/invalid-req-type"; }; // @public @@ -422,6 +430,9 @@ export const indexedDBLocalPersistence: Persistence; // @public export function initializeAuth(app: FirebaseApp, deps?: Dependencies): Auth; +// @public +export function initializeRecaptchaConfig(auth: Auth): Promise; + // @public export const inMemoryPersistence: Persistence; diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 2d84967cc47..58af71f3560 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -31,6 +31,7 @@ Firebase Authentication | [fetchSignInMethodsForEmail(auth, email)](./auth.md#fetchsigninmethodsforemail) | Gets the list of possible sign in methods for the given email address. | | [getMultiFactorResolver(auth, error)](./auth.md#getmultifactorresolver) | Provides a [MultiFactorResolver](./auth.multifactorresolver.md#multifactorresolver_interface) suitable for completion of a multi-factor flow. | | [getRedirectResult(auth, resolver)](./auth.md#getredirectresult) | Returns a [UserCredential](./auth.usercredential.md#usercredential_interface) from the redirect-based sign-in flow. | +| [initializeRecaptchaConfig(auth)](./auth.md#initializerecaptchaconfig) | Loads the reCAPTCHA configuration into the Auth instance. | | [isSignInWithEmailLink(auth, emailLink)](./auth.md#issigninwithemaillink) | Checks if an incoming link is a sign-in with email link suitable for [signInWithEmailLink()](./auth.md#signinwithemaillink). | | [onAuthStateChanged(auth, nextOrObserver, error, completed)](./auth.md#onauthstatechanged) | Adds an observer for changes to the user's sign-in state. | | [onIdTokenChanged(auth, nextOrObserver, error, completed)](./auth.md#onidtokenchanged) | Adds an observer for changes to the signed-in user's ID token. | @@ -486,6 +487,40 @@ const operationType = result.operationType; ``` +## initializeRecaptchaConfig() + +Loads the reCAPTCHA configuration into the `Auth` instance. + +This will load the reCAPTCHA config, which indicates whether the reCAPTCHA verification flow should be triggered for each auth provider, into the current Auth session. + +If initializeRecaptchaConfig() is not invoked, the auth flow will always start without reCAPTCHA verification. If the provider is configured to require reCAPTCHA verification, the SDK will transparently load the reCAPTCHA config and restart the auth flows. + +Thus, by calling this optional method, you will reduce the latency of future auth flows. Loading the reCAPTCHA config early will also enhance the signal collected by reCAPTCHA. + +Signature: + +```typescript +export declare function initializeRecaptchaConfig(auth: Auth): Promise; +``` + +### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| auth | [Auth](./auth.auth.md#auth_interface) | The [Auth](./auth.auth.md#auth_interface) instance. | + +Returns: + +Promise<void> + +### Example + + +```javascript +initializeRecaptchaConfig(auth); + +``` + ## isSignInWithEmailLink() Checks if an incoming link is a sign-in with email link suitable for [signInWithEmailLink()](./auth.md#signinwithemaillink). @@ -1795,6 +1830,14 @@ AUTH_ERROR_CODES_MAP_DO_NOT_USE_INTERNALLY: { readonly WEAK_PASSWORD: "auth/weak-password"; readonly WEB_STORAGE_UNSUPPORTED: "auth/web-storage-unsupported"; readonly ALREADY_INITIALIZED: "auth/already-initialized"; + readonly RECAPTCHA_NOT_ENABLED: "auth/recaptcha-not-enabled"; + readonly MISSING_RECAPTCHA_TOKEN: "auth/missing-recaptcha-token"; + readonly INVALID_RECAPTCHA_TOKEN: "auth/invalid-recaptcha-token"; + readonly INVALID_RECAPTCHA_ACTION: "auth/invalid-recaptcha-action"; + readonly MISSING_CLIENT_TYPE: "auth/missing-client-type"; + readonly MISSING_RECAPTCHA_VERSION: "auth/missing-recaptcha-version"; + readonly INVALID_RECAPTCHA_VERSION: "auth/invalid-recaptcha-version"; + readonly INVALID_REQ_TYPE: "auth/invalid-req-type"; } ``` diff --git a/packages/auth/demo/public/index.html b/packages/auth/demo/public/index.html index d098860f216..cbac8d73d90 100644 --- a/packages/auth/demo/public/index.html +++ b/packages/auth/demo/public/index.html @@ -232,15 +232,21 @@
Set Tenant
- -
+ +
Recaptcha Configs
+ +
Sign Up
diff --git a/packages/auth/demo/src/index.js b/packages/auth/demo/src/index.js index 5159d1169d7..a3abe1e3f37 100644 --- a/packages/auth/demo/src/index.js +++ b/packages/auth/demo/src/index.js @@ -71,7 +71,8 @@ import { reauthenticateWithRedirect, getRedirectResult, browserPopupRedirectResolver, - connectAuthEmulator + connectAuthEmulator, + initializeRecaptchaConfig } from '@firebase/auth'; import { config } from './config'; @@ -480,6 +481,18 @@ function onSignInAnonymously() { signInAnonymously(auth).then(onAuthUserCredentialSuccess, onAuthError); } +function onSetTenantID(_event) { + const tenantId = $('#tenant-id').val(); + auth.tenantId = tenantId; + if (tenantId === '') { + auth.tenantId = null; + } +} + +function onInitializeRecaptchaConfig() { + initializeRecaptchaConfig(auth); +} + /** * Signs in with a generic IdP credential. */ @@ -2018,6 +2031,8 @@ function initApp() { ); $('.sign-in-with-custom-token').click(onSignInWithCustomToken); $('#sign-in-anonymously').click(onSignInAnonymously); + $('.set-tenant-id').click(onSetTenantID); + $('#initialize-recaptcha-config').click(onInitializeRecaptchaConfig); $('#sign-in-with-generic-idp-credential').click( onSignInWithGenericIdPCredential ); diff --git a/packages/auth/src/api/authentication/email_and_password.test.ts b/packages/auth/src/api/authentication/email_and_password.test.ts index e58e5f4b293..e07641a554a 100644 --- a/packages/auth/src/api/authentication/email_and_password.test.ts +++ b/packages/auth/src/api/authentication/email_and_password.test.ts @@ -21,7 +21,12 @@ import chaiAsPromised from 'chai-as-promised'; import { ActionCodeOperation } from '../../model/public_types'; import { FirebaseError } from '@firebase/util'; -import { Endpoint, HttpHeader } from '../'; +import { + Endpoint, + HttpHeader, + RecaptchaClientType, + RecaptchaVersion +} from '../'; import { mockEndpoint } from '../../../test/helpers/api/helper'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; import * as mockFetch from '../../../test/helpers/mock_fetch'; @@ -44,7 +49,10 @@ describe('api/authentication/signInWithPassword', () => { const request = { returnSecureToken: true, email: 'test@foo.com', - password: 'my-password' + password: 'my-password', + captchaResponse: 'recaptcha-token', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }; let auth: TestAuth; @@ -187,7 +195,10 @@ describe('api/authentication/sendEmailVerification', () => { describe('api/authentication/sendPasswordResetEmail', () => { const request: PasswordResetRequest = { requestType: ActionCodeOperation.PASSWORD_RESET, - email: 'test@foo.com' + email: 'test@foo.com', + captchaResp: 'recaptcha-token', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }; let auth: TestAuth; @@ -245,7 +256,10 @@ describe('api/authentication/sendPasswordResetEmail', () => { describe('api/authentication/sendSignInLinkToEmail', () => { const request: EmailSignInRequest = { requestType: ActionCodeOperation.EMAIL_SIGNIN, - email: 'test@foo.com' + email: 'test@foo.com', + captchaResp: 'recaptcha-token', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }; let auth: TestAuth; diff --git a/packages/auth/src/api/authentication/email_and_password.ts b/packages/auth/src/api/authentication/email_and_password.ts index 6823fb8794d..2f9664f72db 100644 --- a/packages/auth/src/api/authentication/email_and_password.ts +++ b/packages/auth/src/api/authentication/email_and_password.ts @@ -20,6 +20,8 @@ import { ActionCodeOperation, Auth } from '../../model/public_types'; import { Endpoint, HttpMethod, + RecaptchaClientType, + RecaptchaVersion, _addTidIfNecessary, _performApiRequest, _performSignInRequest @@ -31,6 +33,9 @@ export interface SignInWithPasswordRequest { email: string; password: string; tenantId?: string; + captchaResponse?: string; + clientType?: RecaptchaClientType; + recaptchaVersion?: RecaptchaVersion; } export interface SignInWithPasswordResponse extends IdTokenResponse { @@ -76,11 +81,16 @@ export interface PasswordResetRequest extends GetOobCodeRequest { requestType: ActionCodeOperation.PASSWORD_RESET; email: string; captchaResp?: string; + clientType?: RecaptchaClientType; + recaptchaVersion?: RecaptchaVersion; } export interface EmailSignInRequest extends GetOobCodeRequest { requestType: ActionCodeOperation.EMAIL_SIGNIN; email: string; + captchaResp?: string; + clientType?: RecaptchaClientType; + recaptchaVersion?: RecaptchaVersion; } export interface VerifyAndChangeEmailRequest extends GetOobCodeRequest { diff --git a/packages/auth/src/api/authentication/recaptcha.test.ts b/packages/auth/src/api/authentication/recaptcha.test.ts index 2f69d8ea821..a8864b77420 100644 --- a/packages/auth/src/api/authentication/recaptcha.test.ts +++ b/packages/auth/src/api/authentication/recaptcha.test.ts @@ -20,12 +20,20 @@ import chaiAsPromised from 'chai-as-promised'; import { FirebaseError } from '@firebase/util'; -import { Endpoint, HttpHeader } from '../'; -import { mockEndpoint } from '../../../test/helpers/api/helper'; +import { + Endpoint, + HttpHeader, + RecaptchaClientType, + RecaptchaVersion +} from '../'; +import { + mockEndpoint, + mockEndpointWithParams +} from '../../../test/helpers/api/helper'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; import * as mockFetch from '../../../test/helpers/mock_fetch'; import { ServerError } from '../errors'; -import { getRecaptchaParams } from './recaptcha'; +import { getRecaptchaParams, getRecaptchaConfig } from './recaptcha'; use(chaiAsPromised); @@ -80,3 +88,58 @@ describe('api/authentication/getRecaptchaParams', () => { expect(mock.calls[0].request).to.be.undefined; }); }); + +describe('api/authentication/getRecaptchaConfig', () => { + const request = { + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }; + + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should GET to the correct endpoint', async () => { + const mock = mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + request, + { + recaptchaKey: 'site-key' + } + ); + + const response = await getRecaptchaConfig(auth, request); + expect(response.recaptchaKey).to.eq('site-key'); + expect(mock.calls[0].method).to.eq('GET'); + expect(mock.calls[0].headers!.get(HttpHeader.CONTENT_TYPE)).to.eq( + 'application/json' + ); + expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq( + 'testSDK/0.0.0' + ); + }); + + it('should handle errors', async () => { + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + request, + { + error: { + code: 400, + message: ServerError.UNAUTHORIZED_DOMAIN + } + }, + 400 + ); + + await expect(getRecaptchaConfig(auth, request)).to.be.rejectedWith( + FirebaseError, + 'auth/unauthorized-continue-uri' + ); + }); +}); diff --git a/packages/auth/src/api/authentication/recaptcha.ts b/packages/auth/src/api/authentication/recaptcha.ts index 0d5812b1e6f..ab3633f6214 100644 --- a/packages/auth/src/api/authentication/recaptcha.ts +++ b/packages/auth/src/api/authentication/recaptcha.ts @@ -15,7 +15,14 @@ * limitations under the License. */ -import { Endpoint, HttpMethod, _performApiRequest } from '../index'; +import { + Endpoint, + HttpMethod, + RecaptchaClientType, + RecaptchaVersion, + _performApiRequest, + _addTidIfNecessary +} from '../index'; import { Auth } from '../../model/public_types'; interface GetRecaptchaParamResponse { @@ -33,3 +40,35 @@ export async function getRecaptchaParams(auth: Auth): Promise { ).recaptchaSiteKey || '' ); } + +// The following functions are for reCAPTCHA enterprise integration. +interface GetRecaptchaConfigRequest { + tenantId?: string; + clientType?: RecaptchaClientType; + version?: RecaptchaVersion; +} + +interface RecaptchaEnforcementState { + provider: string; + enforcementState: string; +} + +export interface GetRecaptchaConfigResponse { + recaptchaKey: string; + recaptchaEnforcementState: RecaptchaEnforcementState[]; +} + +export async function getRecaptchaConfig( + auth: Auth, + request: GetRecaptchaConfigRequest +): Promise { + return _performApiRequest< + GetRecaptchaConfigRequest, + GetRecaptchaConfigResponse + >( + auth, + HttpMethod.GET, + Endpoint.GET_RECAPTCHA_CONFIG, + _addTidIfNecessary(auth, request) + ); +} diff --git a/packages/auth/src/api/authentication/sign_up.test.ts b/packages/auth/src/api/authentication/sign_up.test.ts index 50a952de6ed..7a9a6036f6f 100644 --- a/packages/auth/src/api/authentication/sign_up.test.ts +++ b/packages/auth/src/api/authentication/sign_up.test.ts @@ -20,7 +20,12 @@ import chaiAsPromised from 'chai-as-promised'; import { FirebaseError } from '@firebase/util'; -import { Endpoint, HttpHeader } from '../'; +import { + Endpoint, + HttpHeader, + RecaptchaClientType, + RecaptchaVersion +} from '../'; import { mockEndpoint } from '../../../test/helpers/api/helper'; import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; import * as mockFetch from '../../../test/helpers/mock_fetch'; @@ -33,7 +38,10 @@ describe('api/authentication/signUp', () => { const request = { returnSecureToken: true, email: 'test@foo.com', - password: 'my-password' + password: 'my-password', + captchaResponse: 'recaptcha-token', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE }; let auth: TestAuth; diff --git a/packages/auth/src/api/authentication/sign_up.ts b/packages/auth/src/api/authentication/sign_up.ts index a271fb57053..86fcf2381d7 100644 --- a/packages/auth/src/api/authentication/sign_up.ts +++ b/packages/auth/src/api/authentication/sign_up.ts @@ -18,6 +18,8 @@ import { Endpoint, HttpMethod, + RecaptchaClientType, + RecaptchaVersion, _addTidIfNecessary, _performSignInRequest } from '../index'; @@ -29,6 +31,9 @@ export interface SignUpRequest { email?: string; password?: string; tenantId?: string; + captchaResponse?: string; + clientType?: RecaptchaClientType; + recaptchaVersion?: RecaptchaVersion; } export interface SignUpResponse extends IdTokenResponse { diff --git a/packages/auth/src/api/errors.ts b/packages/auth/src/api/errors.ts index b106dfb17c2..58dc180b0be 100644 --- a/packages/auth/src/api/errors.ts +++ b/packages/auth/src/api/errors.ts @@ -90,7 +90,15 @@ export const enum ServerError { USER_CANCELLED = 'USER_CANCELLED', USER_DISABLED = 'USER_DISABLED', USER_NOT_FOUND = 'USER_NOT_FOUND', - WEAK_PASSWORD = 'WEAK_PASSWORD' + WEAK_PASSWORD = 'WEAK_PASSWORD', + RECAPTCHA_NOT_ENABLED = 'RECAPTCHA_NOT_ENABLED', + MISSING_RECAPTCHA_TOKEN = 'MISSING_RECAPTCHA_TOKEN', + INVALID_RECAPTCHA_TOKEN = 'INVALID_RECAPTCHA_TOKEN', + INVALID_RECAPTCHA_ACTION = 'INVALID_RECAPTCHA_ACTION', + MISSING_CLIENT_TYPE = 'MISSING_CLIENT_TYPE', + MISSING_RECAPTCHA_VERSION = 'MISSING_RECAPTCHA_VERSION', + INVALID_RECAPTCHA_VERSION = 'INVALID_RECAPTCHA_VERSION', + INVALID_REQ_TYPE = 'INVALID_REQ_TYPE' } /** @@ -203,5 +211,18 @@ export const SERVER_ERROR_MAP: Partial> = { AuthErrorCode.SECOND_FACTOR_LIMIT_EXCEEDED, // Blocking functions related errors. - [ServerError.BLOCKING_FUNCTION_ERROR_RESPONSE]: AuthErrorCode.INTERNAL_ERROR + [ServerError.BLOCKING_FUNCTION_ERROR_RESPONSE]: AuthErrorCode.INTERNAL_ERROR, + + // Recaptcha related errors. + [ServerError.RECAPTCHA_NOT_ENABLED]: AuthErrorCode.RECAPTCHA_NOT_ENABLED, + [ServerError.MISSING_RECAPTCHA_TOKEN]: AuthErrorCode.MISSING_RECAPTCHA_TOKEN, + [ServerError.INVALID_RECAPTCHA_TOKEN]: AuthErrorCode.INVALID_RECAPTCHA_TOKEN, + [ServerError.INVALID_RECAPTCHA_ACTION]: + AuthErrorCode.INVALID_RECAPTCHA_ACTION, + [ServerError.MISSING_CLIENT_TYPE]: AuthErrorCode.MISSING_CLIENT_TYPE, + [ServerError.MISSING_RECAPTCHA_VERSION]: + AuthErrorCode.MISSING_RECAPTCHA_VERSION, + [ServerError.INVALID_RECAPTCHA_VERSION]: + AuthErrorCode.INVALID_RECAPTCHA_VERSION, + [ServerError.INVALID_REQ_TYPE]: AuthErrorCode.INVALID_REQ_TYPE }; diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index 4b07fa6e18a..d3e18b66a6c 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -66,7 +66,24 @@ export const enum Endpoint { START_MFA_SIGN_IN = '/v2/accounts/mfaSignIn:start', FINALIZE_MFA_SIGN_IN = '/v2/accounts/mfaSignIn:finalize', WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw', - GET_PROJECT_CONFIG = '/v1/projects' + GET_PROJECT_CONFIG = '/v1/projects', + GET_RECAPTCHA_CONFIG = '/v2/recaptchaConfig' +} + +export const enum RecaptchaClientType { + WEB = 'CLIENT_TYPE_WEB', + ANDROID = 'CLIENT_TYPE_ANDROID', + IOS = 'CLIENT_TYPE_IOS' +} + +export const enum RecaptchaVersion { + ENTERPRISE = 'RECAPTCHA_ENTERPRISE' +} + +export const enum RecaptchaActionName { + SIGN_IN_WITH_PASSWORD = 'signInWithPassword', + GET_OOB_CODE = 'getOobCode', + SIGN_UP_PASSWORD = 'signUpPassword' } export const DEFAULT_API_TIMEOUT_MS = new Delay(30_000, 60_000); diff --git a/packages/auth/src/core/auth/auth_impl.test.ts b/packages/auth/src/core/auth/auth_impl.test.ts index 514a61d25e4..5435489c878 100644 --- a/packages/auth/src/core/auth/auth_impl.test.ts +++ b/packages/auth/src/core/auth/auth_impl.test.ts @@ -41,6 +41,9 @@ import * as reload from '../user/reload'; import { AuthImpl, DefaultConfig } from './auth_impl'; import { _initializeAuthInstance } from './initialize'; import { ClientPlatform } from '../util/version'; +import { mockEndpointWithParams } from '../../../test/helpers/api/helper'; +import { Endpoint, RecaptchaClientType, RecaptchaVersion } from '../../api'; +import * as mockFetch from '../../../test/helpers/mock_fetch'; import { AuthErrorCode } from '../errors'; use(sinonChai); @@ -689,4 +692,98 @@ describe('core/auth/auth_impl', () => { }); }); }); + + context('recaptchaEnforcementState', () => { + const recaptchaConfigResponseEnforce = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { provider: 'EMAIL_PASSWORD_PROVIDER', enforcementState: 'ENFORCE' } + ] + }; + const recaptchaConfigResponseOff = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { provider: 'EMAIL_PASSWORD_PROVIDER', enforcementState: 'OFF' } + ] + }; + const cachedRecaptchaConfigEnforce = { + emailPasswordEnabled: true, + siteKey: 'site-key' + }; + const cachedRecaptchaConfigOFF = { + emailPasswordEnabled: false, + siteKey: 'site-key' + }; + + beforeEach(async () => { + mockFetch.setUp(); + }); + + afterEach(() => { + mockFetch.tearDown(); + }); + + it('recaptcha config should be set for agent if tenant id is null.', async () => { + auth = await testAuth(); + auth.tenantId = null; + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + + expect(auth._getRecaptchaConfig()).to.eql(cachedRecaptchaConfigEnforce); + }); + + it('recaptcha config should be set for tenant if tenant id is not null.', async () => { + auth = await testAuth(); + auth.tenantId = 'tenant-id'; + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE, + tenantId: 'tenant-id' + }, + recaptchaConfigResponseOff + ); + await auth.initializeRecaptchaConfig(); + + expect(auth._getRecaptchaConfig()).to.eql(cachedRecaptchaConfigOFF); + }); + + it('recaptcha config should dynamically switch if tenant id switches.', async () => { + auth = await testAuth(); + auth.tenantId = null; + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + auth.tenantId = 'tenant-id'; + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE, + tenantId: 'tenant-id' + }, + recaptchaConfigResponseOff + ); + await auth.initializeRecaptchaConfig(); + + auth.tenantId = null; + expect(auth._getRecaptchaConfig()).to.eql(cachedRecaptchaConfigEnforce); + auth.tenantId = 'tenant-id'; + expect(auth._getRecaptchaConfig()).to.eql(cachedRecaptchaConfigOFF); + }); + }); }); diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index 89a85c3cac4..d7308c03fcc 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -61,8 +61,11 @@ import { _assert } from '../util/assert'; import { _getInstance } from '../util/instantiator'; import { _getUserLanguage } from '../util/navigator'; import { _getClientVersion } from '../util/version'; -import { HttpHeader } from '../../api'; +import { HttpHeader, RecaptchaClientType, RecaptchaVersion } from '../../api'; +import { getRecaptchaConfig } from '../../api/authentication/recaptcha'; +import { RecaptchaEnterpriseVerifier } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; import { AuthMiddlewareQueue } from './middleware'; +import { RecaptchaConfig } from '../../platform_browser/recaptcha/recaptcha'; import { _logWarn } from '../util/log'; interface AsyncAction { @@ -96,6 +99,8 @@ export class AuthImpl implements AuthInternal, _FirebaseService { _popupRedirectResolver: PopupRedirectResolverInternal | null = null; _errorFactory: ErrorFactory = _DEFAULT_AUTH_ERROR_FACTORY; + _agentRecaptchaConfig: RecaptchaConfig | null = null; + _tenantRecaptchaConfigs: Record = {}; readonly name: string; // Tracks the last notified UID for state change listeners to prevent @@ -390,6 +395,33 @@ export class AuthImpl implements AuthInternal, _FirebaseService { }); } + async initializeRecaptchaConfig(): Promise { + const response = await getRecaptchaConfig(this, { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }); + + const config = new RecaptchaConfig(response); + if (this.tenantId == null) { + this._agentRecaptchaConfig = config; + } else { + this._tenantRecaptchaConfigs[this.tenantId] = config; + } + + if (config.emailPasswordEnabled) { + const verifier = new RecaptchaEnterpriseVerifier(this); + void verifier.verify(); + } + } + + _getRecaptchaConfig(): RecaptchaConfig | null { + if (this.tenantId == null) { + return this._agentRecaptchaConfig; + } else { + return this._tenantRecaptchaConfigs[this.tenantId]; + } + } + _getPersistence(): string { return this.assertedPersistence.persistence.type; } diff --git a/packages/auth/src/core/credentials/email.test.ts b/packages/auth/src/core/credentials/email.test.ts index e70a49a2423..5cdd1c01740 100644 --- a/packages/auth/src/core/credentials/email.test.ts +++ b/packages/auth/src/core/credentials/email.test.ts @@ -17,15 +17,27 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; import { ProviderId, SignInMethod } from '../../model/enums'; -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 mockFetch from '../../../test/helpers/mock_fetch'; -import { Endpoint } from '../../api'; +import { + Endpoint, + RecaptchaClientType, + RecaptchaVersion, + RecaptchaActionName +} from '../../api'; import { APIUserInfo } from '../../api/account_management/account'; import { EmailAuthCredential } from './email'; +import { MockGreCAPTCHATopLevel } from '../../platform_browser/recaptcha/recaptcha_mock'; +import * as jsHelpers from '../../platform_browser/load_js'; +import { ServerError } from '../../api/errors'; use(chaiAsPromised); @@ -81,7 +93,217 @@ describe('core/credentials/email', () => { expect(apiMock.calls[0].request).to.eql({ returnSecureToken: true, email: 'some-email', - password: 'some-password' + password: 'some-password', + clientType: 'CLIENT_TYPE_WEB' + }); + }); + + context('#recaptcha', () => { + beforeEach(async () => {}); + + afterEach(() => { + sinon.restore(); + }); + + const recaptchaConfigResponseEnforce = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { provider: 'EMAIL_PASSWORD_PROVIDER', enforcementState: 'ENFORCE' } + ] + }; + const recaptchaConfigResponseOff = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { provider: 'EMAIL_PASSWORD_PROVIDER', enforcementState: 'OFF' } + ] + }; + + it('calls sign in with password with recaptcha enabled', async () => { + const recaptcha = new MockGreCAPTCHATopLevel(); + if (typeof window === 'undefined') { + return; + } + window.grecaptcha = recaptcha; + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + + const idTokenResponse = await credential._getIdTokenResponse(auth); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + expect(apiMock.calls[0].request).to.eql({ + captchaResponse: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + email: 'some-email', + password: 'some-password', + recaptchaVersion: RecaptchaVersion.ENTERPRISE, + returnSecureToken: true + }); + }); + + it('calls sign in with password with recaptcha disabled', async () => { + const recaptcha = new MockGreCAPTCHATopLevel(); + if (typeof window === 'undefined') { + return; + } + window.grecaptcha = recaptcha; + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseOff + ); + await auth.initializeRecaptchaConfig(); + + const idTokenResponse = await credential._getIdTokenResponse(auth); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + expect(apiMock.calls[0].request).to.eql({ + email: 'some-email', + password: 'some-password', + returnSecureToken: true, + clientType: 'CLIENT_TYPE_WEB' + }); + }); + + it('calls sign in with password with recaptcha forced refresh succeed', async () => { + if (typeof window === 'undefined') { + return; + } + // Mock recaptcha js loading method and manually set window.recaptcha + sinon + .stub(jsHelpers, '_loadJS') + .returns(Promise.resolve(new Event(''))); + const recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + const stub = sinon.stub(recaptcha.enterprise, 'execute'); + + // First verification should fail with 'wrong-site-key' + stub + .withArgs('wrong-site-key', { + action: RecaptchaActionName.SIGN_IN_WITH_PASSWORD + }) + .rejects(); + // Second verifcation should succeed with site key refreshed + stub + .withArgs('site-key', { + action: RecaptchaActionName.SIGN_IN_WITH_PASSWORD + }) + .returns(Promise.resolve('recaptcha-response')); + + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + auth._agentRecaptchaConfig!.siteKey = 'wrong-site-key'; + + const idTokenResponse = await credential._getIdTokenResponse(auth); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + expect(apiMock.calls[0].request).to.eql({ + captchaResponse: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + email: 'some-email', + password: 'some-password', + recaptchaVersion: RecaptchaVersion.ENTERPRISE, + returnSecureToken: true + }); + }); + + it('calls fallback to recaptcha flow when receiving MISSING_RECAPTCHA_TOKEN error', async () => { + if (typeof window === 'undefined') { + return; + } + + // First call without recaptcha token should fail with MISSING_RECAPTCHA_TOKEN error + mockEndpointWithParams( + Endpoint.SIGN_IN_WITH_PASSWORD, + { + email: 'second-email', + password: 'some-password', + returnSecureToken: true, + clientType: RecaptchaClientType.WEB + }, + { + error: { + code: 400, + message: ServerError.MISSING_RECAPTCHA_TOKEN + } + }, + 400 + ); + + // Second call with a valid recaptcha token (captchaResp) should succeed + mockEndpointWithParams( + Endpoint.SIGN_IN_WITH_PASSWORD, + { + captchaResponse: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + email: 'some-email', + password: 'some-password', + recaptchaVersion: RecaptchaVersion.ENTERPRISE, + returnSecureToken: true + }, + { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + } + ); + + // Mock recaptcha js loading method and manually set window.recaptcha + sinon + .stub(jsHelpers, '_loadJS') + .returns(Promise.resolve(new Event(''))); + const recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + const stub = sinon.stub(recaptcha.enterprise, 'execute'); + stub + .withArgs('site-key', { + action: RecaptchaActionName.SIGN_IN_WITH_PASSWORD + }) + .returns(Promise.resolve('recaptcha-response')); + + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + + const idTokenResponse = await credential._getIdTokenResponse(auth); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); }); }); }); @@ -122,7 +344,8 @@ describe('core/credentials/email', () => { expect(apiMock.calls[0].request).to.eql({ returnSecureToken: true, email: 'some-email', - password: 'some-password' + password: 'some-password', + clientType: 'CLIENT_TYPE_WEB' }); }); }); diff --git a/packages/auth/src/core/credentials/email.ts b/packages/auth/src/core/credentials/email.ts index 45e3ad5908d..6421f33b5a1 100644 --- a/packages/auth/src/core/credentials/email.ts +++ b/packages/auth/src/core/credentials/email.ts @@ -18,7 +18,10 @@ import { ProviderId, SignInMethod } from '../../model/enums'; import { updateEmailPassword } from '../../api/account_management/email_and_password'; -import { signInWithPassword } from '../../api/authentication/email_and_password'; +import { + signInWithPassword, + SignInWithPasswordRequest +} from '../../api/authentication/email_and_password'; import { signInWithEmailLink, signInWithEmailLinkForLinking @@ -28,7 +31,8 @@ import { IdTokenResponse } from '../../model/id_token'; import { AuthErrorCode } from '../errors'; import { _fail } from '../util/assert'; import { AuthCredential } from './auth_credential'; - +import { injectRecaptchaFields } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; +import { RecaptchaActionName, RecaptchaClientType } from '../../api'; /** * Interface that represents the credentials returned by {@link EmailAuthProvider} for * {@link ProviderId}.PASSWORD @@ -113,11 +117,38 @@ export class EmailAuthCredential extends AuthCredential { async _getIdTokenResponse(auth: AuthInternal): Promise { switch (this.signInMethod) { case SignInMethod.EMAIL_PASSWORD: - return signInWithPassword(auth, { + const request: SignInWithPasswordRequest = { returnSecureToken: true, email: this._email, - password: this._password - }); + password: this._password, + clientType: RecaptchaClientType.WEB + }; + if (auth._getRecaptchaConfig()?.emailPasswordEnabled) { + const requestWithRecaptcha = await injectRecaptchaFields( + auth, + request, + RecaptchaActionName.SIGN_IN_WITH_PASSWORD + ); + return signInWithPassword(auth, requestWithRecaptcha); + } else { + return signInWithPassword(auth, request).catch(async error => { + if ( + error.code === `auth/${AuthErrorCode.MISSING_RECAPTCHA_TOKEN}` + ) { + console.log( + 'Sign-in with email address and password is protected by reCAPTCHA for this project. Automatically triggering the reCAPTCHA flow and restarting the sign-in flow.' + ); + const requestWithRecaptcha = await injectRecaptchaFields( + auth, + request, + RecaptchaActionName.SIGN_IN_WITH_PASSWORD + ); + return signInWithPassword(auth, requestWithRecaptcha); + } else { + return Promise.reject(error); + } + }); + } case SignInMethod.EMAIL_LINK: return signInWithEmailLink(auth, { email: this._email, diff --git a/packages/auth/src/core/errors.ts b/packages/auth/src/core/errors.ts index dbd62fb65ef..e450c31deaf 100644 --- a/packages/auth/src/core/errors.ts +++ b/packages/auth/src/core/errors.ts @@ -124,7 +124,15 @@ export const enum AuthErrorCode { USER_SIGNED_OUT = 'user-signed-out', WEAK_PASSWORD = 'weak-password', WEB_STORAGE_UNSUPPORTED = 'web-storage-unsupported', - ALREADY_INITIALIZED = 'already-initialized' + ALREADY_INITIALIZED = 'already-initialized', + RECAPTCHA_NOT_ENABLED = 'recaptcha-not-enabled', + MISSING_RECAPTCHA_TOKEN = 'missing-recaptcha-token', + INVALID_RECAPTCHA_TOKEN = 'invalid-recaptcha-token', + INVALID_RECAPTCHA_ACTION = 'invalid-recaptcha-action', + MISSING_CLIENT_TYPE = 'missing-client-type', + MISSING_RECAPTCHA_VERSION = 'missing-recaptcha-version', + INVALID_RECAPTCHA_VERSION = 'invalid-recaptcha-version', + INVALID_REQ_TYPE = 'invalid-req-type' } function _debugErrorMap(): ErrorMap { @@ -358,7 +366,22 @@ function _debugErrorMap(): ErrorMap { 'initializeAuth() has already been called with ' + 'different options. To avoid this error, call initializeAuth() with the ' + 'same options as when it was originally called, or call getAuth() to return the' + - ' already initialized instance.' + ' already initialized instance.', + [AuthErrorCode.MISSING_RECAPTCHA_TOKEN]: + 'The reCAPTCHA token is missing when sending request to the backend.', + [AuthErrorCode.INVALID_RECAPTCHA_TOKEN]: + 'The reCAPTCHA token is invalid when sending request to the backend.', + [AuthErrorCode.INVALID_RECAPTCHA_ACTION]: + 'The reCAPTCHA action is invalid when sending request to the backend.', + [AuthErrorCode.RECAPTCHA_NOT_ENABLED]: + 'reCAPTCHA Enterprise integration is not enabled for this project.', + [AuthErrorCode.MISSING_CLIENT_TYPE]: + 'The reCAPTCHA client type is missing when sending request to the backend.', + [AuthErrorCode.MISSING_RECAPTCHA_VERSION]: + 'The reCAPTCHA version is missing when sending request to the backend.', + [AuthErrorCode.INVALID_REQ_TYPE]: 'Invalid request parameters.', + [AuthErrorCode.INVALID_RECAPTCHA_VERSION]: + 'The reCAPTCHA version is invalid when sending request to the backend.' }; } @@ -560,5 +583,13 @@ export const AUTH_ERROR_CODES_MAP_DO_NOT_USE_INTERNALLY = { USER_SIGNED_OUT: 'auth/user-signed-out', WEAK_PASSWORD: 'auth/weak-password', WEB_STORAGE_UNSUPPORTED: 'auth/web-storage-unsupported', - ALREADY_INITIALIZED: 'auth/already-initialized' + ALREADY_INITIALIZED: 'auth/already-initialized', + RECAPTCHA_NOT_ENABLED: 'auth/recaptcha-not-enabled', + MISSING_RECAPTCHA_TOKEN: 'auth/missing-recaptcha-token', + INVALID_RECAPTCHA_TOKEN: 'auth/invalid-recaptcha-token', + INVALID_RECAPTCHA_ACTION: 'auth/invalid-recaptcha-action', + MISSING_CLIENT_TYPE: 'auth/missing-client-type', + MISSING_RECAPTCHA_VERSION: 'auth/missing-recaptcha-version', + INVALID_RECAPTCHA_VERSION: 'auth/invalid-recaptcha-version', + INVALID_REQ_TYPE: 'auth/invalid-req-type' } as const; diff --git a/packages/auth/src/core/index.ts b/packages/auth/src/core/index.ts index ec551f9510c..9d64d554e60 100644 --- a/packages/auth/src/core/index.ts +++ b/packages/auth/src/core/index.ts @@ -25,6 +25,7 @@ import { ErrorFn, Unsubscribe } from '../model/public_types'; +import { _castAuth } from '../core/auth/auth_impl'; export { debugErrorMap, @@ -60,6 +61,37 @@ export function setPersistence( ): Promise { return getModularInstance(auth).setPersistence(persistence); } + +/** + * Loads the reCAPTCHA configuration into the `Auth` instance. + * + * @remarks + * This will load the reCAPTCHA config, which indicates whether the reCAPTCHA + * verification flow should be triggered for each auth provider, into the + * current Auth session. + * + * If initializeRecaptchaConfig() is not invoked, the auth flow will always start + * without reCAPTCHA verification. If the provider is configured to require reCAPTCHA + * verification, the SDK will transparently load the reCAPTCHA config and restart the + * auth flows. + * + * Thus, by calling this optional method, you will reduce the latency of future auth flows. + * Loading the reCAPTCHA config early will also enhance the signal collected by reCAPTCHA. + * + * @example + * ```javascript + * initializeRecaptchaConfig(auth); + * ``` + * + * @param auth - The {@link Auth} instance. + * + * @public + */ +export function initializeRecaptchaConfig(auth: Auth): Promise { + const authInternal = _castAuth(auth); + return authInternal.initializeRecaptchaConfig(); +} + /** * Adds an observer for changes to the signed-in user's ID token. * 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 ebaa8cf26d5..fd9389aefc3 100644 --- a/packages/auth/src/core/strategies/email_and_password.test.ts +++ b/packages/auth/src/core/strategies/email_and_password.test.ts @@ -18,15 +18,25 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; +import * as sinon from 'sinon'; import { ActionCodeOperation } from '../../model/public_types'; import { OperationType } from '../../model/enums'; import { FirebaseError } from '@firebase/util'; +import * as jsHelpers from '../../platform_browser/load_js'; -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 mockFetch from '../../../test/helpers/mock_fetch'; -import { Endpoint } from '../../api'; +import { + Endpoint, + RecaptchaClientType, + RecaptchaVersion, + RecaptchaActionName +} from '../../api'; import { APIUserInfo } from '../../api/account_management/account'; import { ServerError } from '../../api/errors'; import { UserCredentialInternal } from '../../model/user'; @@ -39,6 +49,7 @@ import { signInWithEmailAndPassword, verifyPasswordResetCode } from './email_and_password'; +import { MockGreCAPTCHATopLevel } from '../../platform_browser/recaptcha/recaptcha_mock'; use(chaiAsPromised); use(sinonChai); @@ -62,7 +73,8 @@ describe('core/strategies/sendPasswordResetEmail', () => { await sendPasswordResetEmail(auth, email); expect(mock.calls[0].request).to.eql({ requestType: ActionCodeOperation.PASSWORD_RESET, - email + email, + clientType: 'CLIENT_TYPE_WEB' }); }); @@ -104,7 +116,8 @@ describe('core/strategies/sendPasswordResetEmail', () => { continueUrl: 'my-url', dynamicLinkDomain: 'fdl-domain', canHandleCodeInApp: true, - iOSBundleId: 'my-bundle' + iOSBundleId: 'my-bundle', + clientType: 'CLIENT_TYPE_WEB' }); }); }); @@ -132,10 +145,167 @@ describe('core/strategies/sendPasswordResetEmail', () => { canHandleCodeInApp: true, androidInstallApp: false, androidMinimumVersionCode: 'my-version', - androidPackageName: 'my-package' + androidPackageName: 'my-package', + clientType: 'CLIENT_TYPE_WEB' }); }); }); + + context('#recaptcha', () => { + const recaptchaConfigResponseEnforce = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: 'EMAIL_PASSWORD_PROVIDER', + enforcementState: 'ENFORCE' + } + ] + }; + const recaptchaConfigResponseOff = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { provider: 'EMAIL_PASSWORD_PROVIDER', enforcementState: 'OFF' } + ] + }; + beforeEach(async () => { + if (typeof window === 'undefined') { + return; + } + const recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls send password reset email with recaptcha enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + + const apiMock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendPasswordResetEmail(auth, email); + + expect(apiMock.calls[0].request).to.eql({ + requestType: ActionCodeOperation.PASSWORD_RESET, + email, + captchaResp: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('calls send password reset with recaptcha disabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseOff + ); + await auth.initializeRecaptchaConfig(); + + const apiMock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendPasswordResetEmail(auth, email); + expect(apiMock.calls[0].request).to.eql({ + requestType: ActionCodeOperation.PASSWORD_RESET, + email, + clientType: 'CLIENT_TYPE_WEB' + }); + }); + + it('calls fallback to recaptcha flow when receiving MISSING_RECAPTCHA_TOKEN error', async () => { + if (typeof window === 'undefined') { + return; + } + + // First call without recaptcha token should fail with MISSING_RECAPTCHA_TOKEN error + mockEndpointWithParams( + Endpoint.SEND_OOB_CODE, + { + requestType: ActionCodeOperation.PASSWORD_RESET, + email, + clientType: RecaptchaClientType.WEB + }, + { + error: { + code: 400, + message: ServerError.MISSING_RECAPTCHA_TOKEN + } + }, + 400 + ); + + // Second call with a valid recaptcha token (captchaResp) should succeed + mockEndpointWithParams( + Endpoint.SEND_OOB_CODE, + { + requestType: ActionCodeOperation.PASSWORD_RESET, + email, + captchaResp: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }, + { + email + } + ); + + // Mock recaptcha js loading method and manually set window.recaptcha + sinon.stub(jsHelpers, '_loadJS').returns(Promise.resolve(new Event(''))); + const recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + const stub = sinon.stub(recaptcha.enterprise, 'execute'); + stub + .withArgs('site-key', { + action: RecaptchaActionName.GET_OOB_CODE + }) + .returns(Promise.resolve('recaptcha-response')); + + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + + mockEndpoint(Endpoint.SEND_OOB_CODE, { email }); + const response = await sendPasswordResetEmail(auth, email); + expect(response).to.eq(undefined); + }); + }); }); describe('core/strategies/confirmPasswordReset', () => { @@ -400,6 +570,181 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () expect(user.uid).to.eq(serverUser.localId); expect(user.isAnonymous).to.be.false; }); + + context('#recaptcha', () => { + const recaptchaConfigResponseEnforce = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: 'EMAIL_PASSWORD_PROVIDER', + enforcementState: 'ENFORCE' + } + ] + }; + + beforeEach(async () => { + const recaptcha = new MockGreCAPTCHATopLevel(); + if (typeof window === 'undefined') { + return; + } + window.grecaptcha = recaptcha; + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls create user with email password with recaptcha enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + + const { _tokenResponse, user, operationType } = + (await createUserWithEmailAndPassword( + auth, + 'some-email', + 'some-password' + )) as UserCredentialInternal; + expect(_tokenResponse).to.eql({ + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user.uid).to.eq(serverUser.localId); + expect(user.isAnonymous).to.be.false; + }); + + it('calls create user with email password with recaptcha disabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + + const { _tokenResponse, user, operationType } = + (await createUserWithEmailAndPassword( + auth, + 'some-email', + 'some-password' + )) as UserCredentialInternal; + expect(_tokenResponse).to.eql({ + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user.uid).to.eq(serverUser.localId); + expect(user.isAnonymous).to.be.false; + }); + + it('calls fallback to recaptcha flow when receiving MISSING_RECAPTCHA_TOKEN error', async () => { + if (typeof window === 'undefined') { + return; + } + + // First call without recaptcha token should fail with MISSING_RECAPTCHA_TOKEN error + mockEndpointWithParams( + Endpoint.SIGN_UP, + { + email: 'some-email', + password: 'some-password', + clientType: RecaptchaClientType.WEB + }, + { + error: { + code: 400, + message: ServerError.MISSING_RECAPTCHA_TOKEN + } + }, + 400 + ); + + // Second call with a valid recaptcha token (captchaResp) should succeed + mockEndpointWithParams( + Endpoint.SIGN_UP, + { + email: 'some-email', + password: 'some-password', + captchaResp: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }, + { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + } + ); + + // Mock recaptcha js loading method and manually set window.recaptcha + sinon.stub(jsHelpers, '_loadJS').returns(Promise.resolve(new Event(''))); + const recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + const stub = sinon.stub(recaptcha.enterprise, 'execute'); + stub + .withArgs('site-key', { + action: RecaptchaActionName.SIGN_UP_PASSWORD + }) + .returns(Promise.resolve('recaptcha-response')); + + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + + const { _tokenResponse, user, operationType } = + (await createUserWithEmailAndPassword( + auth, + 'some-email', + 'some-password' + )) as UserCredentialInternal; + expect(_tokenResponse).to.eql({ + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user.uid).to.eq(serverUser.localId); + expect(user.isAnonymous).to.be.false; + }); + }); }); describe('core/strategies/email_and_password/signInWithEmailAndPassword', () => { diff --git a/packages/auth/src/core/strategies/email_and_password.ts b/packages/auth/src/core/strategies/email_and_password.ts index 9d968fa2719..6ff248f8532 100644 --- a/packages/auth/src/core/strategies/email_and_password.ts +++ b/packages/auth/src/core/strategies/email_and_password.ts @@ -25,7 +25,7 @@ import { import * as account from '../../api/account_management/email_and_password'; import * as authentication from '../../api/authentication/email_and_password'; -import { signUp } from '../../api/authentication/sign_up'; +import { signUp, SignUpRequest } from '../../api/authentication/sign_up'; import { MultiFactorInfoImpl } from '../../mfa/mfa_info'; import { EmailAuthProvider } from '../providers/email'; import { UserCredentialImpl } from '../user/user_credential_impl'; @@ -36,6 +36,9 @@ import { _castAuth } from '../auth/auth_impl'; import { AuthErrorCode } from '../errors'; import { getModularInstance } from '@firebase/util'; import { OperationType } from '../../model/enums'; +import { injectRecaptchaFields } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; +import { IdTokenResponse } from '../../model/id_token'; +import { RecaptchaActionName, RecaptchaClientType } from '../../api'; /** * Sends a password reset email to the given email address. @@ -74,16 +77,67 @@ export async function sendPasswordResetEmail( email: string, actionCodeSettings?: ActionCodeSettings ): Promise { - const authModular = getModularInstance(auth); + const authInternal = _castAuth(auth); const request: authentication.PasswordResetRequest = { requestType: ActionCodeOperation.PASSWORD_RESET, - email + email, + clientType: RecaptchaClientType.WEB }; - if (actionCodeSettings) { - _setActionCodeSettingsOnRequest(authModular, request, actionCodeSettings); + if (authInternal._getRecaptchaConfig()?.emailPasswordEnabled) { + const requestWithRecaptcha = await injectRecaptchaFields( + authInternal, + request, + RecaptchaActionName.GET_OOB_CODE, + true + ); + if (actionCodeSettings) { + _setActionCodeSettingsOnRequest( + authInternal, + requestWithRecaptcha, + actionCodeSettings + ); + } + await authentication.sendPasswordResetEmail( + authInternal, + requestWithRecaptcha + ); + } else { + if (actionCodeSettings) { + _setActionCodeSettingsOnRequest( + authInternal, + request, + actionCodeSettings + ); + } + await authentication + .sendPasswordResetEmail(authInternal, request) + .catch(async error => { + if (error.code === `auth/${AuthErrorCode.MISSING_RECAPTCHA_TOKEN}`) { + console.log( + 'Password resets are protected by reCAPTCHA for this project. Automatically triggering the reCAPTCHA flow and restarting the password reset flow.' + ); + const requestWithRecaptcha = await injectRecaptchaFields( + authInternal, + request, + RecaptchaActionName.GET_OOB_CODE, + true + ); + if (actionCodeSettings) { + _setActionCodeSettingsOnRequest( + authInternal, + requestWithRecaptcha, + actionCodeSettings + ); + } + await authentication.sendPasswordResetEmail( + authInternal, + requestWithRecaptcha + ); + } else { + return Promise.reject(error); + } + }); } - - await authentication.sendPasswordResetEmail(authModular, request); } /** @@ -227,10 +281,40 @@ export async function createUserWithEmailAndPassword( password: string ): Promise { const authInternal = _castAuth(auth); - const response = await signUp(authInternal, { + const request: SignUpRequest = { returnSecureToken: true, email, - password + password, + clientType: RecaptchaClientType.WEB + }; + let signUpResponse: Promise; + if (authInternal._getRecaptchaConfig()?.emailPasswordEnabled) { + const requestWithRecaptcha = await injectRecaptchaFields( + authInternal, + request, + RecaptchaActionName.SIGN_UP_PASSWORD + ); + signUpResponse = signUp(authInternal, requestWithRecaptcha); + } else { + signUpResponse = signUp(authInternal, request).catch(async error => { + if (error.code === `auth/${AuthErrorCode.MISSING_RECAPTCHA_TOKEN}`) { + console.log( + 'Sign-up is protected by reCAPTCHA for this project. Automatically triggering the reCAPTCHA flow and restarting the sign-up flow.' + ); + const requestWithRecaptcha = await injectRecaptchaFields( + authInternal, + request, + RecaptchaActionName.SIGN_UP_PASSWORD + ); + return signUp(authInternal, requestWithRecaptcha); + } else { + return Promise.reject(error); + } + }); + } + + const response = await signUpResponse.catch(error => { + return Promise.reject(error); }); const userCredential = await UserCredentialImpl._fromIdTokenResponse( diff --git a/packages/auth/src/core/strategies/email_link.test.ts b/packages/auth/src/core/strategies/email_link.test.ts index afbbd833b1f..4815205c916 100644 --- a/packages/auth/src/core/strategies/email_link.test.ts +++ b/packages/auth/src/core/strategies/email_link.test.ts @@ -18,15 +18,24 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; +import * as sinon from 'sinon'; import { ActionCodeOperation } from '../../model/public_types'; import { OperationType } from '../../model/enums'; import { FirebaseError } from '@firebase/util'; -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 mockFetch from '../../../test/helpers/mock_fetch'; -import { Endpoint } from '../../api'; +import { + Endpoint, + RecaptchaClientType, + RecaptchaVersion, + RecaptchaActionName +} from '../../api'; import { APIUserInfo } from '../../api/account_management/account'; import { ServerError } from '../../api/errors'; import { UserCredentialInternal } from '../../model/user'; @@ -35,6 +44,8 @@ import { sendSignInLinkToEmail, signInWithEmailLink } from './email_link'; +import { MockGreCAPTCHATopLevel } from '../../platform_browser/recaptcha/recaptcha_mock'; +import * as jsHelpers from '../../platform_browser/load_js'; use(chaiAsPromised); use(sinonChai); @@ -63,7 +74,8 @@ describe('core/strategies/sendSignInLinkToEmail', () => { requestType: ActionCodeOperation.EMAIL_SIGNIN, email, canHandleCodeInApp: true, - continueUrl: 'continue-url' + continueUrl: 'continue-url', + clientType: 'CLIENT_TYPE_WEB' }); }); @@ -119,7 +131,8 @@ describe('core/strategies/sendSignInLinkToEmail', () => { continueUrl: 'my-url', dynamicLinkDomain: 'fdl-domain', canHandleCodeInApp: true, - iOSBundleId: 'my-bundle' + iOSBundleId: 'my-bundle', + clientType: 'CLIENT_TYPE_WEB' }); }); }); @@ -147,8 +160,206 @@ describe('core/strategies/sendSignInLinkToEmail', () => { canHandleCodeInApp: true, androidInstallApp: false, androidMinimumVersionCode: 'my-version', - androidPackageName: 'my-package' + androidPackageName: 'my-package', + clientType: 'CLIENT_TYPE_WEB' + }); + }); + }); + + context('#recaptcha', () => { + const recaptchaConfigResponseEnforce = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: 'EMAIL_PASSWORD_PROVIDER', + enforcementState: 'ENFORCE' + } + ] + }; + const recaptchaConfigResponseOff = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { provider: 'EMAIL_PASSWORD_PROVIDER', enforcementState: 'OFF' } + ] + }; + + beforeEach(async () => { + const recaptcha = new MockGreCAPTCHATopLevel(); + if (typeof window === 'undefined') { + return; + } + window.grecaptcha = recaptcha; + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls send sign in link to email with recaptcha enabled', async () => { + if (typeof window === 'undefined') { + return; + } + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + + const apiMock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendSignInLinkToEmail(auth, email, { + handleCodeInApp: true, + url: 'continue-url' + }); + expect(apiMock.calls[0].request).to.eql({ + requestType: ActionCodeOperation.EMAIL_SIGNIN, + email, + canHandleCodeInApp: true, + continueUrl: 'continue-url', + captchaResp: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }); + }); + + it('calls send sign in link to email with recaptcha disabled', async () => { + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseOff + ); + await auth.initializeRecaptchaConfig(); + + const apiMock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendSignInLinkToEmail(auth, email, { + handleCodeInApp: true, + url: 'continue-url' + }); + expect(apiMock.calls[0].request).to.eql({ + requestType: ActionCodeOperation.EMAIL_SIGNIN, + email, + canHandleCodeInApp: true, + continueUrl: 'continue-url', + clientType: 'CLIENT_TYPE_WEB' + }); + }); + + it('calls send sign in link to email with recaptcha forced refresh succeed', async () => { + const recaptcha = new MockGreCAPTCHATopLevel(); + if (typeof window === 'undefined') { + return; + } + window.grecaptcha = recaptcha; + const stub = sinon.stub(recaptcha.enterprise, 'execute'); + + // // First verification should fail with 'wrong-site-key' + stub + .withArgs('wrong-site-key', { action: 'signInWithEmailLink' }) + .rejects(); + // Second verifcation should succeed with site key refreshed + stub + .withArgs('site-key', { action: 'signInWithEmailLink' }) + .returns(Promise.resolve('recaptcha-response')); + + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + auth._agentRecaptchaConfig!.siteKey = 'wrong-site-key'; + + mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + expect( + sendSignInLinkToEmail(auth, email, { + handleCodeInApp: true, + url: 'continue-url' + }) + ).returned; + }); + + it('calls fallback to recaptcha flow when receiving MISSING_RECAPTCHA_TOKEN error', async () => { + if (typeof window === 'undefined') { + return; + } + + // First call without recaptcha token should fail with MISSING_RECAPTCHA_TOKEN error + mockEndpointWithParams( + Endpoint.SEND_OOB_CODE, + { + requestType: ActionCodeOperation.EMAIL_SIGNIN, + email, + clientType: RecaptchaClientType.WEB + }, + { + error: { + code: 400, + message: ServerError.MISSING_RECAPTCHA_TOKEN + } + }, + 400 + ); + + // Second call with a valid recaptcha token (captchaResp) should succeed + mockEndpointWithParams( + Endpoint.SEND_OOB_CODE, + { + requestType: ActionCodeOperation.EMAIL_SIGNIN, + email, + captchaResp: 'recaptcha-response', + clientType: RecaptchaClientType.WEB, + recaptchaVersion: RecaptchaVersion.ENTERPRISE + }, + { + email + } + ); + + // Mock recaptcha js loading method and manually set window.recaptcha + sinon.stub(jsHelpers, '_loadJS').returns(Promise.resolve(new Event(''))); + const recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + const stub = sinon.stub(recaptcha.enterprise, 'execute'); + stub + .withArgs('site-key', { + action: RecaptchaActionName.GET_OOB_CODE + }) + .returns(Promise.resolve('recaptcha-response')); + + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + recaptchaConfigResponseEnforce + ); + await auth.initializeRecaptchaConfig(); + + mockEndpoint(Endpoint.SEND_OOB_CODE, { email }); + const response = await sendSignInLinkToEmail(auth, email, { + handleCodeInApp: true, + url: 'continue-url' }); + expect(response).to.eq(undefined); }); }); }); diff --git a/packages/auth/src/core/strategies/email_link.ts b/packages/auth/src/core/strategies/email_link.ts index 0c64d02b93a..f67a1e3ea03 100644 --- a/packages/auth/src/core/strategies/email_link.ts +++ b/packages/auth/src/core/strategies/email_link.ts @@ -31,6 +31,9 @@ import { signInWithCredential } from './credential'; import { AuthErrorCode } from '../errors'; import { _assert } from '../util/assert'; import { getModularInstance } from '@firebase/util'; +import { _castAuth } from '../auth/auth_impl'; +import { injectRecaptchaFields } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; +import { RecaptchaActionName, RecaptchaClientType } from '../../api'; /** * Sends a sign-in email link to the user with the specified email. @@ -75,21 +78,60 @@ export async function sendSignInLinkToEmail( email: string, actionCodeSettings: ActionCodeSettings ): Promise { - const authModular = getModularInstance(auth); + const authInternal = _castAuth(auth); const request: api.EmailSignInRequest = { requestType: ActionCodeOperation.EMAIL_SIGNIN, - email + email, + clientType: RecaptchaClientType.WEB }; - _assert( - actionCodeSettings.handleCodeInApp, - authModular, - AuthErrorCode.ARGUMENT_ERROR - ); - if (actionCodeSettings) { - _setActionCodeSettingsOnRequest(authModular, request, actionCodeSettings); + function setActionCodeSettings( + request: api.EmailSignInRequest, + actionCodeSettings: ActionCodeSettings + ): void { + _assert( + actionCodeSettings.handleCodeInApp, + authInternal, + AuthErrorCode.ARGUMENT_ERROR + ); + if (actionCodeSettings) { + _setActionCodeSettingsOnRequest( + authInternal, + request, + actionCodeSettings + ); + } + } + if (authInternal._getRecaptchaConfig()?.emailPasswordEnabled) { + const requestWithRecaptcha = await injectRecaptchaFields( + authInternal, + request, + RecaptchaActionName.GET_OOB_CODE, + true + ); + setActionCodeSettings(requestWithRecaptcha, actionCodeSettings); + await api.sendSignInLinkToEmail(authInternal, requestWithRecaptcha); + } else { + setActionCodeSettings(request, actionCodeSettings); + await api + .sendSignInLinkToEmail(authInternal, request) + .catch(async error => { + if (error.code === `auth/${AuthErrorCode.MISSING_RECAPTCHA_TOKEN}`) { + console.log( + 'Email link sign-in is protected by reCAPTCHA for this project. Automatically triggering the reCAPTCHA flow and restarting the sign-in flow.' + ); + const requestWithRecaptcha = await injectRecaptchaFields( + authInternal, + request, + RecaptchaActionName.GET_OOB_CODE, + true + ); + setActionCodeSettings(requestWithRecaptcha, actionCodeSettings); + await api.sendSignInLinkToEmail(authInternal, requestWithRecaptcha); + } else { + return Promise.reject(error); + } + }); } - - await api.sendSignInLinkToEmail(authModular, request); } /** diff --git a/packages/auth/src/model/auth.ts b/packages/auth/src/model/auth.ts index f0584368c94..d2e50d92b58 100644 --- a/packages/auth/src/model/auth.ts +++ b/packages/auth/src/model/auth.ts @@ -29,6 +29,7 @@ import { AuthErrorCode, AuthErrorParams } from '../core/errors'; import { PopupRedirectResolverInternal } from './popup_redirect'; import { UserInternal } from './user'; import { ClientPlatform } from '../core/util/version'; +import { RecaptchaConfig } from '../platform_browser/recaptcha/recaptcha'; export type AppName = string; export type ApiKey = string; @@ -60,6 +61,8 @@ export interface ConfigInternal extends Config { export interface AuthInternal extends Auth { currentUser: User | null; emulatorConfig: EmulatorConfig | null; + _agentRecaptchaConfig: RecaptchaConfig | null; + _tenantRecaptchaConfigs: Record; _canInitEmulator: boolean; _isInitialized: boolean; _initializationPromise: Promise | null; @@ -79,6 +82,7 @@ export interface AuthInternal extends Auth { _startProactiveRefresh(): void; _stopProactiveRefresh(): void; _getPersistence(): string; + _getRecaptchaConfig(): RecaptchaConfig | null; _logFramework(framework: string): void; _getFrameworks(): readonly string[]; _getAdditionalHeaders(): Promise>; @@ -93,4 +97,5 @@ export interface AuthInternal extends Auth { useDeviceLanguage(): void; signOut(): Promise; + initializeRecaptchaConfig(): Promise; } diff --git a/packages/auth/src/platform_browser/auth_window.ts b/packages/auth/src/platform_browser/auth_window.ts index c7c9a182df9..9d9ed98637f 100644 --- a/packages/auth/src/platform_browser/auth_window.ts +++ b/packages/auth/src/platform_browser/auth_window.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Recaptcha } from './recaptcha/recaptcha'; +import { Recaptcha, GreCAPTCHATopLevel } from './recaptcha/recaptcha'; /** * A specialized window type that melds the normal window type plus the @@ -27,7 +27,7 @@ export type AuthWindow = { [T in keyof Window]: Window[T]; } & { // Any known / named properties we want to add - grecaptcha?: Recaptcha; + grecaptcha?: Recaptcha | GreCAPTCHATopLevel; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ ___jsl?: Record; gapi?: typeof gapi; diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts new file mode 100644 index 00000000000..e0ff090d8dd --- /dev/null +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha.test.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; + +import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; + +import { + MockReCaptcha, + MockGreCAPTCHATopLevel, + MockGreCAPTCHA +} from './recaptcha_mock'; + +import { isV2, isEnterprise } from './recaptcha'; + +use(chaiAsPromised); +use(sinonChai); + +describe('platform_browser/recaptcha/recaptcha', () => { + let auth: TestAuth; + let recaptchaV2: MockReCaptcha; + let recaptchaV3: MockGreCAPTCHA; + let recaptchaEnterprise: MockGreCAPTCHATopLevel; + + context('#verify', () => { + beforeEach(async () => { + auth = await testAuth(); + recaptchaV2 = new MockReCaptcha(auth); + recaptchaV3 = new MockGreCAPTCHA(); + recaptchaEnterprise = new MockGreCAPTCHATopLevel(); + }); + + it('isV2', async () => { + expect(isV2(undefined)).to.be.false; + expect(isV2(recaptchaV2)).to.be.true; + expect(isV2(recaptchaV3)).to.be.false; + expect(isV2(recaptchaEnterprise)).to.be.false; + }); + + it('isEnterprise', async () => { + expect(isEnterprise(undefined)).to.be.false; + expect(isEnterprise(recaptchaV2)).to.be.false; + expect(isEnterprise(recaptchaV3)).to.be.false; + expect(isEnterprise(recaptchaEnterprise)).to.be.true; + }); + }); +}); diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha.ts index 065e6c3308b..ab1b73e35f7 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha.ts @@ -16,10 +16,82 @@ */ import { RecaptchaParameters } from '../../model/public_types'; +import { GetRecaptchaConfigResponse } from '../../api/authentication/recaptcha'; +// reCAPTCHA v2 interface export interface Recaptcha { render: (container: HTMLElement, parameters: RecaptchaParameters) => number; getResponse: (id: number) => string; execute: (id: number) => unknown; reset: (id: number) => unknown; } + +export function isV2( + grecaptcha: Recaptcha | GreCAPTCHA | undefined +): grecaptcha is Recaptcha { + return ( + grecaptcha !== undefined && + (grecaptcha as Recaptcha).getResponse !== undefined + ); +} + +// reCAPTCHA Enterprise & v3 shared interface +export interface GreCAPTCHATopLevel extends GreCAPTCHA { + enterprise: GreCAPTCHA; +} + +// reCAPTCHA Enterprise interface +export interface GreCAPTCHA { + ready: (callback: () => void) => void; + execute: (siteKey: string, options: { action: string }) => Promise; + render: ( + container: string | HTMLElement, + parameters: GreCAPTCHARenderOption + ) => string; +} + +export interface GreCAPTCHARenderOption { + sitekey: string; + size: 'invisible'; +} + +export function isEnterprise( + grecaptcha: Recaptcha | GreCAPTCHA | undefined +): grecaptcha is GreCAPTCHATopLevel { + return ( + grecaptcha !== undefined && + (grecaptcha as GreCAPTCHATopLevel).enterprise !== undefined + ); +} + +// TODO(chuanr): Replace this with the AuthWindow after resolving the dependency issue in Node.js env. +declare global { + interface Window { + grecaptcha?: Recaptcha | GreCAPTCHATopLevel; + } +} + +export class RecaptchaConfig { + /** + * The reCAPTCHA site key. + */ + siteKey: string = ''; + + /** + * The reCAPTCHA enablement status of the {@link EmailAuthProvider} for the current tenant. + */ + emailPasswordEnabled: boolean = false; + + constructor(response: GetRecaptchaConfigResponse) { + if (response.recaptchaKey === undefined) { + throw new Error('recaptchaKey undefined'); + } + // Example response.recaptchaKey: "projects/proj123/keys/sitekey123" + this.siteKey = response.recaptchaKey.split('/')[3]; + this.emailPasswordEnabled = response.recaptchaEnforcementState.some( + enforcementState => + enforcementState.provider === 'EMAIL_PASSWORD_PROVIDER' && + enforcementState.enforcementState !== 'OFF' + ); + } +} 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 new file mode 100644 index 00000000000..90e548cf98c --- /dev/null +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.test.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +import { Endpoint, RecaptchaClientType, RecaptchaVersion } from '../../api'; +import { mockEndpointWithParams } from '../../../test/helpers/api/helper'; +import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; +import * as mockFetch from '../../../test/helpers/mock_fetch'; +import { ServerError } from '../../api/errors'; + +import { MockGreCAPTCHATopLevel } from './recaptcha_mock'; +import { RecaptchaEnterpriseVerifier } from './recaptcha_enterprise_verifier'; + +use(chaiAsPromised); +use(sinonChai); + +describe('platform_browser/recaptcha/recaptcha_enterprise_verifier', () => { + let auth: TestAuth; + let verifier: RecaptchaEnterpriseVerifier; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + verifier = new RecaptchaEnterpriseVerifier(auth); + }); + + afterEach(() => { + mockFetch.tearDown(); + sinon.restore(); + }); + + context('#verify', () => { + const recaptchaConfigResponseEnforce = { + recaptchaKey: 'foo/bar/to/site-key', + recaptchaEnforcementState: [ + { + provider: 'EMAIL_PASSWORD_PROVIDER', + enforcementState: 'ENFORCE' + } + ] + }; + + const request = { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }; + + let recaptcha: MockGreCAPTCHATopLevel; + beforeEach(() => { + recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + }); + + it('returns if response is available', async () => { + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + request, + recaptchaConfigResponseEnforce + ); + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + expect(await verifier.verify()).to.eq('recaptcha-response'); + }); + + it('reject if error is thrown when retieve site key', async () => { + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + request, + { + error: { + code: 400, + message: ServerError.MISSING_CLIENT_TYPE + } + }, + 400 + ); + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve('recaptcha-response')); + await expect(verifier.verify()).to.be.rejectedWith( + Error, + 'auth/missing-client-type' + ); + }); + + it('reject if error is thrown when retieve recaptcha token', async () => { + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + request, + recaptchaConfigResponseEnforce + ); + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.reject(Error('retieve-recaptcha-token-error'))); + await expect(verifier.verify()).to.be.rejectedWith( + Error, + 'retieve-recaptcha-token-error' + ); + }); + }); +}); diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts new file mode 100644 index 00000000000..b051a05e88a --- /dev/null +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts @@ -0,0 +1,179 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isEnterprise, RecaptchaConfig } from './recaptcha'; +import { getRecaptchaConfig } from '../../api/authentication/recaptcha'; +import { + RecaptchaClientType, + RecaptchaVersion, + RecaptchaActionName +} from '../../api'; + +import { Auth } from '../../model/public_types'; +import { AuthInternal } from '../../model/auth'; +import { _castAuth } from '../../core/auth/auth_impl'; +import * as jsHelpers from '../load_js'; + +const RECAPTCHA_ENTERPRISE_URL = + 'https://www.google.com/recaptcha/enterprise.js?render='; + +export const RECAPTCHA_ENTERPRISE_VERIFIER_TYPE = 'recaptcha-enterprise'; + +export class RecaptchaEnterpriseVerifier { + /** + * Identifies the type of application verifier (e.g. "recaptcha-enterprise"). + */ + readonly type = RECAPTCHA_ENTERPRISE_VERIFIER_TYPE; + + private readonly auth: AuthInternal; + + /** + * + * @param authExtern - The corresponding Firebase {@link Auth} instance. + * + */ + constructor(authExtern: Auth) { + this.auth = _castAuth(authExtern); + } + + /** + * Executes the verification process. + * + * @returns A Promise for a token that can be used to assert the validity of a request. + */ + async verify( + action: string = 'verify', + forceRefresh = false + ): Promise { + async function retrieveSiteKey(auth: AuthInternal): Promise { + if (!forceRefresh) { + if (auth.tenantId == null && auth._agentRecaptchaConfig != null) { + return auth._agentRecaptchaConfig.siteKey; + } + if ( + auth.tenantId != null && + auth._tenantRecaptchaConfigs[auth.tenantId] !== undefined + ) { + return auth._tenantRecaptchaConfigs[auth.tenantId].siteKey; + } + } + + return new Promise(async (resolve, reject) => { + getRecaptchaConfig(auth, { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }) + .then(response => { + if (response.recaptchaKey === undefined) { + reject(new Error('recaptcha Enterprise site key undefined')); + } else { + const config = new RecaptchaConfig(response); + if (auth.tenantId == null) { + auth._agentRecaptchaConfig = config; + } else { + auth._tenantRecaptchaConfigs[auth.tenantId] = config; + } + return resolve(config.siteKey); + } + }) + .catch(error => { + reject(error); + }); + }); + } + + function retrieveRecaptchaToken( + siteKey: string, + resolve: (value: string | PromiseLike) => void, + reject: (reason?: unknown) => void + ): void { + const grecaptcha = window.grecaptcha; + if (isEnterprise(grecaptcha)) { + grecaptcha.enterprise.ready(() => { + try { + grecaptcha.enterprise + .execute(siteKey, { action }) + .then(token => { + resolve(token); + }) + .catch(error => { + reject(error); + }); + } catch (error) { + reject(error); + } + }); + } else { + reject(Error('No reCAPTCHA enterprise script loaded.')); + } + } + + return new Promise((resolve, reject) => { + retrieveSiteKey(this.auth) + .then(siteKey => { + if (!forceRefresh && isEnterprise(window.grecaptcha)) { + retrieveRecaptchaToken(siteKey, resolve, reject); + } else { + if (typeof window === 'undefined') { + reject( + new Error('RecaptchaVerifier is only supported in browser') + ); + return; + } + jsHelpers + ._loadJS(RECAPTCHA_ENTERPRISE_URL + siteKey) + .then(() => { + retrieveRecaptchaToken(siteKey, resolve, reject); + }) + .catch(error => { + reject(error); + }); + } + }) + .catch(error => { + reject(error); + }); + }); + } +} + +export async function injectRecaptchaFields( + auth: AuthInternal, + request: T, + action: RecaptchaActionName, + captchaResp = false +): Promise { + const verifier = new RecaptchaEnterpriseVerifier(auth); + let captchaResponse; + try { + captchaResponse = await verifier.verify(action); + } catch (error) { + captchaResponse = await verifier.verify(action, true); + } + const newRequest = { ...request }; + if (!captchaResp) { + Object.assign(newRequest, { captchaResponse }); + } else { + Object.assign(newRequest, { 'captchaResp': captchaResponse }); + } + Object.assign(newRequest, { 'clientType': RecaptchaClientType.WEB }); + Object.assign(newRequest, { + 'recaptchaVersion': RecaptchaVersion.ENTERPRISE + }); + return newRequest; +} diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha_loader.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha_loader.ts index a75e889398f..2b945464cea 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha_loader.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_loader.ts @@ -23,7 +23,7 @@ import { Delay } from '../../core/util/delay'; import { AuthInternal } from '../../model/auth'; import { _window } from '../auth_window'; import * as jsHelpers from '../load_js'; -import { Recaptcha } from './recaptcha'; +import { Recaptcha, isV2 } from './recaptcha'; import { MockReCaptcha } from './recaptcha_mock'; // ReCaptcha will load using the same callback, so the callback function needs @@ -59,8 +59,8 @@ export class ReCaptchaLoaderImpl implements ReCaptchaLoader { load(auth: AuthInternal, hl = ''): Promise { _assert(isHostLanguageValid(hl), auth, AuthErrorCode.ARGUMENT_ERROR); - if (this.shouldResolveImmediately(hl)) { - return Promise.resolve(_window().grecaptcha!); + if (this.shouldResolveImmediately(hl) && isV2(_window().grecaptcha)) { + return Promise.resolve(_window().grecaptcha! as Recaptcha); } return new Promise((resolve, reject) => { const networkTimeout = _window().setTimeout(() => { @@ -71,9 +71,9 @@ export class ReCaptchaLoaderImpl implements ReCaptchaLoader { _window().clearTimeout(networkTimeout); delete _window()[_JSLOAD_CALLBACK]; - const recaptcha = _window().grecaptcha; + const recaptcha = _window().grecaptcha as Recaptcha; - if (!recaptcha) { + if (!recaptcha || !isV2(recaptcha)) { reject(_createError(auth, AuthErrorCode.INTERNAL_ERROR)); return; } diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha_mock.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha_mock.ts index c21c7a3327d..98401ca98c4 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha_mock.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_mock.ts @@ -19,7 +19,12 @@ import { AuthErrorCode } from '../../core/errors'; import { _assert } from '../../core/util/assert'; import { AuthInternal } from '../../model/auth'; import { RecaptchaParameters } from '../../model/public_types'; -import { Recaptcha } from './recaptcha'; +import { + Recaptcha, + GreCAPTCHATopLevel, + GreCAPTCHARenderOption, + GreCAPTCHA +} from './recaptcha'; export const _SOLVE_TIME_MS = 500; export const _EXPIRATION_TIME_MS = 60_000; @@ -68,6 +73,49 @@ export class MockReCaptcha implements Recaptcha { } } +export class MockGreCAPTCHATopLevel implements GreCAPTCHATopLevel { + enterprise: GreCAPTCHA = new MockGreCAPTCHA(); + ready(callback: () => void): void { + callback(); + } + + execute( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _siteKey: string, + _options: { action: string } + ): Promise { + return Promise.resolve('token'); + } + render( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _container: string | HTMLElement, + _parameters: GreCAPTCHARenderOption + ): string { + return ''; + } +} + +export class MockGreCAPTCHA implements GreCAPTCHA { + ready(callback: () => void): void { + callback(); + } + + execute( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _siteKey: string, + _options: { action: string } + ): Promise { + return Promise.resolve('token'); + } + render( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _container: string | HTMLElement, + _parameters: GreCAPTCHARenderOption + ): string { + return ''; + } +} + export class MockWidget { private readonly container: HTMLElement; private readonly isVisible: boolean; diff --git a/packages/auth/test/helpers/api/helper.ts b/packages/auth/test/helpers/api/helper.ts index 0385ef62f66..638310b139e 100644 --- a/packages/auth/test/helpers/api/helper.ts +++ b/packages/auth/test/helpers/api/helper.ts @@ -23,10 +23,35 @@ export function endpointUrl(endpoint: Endpoint): string { return `${TEST_SCHEME}://${TEST_HOST}${endpoint}?key=${TEST_KEY}`; } +export function endpointUrlWithParams( + endpoint: Endpoint, + params: Record +): string { + let url = `${TEST_SCHEME}://${TEST_HOST}${endpoint}?key=${TEST_KEY}`; + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + url += '&'; + url += key; + url += '='; + url += encodeURIComponent(params[key]); + } + } + return url; +} + export function mockEndpoint( endpoint: Endpoint, response: object, status = 200 ): Route { - return mock(endpointUrl(endpoint), response, status); + return mockEndpointWithParams(endpoint, {}, response, status); +} + +export function mockEndpointWithParams( + endpoint: Endpoint, + params: Record, + response: object, + status = 200 +): Route { + return mock(endpointUrlWithParams(endpoint, params), response, status); }