diff --git a/docs/api-reference/components/api-provider.md b/docs/api-reference/components/api-provider.md index ecc47c9b..fdc96372 100644 --- a/docs/api-reference/components/api-provider.md +++ b/docs/api-reference/components/api-provider.md @@ -131,6 +131,57 @@ used even when this option is enabled. Read more in the [documentation][gmp-usage-attribution]. +#### `fetchAppCheckToken`: () => Promise\ + +A function that returns a Promise resolving to a Firebase App Check token. +When provided, this function will be set on `google.maps.Settings.getInstance().fetchAppCheckToken` +after the Google Maps JavaScript API has loaded. + +[Firebase App Check][firebase-app-check] helps protect your Google Maps Platform API key by +blocking traffic from unauthorized sources, preventing malicious requests and +unauthorized API calls that could incur charges. It works by validating that requests +come from legitimate apps using attestation providers like [reCAPTCHA Enterprise][recaptcha-enterprise]. + +**Example usage with Firebase:** + +A custom wrapper component that initializes Firebase App Check and passes +the token fetcher to `APIProvider`: + +```tsx +import React, {PropsWithChildren, useCallback} from 'react'; +import {APIProvider, APIProviderProps} from '@vis.gl/react-google-maps'; +import {initializeApp} from 'firebase/app'; +import { + getToken, + initializeAppCheck, + ReCaptchaEnterpriseProvider +} from 'firebase/app-check'; + +// Firebase and App Check initialization +const app = initializeApp({/* ... */}); +const appCheck = initializeAppCheck(firebaseApp, { + provider: new ReCaptchaEnterpriseProvider(RECAPTCHA_SITE_KEY), + isTokenAutoRefreshEnabled: true +}); + +// custom wrapper for the APIProvider +export function CustomAPIProvider({children, ...props}) { + const fetchAppCheckToken = useCallback(() => getToken(appCheck, false), []); + + return ( + + {children} + + ); +} +``` + +For more information, see: + +- [Using App Check with Maps JavaScript API][gmp-app-check] +- [Firebase App Check documentation][firebase-app-check] +- [App Check codelab][gmp-app-check-codelab] + ### Events #### `onLoad`: () => void {#onLoad} @@ -179,6 +230,10 @@ The following hooks are built to work with the `APIProvider` Component: [gmp-lang]: https://developers.google.com/maps/documentation/javascript/localization [gmp-solutions-usage]: https://developers.google.com/maps/reporting-and-monitoring/reporting#solutions-usage [gmp-usage-attribution]: https://developers.google.com/maps/documentation/javascript/reference/map#MapOptions.internalUsageAttributionIds +[gmp-app-check]: https://developers.google.com/maps/documentation/javascript/maps-app-check +[gmp-app-check-codelab]: https://developers.google.com/codelabs/maps-platform/maps-platform-firebase-appcheck +[firebase-app-check]: https://firebase.google.com/docs/app-check +[recaptcha-enterprise]: https://cloud.google.com/security/products/recaptcha [api-provider-src]: https://github.com/visgl/react-google-maps/blob/main/src/components/api-provider.tsx [rgm-new-issue]: https://github.com/visgl/react-google-maps/issues/new/choose [gmp-channel-usage]: https://developers.google.com/maps/reporting-and-monitoring/reporting#usage-tracking-per-channel diff --git a/src/components/__tests__/api-provider.test.tsx b/src/components/__tests__/api-provider.test.tsx index dac9eaf3..abdcba5d 100644 --- a/src/components/__tests__/api-provider.test.tsx +++ b/src/components/__tests__/api-provider.test.tsx @@ -25,6 +25,8 @@ let importLibraryPromise: Promise; let resolveImportLibrary: (value: ImportLibraryResult) => void; let rejectImportLibrary: (reason?: unknown) => void; +let settingsInstance: google.maps.Settings; + const resetImportLibraryPromise = () => { ({ promise: importLibraryPromise, @@ -71,6 +73,12 @@ beforeEach(() => { window.google.maps.importLibrary = undefined; resetAPIProviderState(); resetImportLibraryPromise(); + + // Mock google.maps.Settings (missing in @googlemaps/jest-mocks) + settingsInstance = {fetchAppCheckToken: null} as google.maps.Settings; + google.maps.Settings = { + getInstance: () => settingsInstance + } as unknown as typeof google.maps.Settings; }); afterEach(() => { @@ -220,6 +228,22 @@ test('calls onError when loading the Google Maps JavaScript API fails', async () await waitFor(() => expect(onErrorMock).toHaveBeenCalled()); }); +test('sets fetchAppCheckToken on google.maps.Settings after API loads', async () => { + const mockFetchToken = jest.fn().mockResolvedValue({token: 'test-token'}); + + render( + + + + ); + + await act(async () => { + triggerMapsApiLoaded(); + }); + + expect(settingsInstance.fetchAppCheckToken).toBe(mockFetchToken); +}); + describe('internalUsageAttributionIds', () => { test('provides default attribution IDs in context', () => { render( diff --git a/src/components/api-provider.tsx b/src/components/api-provider.tsx index 9570b733..45b69ea1 100644 --- a/src/components/api-provider.tsx +++ b/src/components/api-provider.tsx @@ -106,6 +106,12 @@ export type APIProviderProps = PropsWithChildren<{ * A function that will be called if there was an error when loading the Google Maps JavaScript API. */ onError?: (error: unknown) => void; + /** + * A function that returns a Promise resolving to an App Check token. + * When provided, it will be set on `google.maps.Settings.getInstance().fetchAppCheckToken` + * after the Google Maps JavaScript API has been loaded. + */ + fetchAppCheckToken?: () => Promise; }>; // loading the Maps JavaScript API can only happen once in the runtime, so these @@ -198,7 +204,8 @@ function useGoogleMapsApiLoader(props: APIProviderProps) { language, authReferrerPolicy, channel, - solutionChannel + solutionChannel, + fetchAppCheckToken } = props; const [status, setStatus] = useState(loadingStatus); @@ -263,7 +270,7 @@ function useGoogleMapsApiLoader(props: APIProviderProps) { }; }, []); - // effect: + // effect: set and store options useEffect( () => { (async () => { @@ -360,6 +367,18 @@ function useGoogleMapsApiLoader(props: APIProviderProps) { [currentSerializedParams, onLoad, onError, importLibraryCallback, libraries] ); + // set the fetchAppCheckToken if provided + useEffect(() => { + if (status !== APILoadingStatus.LOADED) return; + + const settings = google.maps.Settings.getInstance(); + if (fetchAppCheckToken) { + settings.fetchAppCheckToken = fetchAppCheckToken; + } else if (settings.fetchAppCheckToken) { + settings.fetchAppCheckToken = null; + } + }, [status, fetchAppCheckToken]); + return { status, loadedLibraries, diff --git a/types/google.maps.d.ts b/types/google.maps.d.ts index 10078403..b017c85a 100644 --- a/types/google.maps.d.ts +++ b/types/google.maps.d.ts @@ -800,6 +800,16 @@ declare namespace google.maps { } } + interface MapsAppCheckTokenResult { + token: string; + } + + interface Settings { + fetchAppCheckToken: + | (() => Promise) + | null; + } + /** * Maps3D Library interface for use with importLibrary('maps3d'). */