react-google-maps icon indicating copy to clipboard operation
react-google-maps copied to clipboard

[Feat] Allow AppCheck Tokens to be Passed to Google Maps JS API

Open mmhenrimm opened this issue 8 months ago • 12 comments

Target Use Case

For AppCheck protected API's (documentation), Google Maps JS API expects an extra query parameter (appCheckToken) to be passed in when fetching from https://maps.googleapis.com/maps/api/js. As far as I can tell, there is no way to pass an AppCheck token to the APIProvider. This means that this library is not currently compatible with AppCheck at all.

Proposal

I believe this would be as simple as adding a new optional parameter to APIProviderProps here and to ApiProps here. If I'm tracing the code correctly, this would get wired into the URL parameters here for "free".

mmhenrimm avatar May 14 '25 06:05 mmhenrimm

Thanks for bringing that up – we should address that ASAP.

But I can't find a reference to passing the token via a query parameter in the documentation. In step 4 of this guide it is mentioned that we need to provide a callback function fetchAppCheckToken.

As for our implementation, I think we can add support for the appCheckToken via a prop of the APIProvider, not sure if that should be a callback or a string, maybe we should support both.

So both of these would work:

<APIProvider apiKey="..." appCheckToken={() => retrieveAppCheckToken()}>
  ...
</APIProvider>
<APIProvider apiKey="..." appCheckToken={myAppCheckToken}>
  ...
</APIProvider>

What do you think?

usefulthink avatar May 14 '25 13:05 usefulthink

You are absolutely correct - a previous library I was using was passing it directly so I must've assumed that was the correct way to do it, but reading over the documentation again I don't see it.

After some tinkering, I was able to get this working with something like this, but as you can see it's not exactly intuitive or pretty so I do still think it would be nice to abstract it away:

const initAppCheckToken = (apiProviderContext: any) => {
    const coreLibrary = apiProviderContext?.loadedLibraries['core'];
    const settings = coreLibrary?.Settings.getInstance();
    // @ts-ignore - fetchAppCheckToken exists but is not in type definitions
    if (settings) {
      settings.fetchAppCheckToken = () => getToken(appCheck);
    }
    return '';
}

return (
    <APIProvider apiKey={process.env.REACT_APP_GOOGLE_MAPS_API_KEY}>
        <APIProviderContext.Consumer>
            {(apiProviderContext) => (
                <Fragment>
                    {initAppCheckToken(apiProviderContext)}
                    <Map ... />
                </Fragment>
            )}
        </APIProviderContext.Consumer>
    </APIProvider>
);

As for how it should be as a prop - the AppCheck API returns a call back so I'm on-board with taking in the call-back instead of a string.

mmhenrimm avatar May 14 '25 14:05 mmhenrimm

Try it like this:

const AppCheckHandler = () => {
  const coreLib = useMapsLibrary('core');
  useEffect(() => {
    if(!coreLib) return;
    
    const settings = coreLib.Settings.getInstance();
    settings.fetchAppCheckToken = async () => {
      // ... whatever ... 
    };
  }, [coreLib]);

  return null;
}

const App = () => {
  return (
    <APIProvider ...>
      <AppCheckHandler />
    </APIProvider>
  );
}

usefulthink avatar May 14 '25 16:05 usefulthink

That worked great - appreciate your help - not sure if it still makes sense to add this as a prop to APIProvider but I'm satisfied with the approach you suggested from my end.

mmhenrimm avatar May 14 '25 23:05 mmhenrimm

AppCheck is likely going to gain usage over time, so it would still make sense to add it to the APIProviderProps or add a hook to make it easy to use.

usefulthink avatar May 15 '25 09:05 usefulthink

Hey @usefulthink

I'm currenrtly building an app where I'm using Firebase and the Google Maps API, and I saw that Google recommends this library to embed a map into a React.js application.

I'm also working with AppCheck, and it would be great to have a more simple approach of adding the AppCheck token to the <APIProvider /> component.

Are there any updates on this feature request, has it been looked into?

For now I think I'll utilize the solution that you mentioned here, which seemed to work fine for others.

fredrikj31 avatar Jul 04 '25 04:07 fredrikj31

@fredrikj31 I'm not sure what the ergonomics should be like. How would you prefer having this handled?

Some Ideas:

Handled by APIProvider

import { initializeAppCheck } from 'firebase/app-check';

// This looks nice, but it would create a dependency on 
// `firebase/app-check` for the APIProvider that I don't really 
// want to have in this library.

const App = () => {
  const appCheck = initializeAppCheck(...);

  return (
    <APIProvider apiKey="..." appCheck={appCheck}>
      // ...
    </APIProvider>
  );
};

Handled by APIProvider, no dependencies

import { initializeAppCheck, getToken } from 'firebase/app-check';

// similar, but keeps the `getToken` dependency out of the APIProvider component

const App = () => {
  const appCheck = initializeAppCheck(...);
  const fetchAppCheckToken = async () => getToken(appCheck);

  return (
    <APIProvider apiKey="..." fetchAppCheckToken={fetchAppCheckToken}>
      // ...
    </APIProvider>
  );
};

separate component, appCheck instance as prop

import {AppCheckHandler} from '@vis.gl/react-google-maps';

const App = () => {
  const appCheck = initializeAppCheck(...);

  return (
    <APIProvider apiKey="...">
      <AppCheckHandler appCheck="{appCheck}" />

      // ...
    </APIProvider>
  );
};

usefulthink avatar Jul 04 '25 14:07 usefulthink

Hey @usefulthink

Looking through your examples, I'm heavily leaning towards option 2 (Handled by APIProvider, no dependencies), where you just provide the AppCheck token and then everything regarding updating and managing the token is up to the developer to manage.

I agree with you, that this library shouldn't have the AppCheck as a dependency, because I can people want to utilize this library for embedding maps, without having AppCheck or using Firebase in general.

So keeping everything out, so the only thing that the <APIProvider /> needs is the token itself. (it could be that the implementation of how to the token is wrong)

I would suggest that you could just pass in the token in it's plain string format like this:

import { initializeAppCheck, getToken } from 'firebase/app-check';

// similar, but keeps the `getToken` dependency out of the APIProvider component

const App = () => {
  const appCheck = initializeAppCheck(...);
  const appCheckToken = getToken(appCheck);

  return (
    <APIProvider apiKey="..." appCheckToken={appCheckToken}>
      // ...
    </APIProvider>
  );
};

As an example I have this Firebase React.js context where I control everything related to Firebase:

export const FirebaseProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const app = initializeApp(firebaseConfig);

  initializeAppCheck(app, {
    provider: new ReCaptchaV3Provider(config.recaptcha.siteKey),
    isTokenAutoRefreshEnabled: true,
  });

  const analytics = getAnalytics(app);
  const auth = getAuth(app);
  const firestore = getFirestore(app);

  return (
    <FirebaseContext.Provider
      value={{
        analytics,
        auth,
        firestore,
      }}
    >
      {children}
    </FirebaseContext.Provider>
  );
};

I think it could be nice to just utilize this Provider to manage the AppCheck token, so as said, the only thing needed for the map, is to provide the token itself.

Hope it all makes sense 😅

Thanks for reaching out, and looking into how we can implement it 🙏

fredrikj31 avatar Jul 04 '25 18:07 fredrikj31

That is interesting – maybe I misunderstood how this actually works.

The way I understood it was that we can't rely on the token being a single stable value for the entire session. I thought that would be why we have to provide a function to the maps API that generates the token as opposed to the token itself. See here for reference: https://developers.google.com/maps/documentation/javascript/places-app-check

Have you seen documentation that explains this in more detail?

EDIT TO ADD:

According to this documentation the Provider only grants tokens with an exiration time. So it would make sense for the maps API to request a token either for every request that needs it or when it hits the expiration time.

I'm wondering if we could implement a reliable way to handle this outside of the maps API (i.e. make sure there is always a valid token in the appCheckToken prop). Otherwise I think the fetchAppCheckToken prop would be the safer option.

usefulthink avatar Jul 04 '25 19:07 usefulthink

Ohhh, I have maybe missunderstood it as well 😅

It seems like the AppCheck token has a TTL, and AppCheck does the re-attestation when half the TTL has gone by.

I think the approach you were talking about, by providing a function into the prop of the <APIProvider /> component would be the best option.

If we choose to do so, then we still don't need the Appcheck dependency into the library, but we can make sure that the library tries to refresh the token on API calls, and it gets the token when it needs it.

This also allows the user who is using the library to control what the fetchAppCheckToken function does. But I think there should be a constraint. The fetchAppCheckToken, should always return the type of the token, so we can ensure that we don't get anything that the <APIProvider /> don't want.

An example of this could be:

import { initializeAppCheck, getToken, type Token } from 'firebase/app-check';

const App = () => {
  const appCheck = initializeAppCheck(...);
  const appCheckToken = getToken(appCheck);

  const fetchAppCheckToken = (): Token => {
    const appCheckToken = getToken(appCheck);
    // Maybe we want some other logic here

    return appCheckToken
  }

  return (
    <APIProvider apiKey="..." fetchAppCheckToken={fetchAppCheckToken}>
      // ...
    </APIProvider>
  );
};

fredrikj31 avatar Jul 07 '25 05:07 fredrikj31

And in your case, you could put that function neatly away into your FirebaseProvider and expose it via a hook const fetchToken = useAppCheckToken(); where the APIProvider is created.

usefulthink avatar Jul 07 '25 07:07 usefulthink

Exactly. I think for both the library and the devs who is using the library, the approach of just providing a function which returns the Token type, would be the best solution.

fredrikj31 avatar Jul 07 '25 09:07 fredrikj31