From 61604979cb35647610ea385a6ba0ca67cb03f5d1 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Tue, 2 Nov 2021 13:55:26 -0700 Subject: [PATCH] Implement ReCaptchaEnterprise for App Check (#5595) * Add recaptcha enterprise provider * start on app-check-compat * Correct Enterprise URL * prettier * Add changeset * start on app-check-compat * Add enterprise endpoint * Fix tests * update index.d.ts * Add more tests * Update .changeset/ten-impalas-wink.md Co-authored-by: Feiyang * Address PR comments * Update doc comment * Address doc-related PR comments * Formatting pass * update comments Co-authored-by: Feiyang Co-authored-by: Feiyang1 --- .changeset/ten-impalas-wink.md | 7 + common/api-review/app-check.api.md | 199 +++++++++++--------- packages/app-check-compat/src/index.ts | 7 +- packages/app-check-compat/src/service.ts | 7 +- packages/app-check/src/api.test.ts | 41 +++- packages/app-check/src/api.ts | 6 +- packages/app-check/src/client.test.ts | 34 +++- packages/app-check/src/client.ts | 20 +- packages/app-check/src/constants.ts | 2 + packages/app-check/src/internal-api.test.ts | 27 ++- packages/app-check/src/providers.ts | 103 +++++++--- packages/app-check/src/public-types.ts | 10 +- packages/app-check/src/recaptcha.test.ts | 103 +++++++++- packages/app-check/src/recaptcha.ts | 101 +++++++--- packages/app-check/src/util.ts | 7 +- packages/app-check/test/util.ts | 24 ++- packages/firebase/compat/index.d.ts | 18 +- 17 files changed, 536 insertions(+), 180 deletions(-) create mode 100644 .changeset/ten-impalas-wink.md diff --git a/.changeset/ten-impalas-wink.md b/.changeset/ten-impalas-wink.md new file mode 100644 index 00000000000..e5b709853d5 --- /dev/null +++ b/.changeset/ten-impalas-wink.md @@ -0,0 +1,7 @@ +--- +'@firebase/app-check': minor +'@firebase/app-check-compat': minor +'firebase': minor +--- + +Add ReCAPTCHA Enterprise as an attestation option for App Check. diff --git a/common/api-review/app-check.api.md b/common/api-review/app-check.api.md index 154e7ce2295..7a67b90bd71 100644 --- a/common/api-review/app-check.api.md +++ b/common/api-review/app-check.api.md @@ -1,94 +1,105 @@ -## API Report File for "@firebase/app-check" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { FirebaseApp } from '@firebase/app'; -import { PartialObserver } from '@firebase/util'; -import { Unsubscribe } from '@firebase/util'; - -// @public -export interface AppCheck { - app: FirebaseApp; -} - -// @internal (undocumented) -export type _AppCheckComponentName = 'app-check'; - -// @internal (undocumented) -export type _AppCheckInternalComponentName = 'app-check-internal'; - -// @public -export interface AppCheckOptions { - isTokenAutoRefreshEnabled?: boolean; - provider: CustomProvider | ReCaptchaV3Provider; -} - -// @public -export interface AppCheckToken { - readonly expireTimeMillis: number; - // (undocumented) - readonly token: string; -} - -// @public -export type AppCheckTokenListener = (token: AppCheckTokenResult) => void; - -// @public -export interface AppCheckTokenResult { - readonly token: string; -} - -// Warning: (ae-forgotten-export) The symbol "AppCheckProvider" needs to be exported by the entry point index.d.ts -// -// @public -export class CustomProvider implements AppCheckProvider { - constructor(_customProviderOptions: CustomProviderOptions); - // Warning: (ae-forgotten-export) The symbol "AppCheckTokenInternal" needs to be exported by the entry point index.d.ts - // - // @internal (undocumented) - getToken(): Promise; - // @internal (undocumented) - initialize(app: FirebaseApp): void; - // @internal (undocumented) - isEqual(otherProvider: unknown): boolean; -} - -// @public -export interface CustomProviderOptions { - getToken: () => Promise; -} - -// @public -export function getToken(appCheckInstance: AppCheck, forceRefresh?: boolean): Promise; - -// @public -export function initializeAppCheck(app: FirebaseApp | undefined, options: AppCheckOptions): AppCheck; - -// @public -export function onTokenChanged(appCheckInstance: AppCheck, observer: PartialObserver): Unsubscribe; - -// @public -export function onTokenChanged(appCheckInstance: AppCheck, onNext: (tokenResult: AppCheckTokenResult) => void, onError?: (error: Error) => void, onCompletion?: () => void): Unsubscribe; - -export { PartialObserver } - -// @public -export class ReCaptchaV3Provider implements AppCheckProvider { - constructor(_siteKey: string); - // @internal - getToken(): Promise; - // @internal (undocumented) - initialize(app: FirebaseApp): void; - // @internal (undocumented) - isEqual(otherProvider: unknown): boolean; - } - -// @public -export function setTokenAutoRefreshEnabled(appCheckInstance: AppCheck, isTokenAutoRefreshEnabled: boolean): void; - -export { Unsubscribe } - - -``` +## API Report File for "@firebase/app-check" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { FirebaseApp } from '@firebase/app'; +import { PartialObserver } from '@firebase/util'; +import { Unsubscribe } from '@firebase/util'; + +// @public +export interface AppCheck { + app: FirebaseApp; +} + +// @internal (undocumented) +export type _AppCheckComponentName = 'app-check'; + +// @internal (undocumented) +export type _AppCheckInternalComponentName = 'app-check-internal'; + +// @public +export interface AppCheckOptions { + isTokenAutoRefreshEnabled?: boolean; + provider: CustomProvider | ReCaptchaV3Provider | ReCaptchaEnterpriseProvider; +} + +// @public +export interface AppCheckToken { + readonly expireTimeMillis: number; + // (undocumented) + readonly token: string; +} + +// @public +export type AppCheckTokenListener = (token: AppCheckTokenResult) => void; + +// @public +export interface AppCheckTokenResult { + readonly token: string; +} + +// Warning: (ae-forgotten-export) The symbol "AppCheckProvider" needs to be exported by the entry point index.d.ts +// +// @public +export class CustomProvider implements AppCheckProvider { + constructor(_customProviderOptions: CustomProviderOptions); + // Warning: (ae-forgotten-export) The symbol "AppCheckTokenInternal" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + getToken(): Promise; + // @internal (undocumented) + initialize(app: FirebaseApp): void; + // @internal (undocumented) + isEqual(otherProvider: unknown): boolean; +} + +// @public +export interface CustomProviderOptions { + getToken: () => Promise; +} + +// @public +export function getToken(appCheckInstance: AppCheck, forceRefresh?: boolean): Promise; + +// @public +export function initializeAppCheck(app: FirebaseApp | undefined, options: AppCheckOptions): AppCheck; + +// @public +export function onTokenChanged(appCheckInstance: AppCheck, observer: PartialObserver): Unsubscribe; + +// @public +export function onTokenChanged(appCheckInstance: AppCheck, onNext: (tokenResult: AppCheckTokenResult) => void, onError?: (error: Error) => void, onCompletion?: () => void): Unsubscribe; + +export { PartialObserver } + +// @public +export class ReCaptchaEnterpriseProvider implements AppCheckProvider { + constructor(_siteKey: string); + // @internal + getToken(): Promise; + // @internal (undocumented) + initialize(app: FirebaseApp): void; + // @internal (undocumented) + isEqual(otherProvider: unknown): boolean; + } + +// @public +export class ReCaptchaV3Provider implements AppCheckProvider { + constructor(_siteKey: string); + // @internal + getToken(): Promise; + // @internal (undocumented) + initialize(app: FirebaseApp): void; + // @internal (undocumented) + isEqual(otherProvider: unknown): boolean; + } + +// @public +export function setTokenAutoRefreshEnabled(appCheckInstance: AppCheck, isTokenAutoRefreshEnabled: boolean): void; + +export { Unsubscribe } + + +``` diff --git a/packages/app-check-compat/src/index.ts b/packages/app-check-compat/src/index.ts index 925b6ca7b5e..2715598270c 100644 --- a/packages/app-check-compat/src/index.ts +++ b/packages/app-check-compat/src/index.ts @@ -28,7 +28,11 @@ import { } from '@firebase/component'; import { AppCheckService } from './service'; import { FirebaseAppCheck } from '@firebase/app-check-types'; -import { ReCaptchaV3Provider, CustomProvider } from '@firebase/app-check'; +import { + ReCaptchaV3Provider, + ReCaptchaEnterpriseProvider, + CustomProvider +} from '@firebase/app-check'; const factory: InstanceFactory<'appCheck-compat'> = ( container: ComponentContainer @@ -46,6 +50,7 @@ export function registerAppCheck(): void { factory, ComponentType.PUBLIC ).setServiceProps({ + ReCaptchaEnterpriseProvider, ReCaptchaV3Provider, CustomProvider }) diff --git a/packages/app-check-compat/src/service.ts b/packages/app-check-compat/src/service.ts index fb0045aad9a..db3f2d1ef46 100644 --- a/packages/app-check-compat/src/service.ts +++ b/packages/app-check-compat/src/service.ts @@ -26,6 +26,7 @@ import { CustomProvider, initializeAppCheck, ReCaptchaV3Provider, + ReCaptchaEnterpriseProvider, setTokenAutoRefreshEnabled as setTokenAutoRefreshEnabledExp, getToken as getTokenExp, onTokenChanged as onTokenChangedExp @@ -43,10 +44,14 @@ export class AppCheckService siteKeyOrProvider: string | AppCheckProvider, isTokenAutoRefreshEnabled?: boolean ): void { - let provider: ReCaptchaV3Provider | CustomProvider; + let provider: + | ReCaptchaV3Provider + | CustomProvider + | ReCaptchaEnterpriseProvider; if (typeof siteKeyOrProvider === 'string') { provider = new ReCaptchaV3Provider(siteKeyOrProvider); } else if ( + siteKeyOrProvider instanceof ReCaptchaEnterpriseProvider || siteKeyOrProvider instanceof ReCaptchaV3Provider || siteKeyOrProvider instanceof CustomProvider ) { diff --git a/packages/app-check/src/api.test.ts b/packages/app-check/src/api.test.ts index 8f30d4b7b91..2d869875a28 100644 --- a/packages/app-check/src/api.test.ts +++ b/packages/app-check/src/api.test.ts @@ -41,7 +41,11 @@ import * as internalApi from './internal-api'; import * as indexeddb from './indexeddb'; import * as debug from './debug'; import { deleteApp, FirebaseApp } from '@firebase/app'; -import { CustomProvider, ReCaptchaV3Provider } from './providers'; +import { + CustomProvider, + ReCaptchaEnterpriseProvider, + ReCaptchaV3Provider +} from './providers'; import { AppCheckService } from './factory'; import { AppCheckToken } from './public-types'; import { getDebugToken } from './debug'; @@ -83,6 +87,16 @@ describe('api', () => { }) ).to.throw(/appCheck\/already-initialized/); }); + it('can only be called once (if given different ReCaptchaEnterpriseProviders)', () => { + initializeAppCheck(app, { + provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY) + }); + expect(() => + initializeAppCheck(app, { + provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY + 'X') + }) + ).to.throw(/appCheck\/already-initialized/); + }); it('can only be called once (if given different CustomProviders)', () => { initializeAppCheck(app, { provider: new CustomProvider({ @@ -107,6 +121,16 @@ describe('api', () => { }) ).to.equal(appCheckInstance); }); + it('can be called multiple times (if given equivalent ReCaptchaEnterpriseProviders)', () => { + const appCheckInstance = initializeAppCheck(app, { + provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY) + }); + expect( + initializeAppCheck(app, { + provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY) + }) + ).to.equal(appCheckInstance); + }); it('can be called multiple times (if given equivalent CustomProviders)', () => { const appCheckInstance = initializeAppCheck(app, { provider: new CustomProvider({ @@ -166,7 +190,7 @@ describe('api', () => { }); it('initialize reCAPTCHA when a ReCaptchaV3Provider is provided', () => { - const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize').returns( + const initReCAPTCHAStub = stub(reCAPTCHA, 'initializeV3').returns( Promise.resolve({} as any) ); initializeAppCheck(app, { @@ -178,6 +202,19 @@ describe('api', () => { ); }); + it('initialize reCAPTCHA when a ReCaptchaEnterpriseProvider is provided', () => { + const initReCAPTCHAStub = stub(reCAPTCHA, 'initializeEnterprise').returns( + Promise.resolve({} as any) + ); + initializeAppCheck(app, { + provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY) + }); + expect(initReCAPTCHAStub).to.have.been.calledWithExactly( + app, + FAKE_SITE_KEY + ); + }); + it('sets activated to true', () => { expect(getState(app).activated).to.equal(false); initializeAppCheck(app, { diff --git a/packages/app-check/src/api.ts b/packages/app-check/src/api.ts index b962f756ddf..596b1a402a3 100644 --- a/packages/app-check/src/api.ts +++ b/packages/app-check/src/api.ts @@ -43,7 +43,11 @@ declare module '@firebase/component' { } } -export { ReCaptchaV3Provider, CustomProvider } from './providers'; +export { + ReCaptchaV3Provider, + CustomProvider, + ReCaptchaEnterpriseProvider +} from './providers'; /** * Activate App Check for the given app. Can be called only once per app. diff --git a/packages/app-check/src/client.test.ts b/packages/app-check/src/client.test.ts index 769c3f49fb5..afa8399b916 100644 --- a/packages/app-check/src/client.test.ts +++ b/packages/app-check/src/client.test.ts @@ -20,7 +20,11 @@ import { expect } from 'chai'; import { stub, SinonStub, useFakeTimers } from 'sinon'; import { FirebaseApp } from '@firebase/app'; import { getFakeApp, getFakePlatformLoggingProvider } from '../test/util'; -import { getExchangeRecaptchaTokenRequest, exchangeToken } from './client'; +import { + getExchangeRecaptchaV3TokenRequest, + exchangeToken, + getExchangeRecaptchaEnterpriseTokenRequest +} from './client'; import { FirebaseError } from '@firebase/util'; import { ERROR_FACTORY, AppCheckError } from './errors'; import { BASE_ENDPOINT } from './constants'; @@ -36,7 +40,7 @@ describe('client', () => { }); it('creates exchange recaptcha token request correctly', () => { - const request = getExchangeRecaptchaTokenRequest( + const request = getExchangeRecaptchaV3TokenRequest( app, 'fake-recaptcha-token' ); @@ -51,6 +55,22 @@ describe('client', () => { }); }); + it('creates exchange recaptcha enterprise token request correctly', () => { + const request = getExchangeRecaptchaEnterpriseTokenRequest( + app, + 'fake-recaptcha-token' + ); + const { projectId, appId, apiKey } = app.options; + + expect(request).to.deep.equal({ + url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:exchangeRecaptchaEnterpriseToken?key=${apiKey}`, + body: { + // eslint-disable-next-line camelcase + recaptcha_enterprise_token: 'fake-recaptcha-token' + } + }); + }); + it('returns a AppCheck token', async () => { // To get a consistent expireTime/issuedAtTime. const clock = useFakeTimers(); @@ -65,7 +85,7 @@ describe('client', () => { ); const response = await exchangeToken( - getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), getFakePlatformLoggingProvider('a/1.2.3 fire-app-check/2.3.4') ); @@ -93,7 +113,7 @@ describe('client', () => { try { await exchangeToken( - getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), getFakePlatformLoggingProvider() ); } catch (e) { @@ -122,7 +142,7 @@ describe('client', () => { try { await exchangeToken( - getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), getFakePlatformLoggingProvider() ); } catch (e) { @@ -150,7 +170,7 @@ describe('client', () => { try { await exchangeToken( - getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), getFakePlatformLoggingProvider() ); } catch (e) { @@ -184,7 +204,7 @@ describe('client', () => { try { await exchangeToken( - getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getExchangeRecaptchaV3TokenRequest(app, 'fake-custom-token'), getFakePlatformLoggingProvider() ); } catch (e) { diff --git a/packages/app-check/src/client.ts b/packages/app-check/src/client.ts index c51f34402e0..94564191f64 100644 --- a/packages/app-check/src/client.ts +++ b/packages/app-check/src/client.ts @@ -18,6 +18,7 @@ import { BASE_ENDPOINT, EXCHANGE_DEBUG_TOKEN_METHOD, + EXCHANGE_RECAPTCHA_ENTERPRISE_TOKEN_METHOD, EXCHANGE_RECAPTCHA_TOKEN_METHOD } from './constants'; import { FirebaseApp } from '@firebase/app'; @@ -103,7 +104,7 @@ export async function exchangeToken( }; } -export function getExchangeRecaptchaTokenRequest( +export function getExchangeRecaptchaV3TokenRequest( app: FirebaseApp, reCAPTCHAToken: string ): AppCheckRequest { @@ -112,8 +113,21 @@ export function getExchangeRecaptchaTokenRequest( return { url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:${EXCHANGE_RECAPTCHA_TOKEN_METHOD}?key=${apiKey}`, body: { - // eslint-disable-next-line - recaptcha_token: reCAPTCHAToken + 'recaptcha_token': reCAPTCHAToken + } + }; +} + +export function getExchangeRecaptchaEnterpriseTokenRequest( + app: FirebaseApp, + reCAPTCHAToken: string +): AppCheckRequest { + const { projectId, appId, apiKey } = app.options; + + return { + url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:${EXCHANGE_RECAPTCHA_ENTERPRISE_TOKEN_METHOD}?key=${apiKey}`, + body: { + 'recaptcha_enterprise_token': reCAPTCHAToken } }; } diff --git a/packages/app-check/src/constants.ts b/packages/app-check/src/constants.ts index 56cdd623427..ecb7fb49a87 100644 --- a/packages/app-check/src/constants.ts +++ b/packages/app-check/src/constants.ts @@ -18,6 +18,8 @@ export const BASE_ENDPOINT = 'https://content-firebaseappcheck.googleapis.com/v1beta'; export const EXCHANGE_RECAPTCHA_TOKEN_METHOD = 'exchangeRecaptchaToken'; +export const EXCHANGE_RECAPTCHA_ENTERPRISE_TOKEN_METHOD = + 'exchangeRecaptchaEnterpriseToken'; export const EXCHANGE_DEBUG_TOKEN_METHOD = 'exchangeDebugToken'; export const TOKEN_REFRESH_TIME = { diff --git a/packages/app-check/src/internal-api.test.ts b/packages/app-check/src/internal-api.test.ts index 81953c0e022..779b84fb9fc 100644 --- a/packages/app-check/src/internal-api.test.ts +++ b/packages/app-check/src/internal-api.test.ts @@ -41,7 +41,7 @@ import * as util from './util'; import { getState, clearState, setState, getDebugState } from './state'; import { AppCheckTokenListener } from './public-types'; import { Deferred } from '@firebase/util'; -import { ReCaptchaV3Provider } from './providers'; +import { ReCaptchaEnterpriseProvider, ReCaptchaV3Provider } from './providers'; import { AppCheckService } from './factory'; import { ListenerType } from './types'; @@ -92,7 +92,7 @@ describe('internal api', () => { }); }); - it('uses reCAPTCHA token to exchange for AppCheck token', async () => { + it('uses reCAPTCHA (V3) token to exchange for AppCheck token', async () => { const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); @@ -115,6 +115,29 @@ describe('internal api', () => { expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); }); + it('uses reCAPTCHA (Enterprise) token to exchange for AppCheck token', async () => { + const appCheck = initializeAppCheck(app, { + provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY) + }); + + const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns( + Promise.resolve(fakeRecaptchaToken) + ); + const exchangeTokenStub: SinonStub = stub( + client, + 'exchangeToken' + ).returns(Promise.resolve(fakeRecaptchaAppCheckToken)); + + const token = await getToken(appCheck as AppCheckService); + + expect(reCAPTCHASpy).to.be.called; + + expect( + exchangeTokenStub.args[0][0].body['recaptcha_enterprise_token'] + ).to.equal(fakeRecaptchaToken); + expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); + }); + it('resolves with a dummy token and an error if failed to get a token', async () => { const errorStub = stub(console, 'error'); const appCheck = initializeAppCheck(app, { diff --git a/packages/app-check/src/providers.ts b/packages/app-check/src/providers.ts index 6c694f5cd89..ab1009c5148 100644 --- a/packages/app-check/src/providers.ts +++ b/packages/app-check/src/providers.ts @@ -18,12 +18,17 @@ import { FirebaseApp, _getProvider } from '@firebase/app'; import { Provider } from '@firebase/component'; import { issuedAtTime } from '@firebase/util'; -import { exchangeToken, getExchangeRecaptchaTokenRequest } from './client'; +import { + exchangeToken, + getExchangeRecaptchaEnterpriseTokenRequest, + getExchangeRecaptchaV3TokenRequest +} from './client'; import { AppCheckError, ERROR_FACTORY } from './errors'; import { CustomProviderOptions } from './public-types'; import { getToken as getReCAPTCHAToken, - initialize as initializeRecaptcha + initializeV3 as initializeRecaptchaV3, + initializeEnterprise as initializeRecaptchaEnterprise } from './recaptcha'; import { AppCheckProvider, AppCheckTokenInternal } from './types'; @@ -47,21 +52,17 @@ export class ReCaptchaV3Provider implements AppCheckProvider { * @internal */ async getToken(): Promise { - if (!this._app || !this._platformLoggerProvider) { - // This should only occur if user has not called initializeAppCheck(). - // We don't have an appName to provide if so. - // This should already be caught in the top level `getToken()` function. - throw ERROR_FACTORY.create(AppCheckError.USE_BEFORE_ACTIVATION, { - appName: '' - }); - } - const attestedClaimsToken = await getReCAPTCHAToken(this._app).catch(_e => { - // reCaptcha.execute() throws null which is not very descriptive. - throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR); - }); + // Top-level `getToken()` has already checked that App Check is initialized + // and therefore this._app and this._platformLoggerProvider are available. + const attestedClaimsToken = await getReCAPTCHAToken(this._app!).catch( + _e => { + // reCaptcha.execute() throws null which is not very descriptive. + throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR); + } + ); return exchangeToken( - getExchangeRecaptchaTokenRequest(this._app, attestedClaimsToken), - this._platformLoggerProvider + getExchangeRecaptchaV3TokenRequest(this._app!, attestedClaimsToken), + this._platformLoggerProvider! ); } @@ -71,7 +72,7 @@ export class ReCaptchaV3Provider implements AppCheckProvider { initialize(app: FirebaseApp): void { this._app = app; this._platformLoggerProvider = _getProvider(app, 'platform-logger'); - initializeRecaptcha(app, this._siteKey).catch(() => { + initializeRecaptchaV3(app, this._siteKey).catch(() => { /* we don't care about the initialization result */ }); } @@ -88,6 +89,66 @@ export class ReCaptchaV3Provider implements AppCheckProvider { } } +/** + * App Check provider that can obtain a reCAPTCHA Enterprise token and exchange it + * for an App Check token. + * + * @public + */ +export class ReCaptchaEnterpriseProvider implements AppCheckProvider { + private _app?: FirebaseApp; + private _platformLoggerProvider?: Provider<'platform-logger'>; + /** + * Create a ReCaptchaEnterpriseProvider instance. + * @param siteKey - reCAPTCHA Enterprise score-based site key. + */ + constructor(private _siteKey: string) {} + + /** + * Returns an App Check token. + * @internal + */ + async getToken(): Promise { + // Top-level `getToken()` has already checked that App Check is initialized + // and therefore this._app and this._platformLoggerProvider are available. + const attestedClaimsToken = await getReCAPTCHAToken(this._app!).catch( + _e => { + // reCaptcha.execute() throws null which is not very descriptive. + throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR); + } + ); + return exchangeToken( + getExchangeRecaptchaEnterpriseTokenRequest( + this._app!, + attestedClaimsToken + ), + this._platformLoggerProvider! + ); + } + + /** + * @internal + */ + initialize(app: FirebaseApp): void { + this._app = app; + this._platformLoggerProvider = _getProvider(app, 'platform-logger'); + initializeRecaptchaEnterprise(app, this._siteKey).catch(() => { + /* we don't care about the initialization result */ + }); + } + + /** + * @internal + */ + isEqual(otherProvider: unknown): boolean { + if (otherProvider instanceof ReCaptchaEnterpriseProvider) { + return this._siteKey === otherProvider._siteKey; + } else { + return false; + } + } +} + /** * Custom provider class. * @public @@ -101,14 +162,6 @@ export class CustomProvider implements AppCheckProvider { * @internal */ async getToken(): Promise { - if (!this._app) { - // This should only occur if user has not called initializeAppCheck(). - // We don't have an appName to provide if so. - // This should already be caught in the top level `getToken()` function. - throw ERROR_FACTORY.create(AppCheckError.USE_BEFORE_ACTIVATION, { - appName: '' - }); - } // custom provider const customToken = await this._customProviderOptions.getToken(); // Try to extract IAT from custom token, in case this token is not diff --git a/packages/app-check/src/public-types.ts b/packages/app-check/src/public-types.ts index ae955c9d8c5..8ffed332cf5 100644 --- a/packages/app-check/src/public-types.ts +++ b/packages/app-check/src/public-types.ts @@ -16,7 +16,11 @@ */ import { FirebaseApp } from '@firebase/app'; -import { CustomProvider, ReCaptchaV3Provider } from './providers'; +import { + CustomProvider, + ReCaptchaEnterpriseProvider, + ReCaptchaV3Provider +} from './providers'; export { Unsubscribe, PartialObserver } from '@firebase/util'; /** @@ -54,9 +58,9 @@ export type _AppCheckComponentName = 'app-check'; */ export interface AppCheckOptions { /** - * reCAPTCHA provider or custom provider. + * A reCAPTCHA V3 provider, reCAPTCHA Enterprise provider, or custom provider. */ - provider: CustomProvider | ReCaptchaV3Provider; + provider: CustomProvider | ReCaptchaV3Provider | ReCaptchaEnterpriseProvider; /** * If set to true, enables automatic background refresh of App Check token. */ diff --git a/packages/app-check/src/recaptcha.test.ts b/packages/app-check/src/recaptcha.test.ts index 7edfadaf19f..2716064c6c0 100644 --- a/packages/app-check/src/recaptcha.test.ts +++ b/packages/app-check/src/recaptcha.test.ts @@ -26,12 +26,17 @@ import { findgreCAPTCHAScriptsOnPage, FAKE_SITE_KEY } from '../test/util'; -import { initialize, getToken } from './recaptcha'; +import { + initializeV3, + initializeEnterprise, + getToken, + GreCAPTCHATopLevel +} from './recaptcha'; import * as utils from './util'; import { getState } from './state'; import { Deferred } from '@firebase/util'; import { initializeAppCheck } from './api'; -import { ReCaptchaV3Provider } from './providers'; +import { ReCaptchaEnterpriseProvider, ReCaptchaV3Provider } from './providers'; describe('recaptcha', () => { let app: FirebaseApp; @@ -45,11 +50,11 @@ describe('recaptcha', () => { return deleteApp(app); }); - describe('initialize()', () => { + describe('initialize() - V3', () => { it('sets reCAPTCHAState', async () => { - self.grecaptcha = getFakeGreCAPTCHA(); + self.grecaptcha = getFakeGreCAPTCHA() as GreCAPTCHATopLevel; expect(getState(app).reCAPTCHAState).to.equal(undefined); - await initialize(app, FAKE_SITE_KEY); + await initializeV3(app, FAKE_SITE_KEY); expect(getState(app).reCAPTCHAState?.initialized).to.be.instanceof( Deferred ); @@ -68,16 +73,62 @@ describe('recaptcha', () => { }); expect(findgreCAPTCHAScriptsOnPage().length).to.equal(0); - await initialize(app, FAKE_SITE_KEY); + await initializeV3(app, FAKE_SITE_KEY); expect(findgreCAPTCHAScriptsOnPage().length).to.equal(1); }); it('creates invisible widget', async () => { const grecaptchaFake = getFakeGreCAPTCHA(); const renderStub = stub(grecaptchaFake, 'render').callThrough(); + self.grecaptcha = grecaptchaFake as GreCAPTCHATopLevel; + + await initializeV3(app, FAKE_SITE_KEY); + + expect(renderStub).to.be.calledWith(`fire_app_check_${app.name}`, { + sitekey: FAKE_SITE_KEY, + size: 'invisible' + }); + + expect(getState(app).reCAPTCHAState?.widgetId).to.equal('fake_widget_1'); + }); + }); + + describe('initialize() - Enterprise', () => { + it('sets reCAPTCHAState', async () => { + self.grecaptcha = getFakeGreCAPTCHA() as GreCAPTCHATopLevel; + expect(getState(app).reCAPTCHAState).to.equal(undefined); + await initializeEnterprise(app, FAKE_SITE_KEY); + expect(getState(app).reCAPTCHAState?.initialized).to.be.instanceof( + Deferred + ); + }); + + it('loads reCAPTCHA script if it was not loaded already', async () => { + const fakeRecaptcha = getFakeGreCAPTCHA(); + let count = 0; + stub(utils, 'getRecaptcha').callsFake(() => { + count++; + if (count === 1) { + return undefined; + } + + return fakeRecaptcha; + }); + + expect(findgreCAPTCHAScriptsOnPage().length).to.equal(0); + await initializeEnterprise(app, FAKE_SITE_KEY); + expect(findgreCAPTCHAScriptsOnPage().length).to.equal(1); + }); + + it('creates invisible widget', async () => { + const grecaptchaFake = getFakeGreCAPTCHA() as GreCAPTCHATopLevel; + const renderStub = stub( + grecaptchaFake.enterprise, + 'render' + ).callThrough(); self.grecaptcha = grecaptchaFake; - await initialize(app, FAKE_SITE_KEY); + await initializeEnterprise(app, FAKE_SITE_KEY); expect(renderStub).to.be.calledWith(`fire_app_check_${app.name}`, { sitekey: FAKE_SITE_KEY, @@ -88,7 +139,7 @@ describe('recaptcha', () => { }); }); - describe('getToken()', () => { + describe('getToken() - V3', () => { it('throws if AppCheck has not been activated yet', () => { return expect(getToken(app)).to.eventually.rejectedWith( /appCheck\/use-before-activation/ @@ -100,7 +151,7 @@ describe('recaptcha', () => { const executeStub = stub(grecaptchaFake, 'execute').returns( Promise.resolve('fake-recaptcha-token') ); - self.grecaptcha = grecaptchaFake; + self.grecaptcha = grecaptchaFake as GreCAPTCHATopLevel; initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); @@ -116,7 +167,7 @@ describe('recaptcha', () => { stub(grecaptchaFake, 'execute').returns( Promise.resolve('fake-recaptcha-token') ); - self.grecaptcha = grecaptchaFake; + self.grecaptcha = grecaptchaFake as GreCAPTCHATopLevel; initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); @@ -125,4 +176,36 @@ describe('recaptcha', () => { expect(token).to.equal('fake-recaptcha-token'); }); }); + + describe('getToken() - Enterprise', () => { + it('calls recaptcha.execute with correct widgetId', async () => { + const grecaptchaFake = getFakeGreCAPTCHA() as GreCAPTCHATopLevel; + const executeStub = stub(grecaptchaFake.enterprise, 'execute').returns( + Promise.resolve('fake-recaptcha-token') + ); + self.grecaptcha = grecaptchaFake; + initializeAppCheck(app, { + provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY) + }); + await getToken(app); + + expect(executeStub).to.have.been.calledWith('fake_widget_1', { + action: 'fire_app_check' + }); + }); + + it('resolves with token returned by recaptcha.execute', async () => { + const grecaptchaFake = getFakeGreCAPTCHA() as GreCAPTCHATopLevel; + stub(grecaptchaFake.enterprise, 'execute').returns( + Promise.resolve('fake-recaptcha-token') + ); + self.grecaptcha = grecaptchaFake; + initializeAppCheck(app, { + provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY) + }); + const token = await getToken(app); + + expect(token).to.equal('fake-recaptcha-token'); + }); + }); }); diff --git a/packages/app-check/src/recaptcha.ts b/packages/app-check/src/recaptcha.ts index 91ea94bfbd2..bf85b67dd40 100644 --- a/packages/app-check/src/recaptcha.ts +++ b/packages/app-check/src/recaptcha.ts @@ -21,8 +21,10 @@ import { Deferred } from '@firebase/util'; import { getRecaptcha, ensureActivated } from './util'; export const RECAPTCHA_URL = 'https://www.google.com/recaptcha/api.js'; +export const RECAPTCHA_ENTERPRISE_URL = + 'https://www.google.com/recaptcha/enterprise.js'; -export function initialize( +export function initializeV3( app: FirebaseApp, siteKey: string ): Promise { @@ -30,39 +32,83 @@ export function initialize( const initialized = new Deferred(); setState(app, { ...state, reCAPTCHAState: { initialized } }); + const divId = makeDiv(app); - const divId = `fire_app_check_${app.name}`; - const invisibleDiv = document.createElement('div'); - invisibleDiv.id = divId; - invisibleDiv.style.display = 'none'; + const grecaptcha = getRecaptcha(false); + if (!grecaptcha) { + loadReCAPTCHAV3Script(() => { + const grecaptcha = getRecaptcha(false); - document.body.appendChild(invisibleDiv); + if (!grecaptcha) { + // it shouldn't happen. + throw new Error('no recaptcha'); + } + queueWidgetRender(app, siteKey, grecaptcha, divId, initialized); + }); + } else { + queueWidgetRender(app, siteKey, grecaptcha, divId, initialized); + } + return initialized.promise; +} +export function initializeEnterprise( + app: FirebaseApp, + siteKey: string +): Promise { + const state = getState(app); + const initialized = new Deferred(); + + setState(app, { ...state, reCAPTCHAState: { initialized } }); + const divId = makeDiv(app); - const grecaptcha = getRecaptcha(); + const grecaptcha = getRecaptcha(true); if (!grecaptcha) { - loadReCAPTCHAScript(() => { - const grecaptcha = getRecaptcha(); + loadReCAPTCHAEnterpriseScript(() => { + const grecaptcha = getRecaptcha(true); if (!grecaptcha) { // it shouldn't happen. throw new Error('no recaptcha'); } - grecaptcha.ready(() => { - // Invisible widgets allow us to set a different siteKey for each widget, so we use them to support multiple apps - renderInvisibleWidget(app, siteKey, grecaptcha, divId); - initialized.resolve(grecaptcha); - }); + queueWidgetRender(app, siteKey, grecaptcha, divId, initialized); }); } else { - grecaptcha.ready(() => { - renderInvisibleWidget(app, siteKey, grecaptcha, divId); - initialized.resolve(grecaptcha); - }); + queueWidgetRender(app, siteKey, grecaptcha, divId, initialized); } - return initialized.promise; } +/** + * Add listener to render the widget and resolve the promise when + * the grecaptcha.ready() event fires. + */ +function queueWidgetRender( + app: FirebaseApp, + siteKey: string, + grecaptcha: GreCAPTCHA, + container: string, + initialized: Deferred +): void { + grecaptcha.ready(() => { + // Invisible widgets allow us to set a different siteKey for each widget, + // so we use them to support multiple apps + renderInvisibleWidget(app, siteKey, grecaptcha, container); + initialized.resolve(grecaptcha); + }); +} + +/** + * Add invisible div to page. + */ +function makeDiv(app: FirebaseApp): string { + const divId = `fire_app_check_${app.name}`; + const invisibleDiv = document.createElement('div'); + invisibleDiv.id = divId; + invisibleDiv.style.display = 'none'; + + document.body.appendChild(invisibleDiv); + return divId; +} + export async function getToken(app: FirebaseApp): Promise { ensureActivated(app); @@ -111,19 +157,30 @@ function renderInvisibleWidget( }); } -function loadReCAPTCHAScript(onload: () => void): void { +function loadReCAPTCHAV3Script(onload: () => void): void { + const script = document.createElement('script'); + script.src = RECAPTCHA_URL; + script.onload = onload; + document.head.appendChild(script); +} + +function loadReCAPTCHAEnterpriseScript(onload: () => void): void { const script = document.createElement('script'); - script.src = `${RECAPTCHA_URL}`; + script.src = RECAPTCHA_ENTERPRISE_URL; script.onload = onload; document.head.appendChild(script); } declare global { interface Window { - grecaptcha: GreCAPTCHA | undefined; + grecaptcha: GreCAPTCHATopLevel | undefined; } } +export interface GreCAPTCHATopLevel extends GreCAPTCHA { + enterprise: GreCAPTCHA; +} + export interface GreCAPTCHA { ready: (callback: () => void) => void; execute: (siteKey: string, options: { action: string }) => Promise; diff --git a/packages/app-check/src/util.ts b/packages/app-check/src/util.ts index 9102d335eb2..dd699141da8 100644 --- a/packages/app-check/src/util.ts +++ b/packages/app-check/src/util.ts @@ -20,7 +20,12 @@ import { getState } from './state'; import { ERROR_FACTORY, AppCheckError } from './errors'; import { FirebaseApp } from '@firebase/app'; -export function getRecaptcha(): GreCAPTCHA | undefined { +export function getRecaptcha( + isEnterprise: boolean = false +): GreCAPTCHA | undefined { + if (isEnterprise) { + return self.grecaptcha?.enterprise; + } return self.grecaptcha; } diff --git a/packages/app-check/test/util.ts b/packages/app-check/test/util.ts index bd0a77885f4..fa9f1afb41a 100644 --- a/packages/app-check/test/util.ts +++ b/packages/app-check/test/util.ts @@ -16,7 +16,12 @@ */ import { FirebaseApp, initializeApp, _registerComponent } from '@firebase/app'; -import { GreCAPTCHA, RECAPTCHA_URL } from '../src/recaptcha'; +import { + GreCAPTCHA, + GreCAPTCHATopLevel, + RECAPTCHA_ENTERPRISE_URL, + RECAPTCHA_URL +} from '../src/recaptcha'; import { Provider, ComponentContainer, @@ -102,12 +107,19 @@ export function getFakePlatformLoggingProvider( return container.getProvider('platform-logger'); } -export function getFakeGreCAPTCHA(): GreCAPTCHA { - return { +export function getFakeGreCAPTCHA( + isTopLevel: boolean = true +): GreCAPTCHATopLevel | GreCAPTCHA { + const greCaptchaTopLevel: GreCAPTCHA = { ready: callback => callback(), render: (_container, _parameters) => 'fake_widget_1', execute: (_siteKey, _options) => Promise.resolve('fake_recaptcha_token') }; + if (isTopLevel) { + (greCaptchaTopLevel as GreCAPTCHATopLevel).enterprise = + getFakeGreCAPTCHA(false); + } + return greCaptchaTopLevel; } /** @@ -119,7 +131,11 @@ export function findgreCAPTCHAScriptsOnPage(): HTMLScriptElement[] { const scriptTags = window.document.getElementsByTagName('script'); const tags = []; for (const tag of Object.values(scriptTags)) { - if (tag.src && tag.src.includes(RECAPTCHA_URL)) { + if ( + tag.src && + (tag.src.includes(RECAPTCHA_URL) || + tag.src.includes(RECAPTCHA_ENTERPRISE_URL)) + ) { tags.push(tag); } } diff --git a/packages/firebase/compat/index.d.ts b/packages/firebase/compat/index.d.ts index a81d7d37e92..f9bd191cdd6 100644 --- a/packages/firebase/compat/index.d.ts +++ b/packages/firebase/compat/index.d.ts @@ -1543,14 +1543,23 @@ declare namespace firebase.appCheck { token: string; } /* - * ReCAPTCHA v3 token provider. + * reCAPTCHA v3 token provider. */ class ReCaptchaV3Provider { /** - * @param siteKey - ReCAPTCHA v3 site key (public key). + * @param siteKey - reCAPTCHA v3 site key (public key). */ constructor(siteKey: string); } + /* + * reCAPTCHA Enterprise token provider. + */ + class ReCaptchaEnterpriseProvider { + /** + * @param keyId - reCAPTCHA Enterprise key ID. + */ + constructor(keyId: string); + } /* * Custom token provider. */ @@ -1581,8 +1590,8 @@ declare namespace firebase.appCheck { /** * Activate AppCheck * @param provider This can be a `ReCaptchaV3Provider` instance, - * a `CustomProvider` instance, an object with a custom `getToken()` - * method, or a reCAPTCHA site key. + * a `ReCaptchaEnterpriseProvider` instance, a `CustomProvider` instance, + * an object with a custom `getToken()` method, or a reCAPTCHA site key. * @param isTokenAutoRefreshEnabled If true, the SDK automatically * refreshes App Check tokens as needed. If undefined, defaults to the * value of `app.automaticDataCollectionEnabled`, which defaults to @@ -1591,6 +1600,7 @@ declare namespace firebase.appCheck { activate( provider: | ReCaptchaV3Provider + | ReCaptchaEnterpriseProvider | CustomProvider | AppCheckProvider | { getToken: () => AppCheckToken }