Validate Places API requests with Firebase AppCheck and reCAPTCHA

Validate Places API requests with Firebase AppCheck and reCAPTCHA

About this codelab

subjectLast updated Dec 10, 2024
account_circleWritten by Thomas Anglaret

1. Before you begin

To ensure the legitimacy of users interacting with your web application, you'll implement Firebase App Check, leveraging reCAPTCHA JWT tokens to verify user sessions. This setup will enable you to securely handle requests from the client application to the Places API (New).

b40cfddb731786fa.png

Live Link

What you will build.

To demonstrate this, you'll create a web app that displays a map upon loading. It will also discreetly generate a reCAPTCHA token using the Firebase SDK. This token is then sent to your Node.js server, where Firebase validates it before fulfilling any requests to the Places API.

If the token is valid, Firebase App Check will store it until it expires, eliminating the need to create a new token for every client request. If the token is invalid, the user will be prompted to complete the reCAPTCHA verification again to obtain a fresh token.

2. Prerequisites

You'll need to familiarize yourself with the items below to complete this Codelab. daea823b6bc38b67.png

Required Google Cloud Products

  • Google Cloud Firebase App Check: database for tokens management
  • Google reCAPTCHA: token creation and verification. It is a tool used to distinguish humans from bots on websites. It works by analyzing user behavior, browser attributes, and network information to generate a score indicating the likelihood of the user being a bot. If the score is high enough, the user is considered human, and no further action is needed. If the score is low, a CAPTCHA puzzle may be presented to confirm the user's identity. This approach is less intrusive than traditional CAPTCHA methods, making the user experience smoother.
  • (Optional) Google Cloud App Engine: deployment environment.

Required Google Maps Platform Products

In this Codelab, you'll use the following Google Maps Platform products:

Other Requirements for this Codelab

To complete this Codelab, you'll need the following accounts, services, and tools:

  • A Google Cloud Platform account with billing enabled
  • A Google Maps Platform API key with the Maps JavaScript API and Places enabled
  • Basic knowledge of JavaScript, HTML, and CSS
  • Basic knowledge of Node.js
  • A text editor or IDE of your choice

3. Get Set Up

Set up Google Maps Platform

If you do not already have a Google Cloud Platform account and a project with billing enabled, please see the Getting Started with Google Maps Platform guide to create a billing account and a project.

  1. In the Cloud Console, click the project drop-down menu and select the project that you want to use for this codelab.

e7ffad81d93745cd.png

  1. Enable the Google Maps Platform APIs and SDKs required for this codelab in the Google Cloud Marketplace. To do so, follow the steps in this video or this documentation.
  2. Generate an API key in the Credentials page of Cloud Console. You can follow the steps in this video or this documentation. All requests to Google Maps Platform require an API key.

Application Default Credentials

You'll use Firebase Admin SDK to interact with your Firebase project as well as making requests to Places API and will need to provide valid credentials for it to work.

We will use ADC Authentication (Automatic Default Credentials) to authenticate your server to make requests. Alternatively (not recommended) you can create a service account and store credentials within your code.

Definition: Application Default Credentials (ADC) is a mechanism Google Cloud provides to automatically authenticate your applications without explicitly managing credentials. It looks for credentials in various locations (like environment variables, service account files, or Google Cloud metadata server) and uses the first one it finds.

  • In your Terminal, use the below command that allows your applications to securely access Google Cloud resources on behalf of the currently logged-in user:
gcloud auth application-default login
  • You will create an .env file at root that specifies a Google Cloud Project variable:
GOOGLE_CLOUD_PROJECT="your-project-id"

Create a service account

Credentials

  • Click on service account created
  • Go in KEYS tab to Create a Key > JSON > save downloaded json credentials. Move the xxx.json file auto downloaded into your root folder
  • (Next Chapter) Name it correctly into the nodejs file server.js (​​firebase-credentials.json)

4. Firebase AppCheck Integration

You will obtain Firebase configuration details and reCAPTCHA secret keys.

You will paste them into the demo application and start the server.

Create an application in Firebase

SELECT the Google Cloud project that was already created (you may have to specify: "Selecting the parent resource")"

a6d171c6d7e98087.png a16010ba102cc90b.png

  • Add an Application from the top left Menu (cog)

18e5a7993ad9ea53.png 4632158304652118.png

Firebase initialization code

  • Save Firebase initialization code to paste in the script.js (next chapter) for the client side

f10dcf6f5027e9f0.png

  • Register your app to allow Firebase to use reCAPTCHA v3 tokens

https://console.firebase.google.com/u/0/project/YOUR_PROJECT/appcheck/apps

da7efe203ce4142c.png

  • Choose reCAPTCHA → create a key in reCAPTCHA website (with right domains configured: localhost for app dev)

b47eab131617467.png e6bddef9d5cf5460.png

  • Paste the reCAPTCHA Secret in Firebase AppCheck

a63bbd533a1b5437.png

  • App status should turn green

4f7962b527b78ee5.png

5. Demo application

  • Client Web App: HTML, JavaScript, CSS files
  • Server: Node.js file
  • Environment (.env): API keys
  • Configuration (app.yaml): Google App Engine deployment settings

Node.js Setup:

  • Navigate: Open your terminal and navigate to the root directory of your cloned project.
  • Install Node.js (if needed): version 18 or higher.
node -v  # Check installed version
  • Initialize Project: Run the following command to initialize a new Node.js project, leaving all settings as default:
npm init 
  • Install Dependencies: Use the following command to install the required project dependencies:
npm install @googlemaps/places firebase-admin express axios dotenv

Configuration: Environment Variables for Google Cloud Project

  • Environment File Creation: At the root directory of your project, create a file named .env. This file will store sensitive configuration data and should not be committed to version control.
  • Populate Environment Variables: Open the .env file and add the following variables, replacing the placeholders with the actual values from your Google Cloud Project:
# Google Cloud Project ID
GOOGLE_CLOUD_PROJECT="your-cloud-project-id"

# reCAPTCHA Keys (obtained in previous steps)
RECAPTCHA_SITE_KEY="your-recaptcha-site-key"
RECAPTCHA_SECRET_KEY="your-recaptcha-secret-key"

# Maps Platform API Keys (obtained in previous steps)
PLACES_API_KEY="your-places-api-key"
MAPS_API_KEY="your-maps-api-key"

6. Code overview

index.html

  • Loads the firebase libraries to create the token in the app
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Places API with AppCheck</title>
  <style></style>  </head>
<body>
  <div id="map"></div>

    <!-- Firebase services -->
  <script src="https://www.gstatic.com/firebasejs/9.15.0/firebase-app-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/9.15.0/firebase-app-check-compat.js"></script>
 
  <script type="module" src="./script.js"></script>
  <link rel="stylesheet" href="./style.css">
</body>
</html>

script.js

  • Fetches API Keys: Retrieves API keys for Google Maps and Firebase App Check from a backend server.
  • Initializes Firebase: Sets up Firebase for authentication and security. (Replace configuration → see Chapter 4).

The validity duration of the Firebase App Check token, ranging from 30 minutes to 7 days, is configured within the Firebase console and cannot be altered by attempting to force a token refresh.

  • Activates App Check: Enables Firebase App Check to verify the authenticity of incoming requests.
  • Loads Google Maps API: Dynamically loads the Google Maps JavaScript library to display the map.
  • Initializes the Map: Creates a Google Map centered on a default location.
  • Handles Map Clicks: Listens for clicks on the map and updates the center point accordingly.
  • Queries Places API: Sends requests to a backend API (/api/data) to fetch information about places (restaurants, parks, bars) near the clicked location, using Firebase App Check for authorization.
  • Displays Markers: Plots the fetched data on the map as markers, showing their names and icons.
let mapsApiKey, recaptchaKey; // API keys
let currentAppCheckToken = null; // AppCheck token

async function init() {
  try {
    await fetchConfig(); // Load API keys from .env variable

    /////////// REPLACE with your Firebase configuration details
    const firebaseConfig = {
      apiKey: "AIza.......",
      authDomain: "places.......",
      projectId: "places.......",
      storageBucket: "places.......",
      messagingSenderId: "17.......",
      appId: "1:175.......",
      measurementId: "G-CPQ.......",
    };
    /////////// REPLACE

    // Initialize Firebase and App Check
    await firebase.initializeApp(firebaseConfig);
    await firebase.appCheck().activate(recaptchaKey);

    // Get the initial App Check token
    currentAppCheckToken = await firebase.appCheck().getToken();

    // Load the Maps JavaScript API dynamically
    const scriptMaps = document.createElement("script");
    scriptMaps.src = `https://maps.googleapis.com/maps/api/js?key=${mapsApiKey}&libraries=marker,places&v=beta`;
    scriptMaps.async = true;
    scriptMaps.defer = true;
    scriptMaps.onload = initMap; // Create the map after the script loads
    document.head.appendChild(scriptMaps);
  } catch (error) {
    console.error("Firebase initialization error:", error);
    // Handle the error appropriately (e.g., display an error message)
  }
}
window.onload = init()

// Fetch configuration data from the backend API
async function fetchConfig() {
  const url = "/api/config";

  try {
    const response = await fetch(url);
    const config = await response.json();
    mapsApiKey = config.mapsApiKey;
    recaptchaKey = config.recaptchaKey;
  } catch (error) {
    console.error("Error fetching configuration:", error);
    // Handle the error (e.g., show a user-friendly message)
  }
}

// Initialize the map when the Maps API script loads
let map; // Dynamic Map
let center = { lat: 48.85557501, lng: 2.34565006 };
function initMap() {
  map = new google.maps.Map(document.getElementById("map"), {
    center: center,
    zoom: 13,
    mapId: "b93f5cef6674c1ff",
    zoomControlOptions: {
      position: google.maps.ControlPosition.RIGHT_TOP,
    },
    streetViewControl: false,
    mapTypeControl: false,
    clickableIcons: false,
    fullscreenControlOptions: {
      position: google.maps.ControlPosition.LEFT_TOP,
    },
  });

  // Initialize the info window for markers
  infoWindow = new google.maps.InfoWindow({});

  // Add a click listener to the map
  map.addListener("click", async (event) => {
    try {
      // Get a fresh App Check token on each click
      const appCheckToken = await firebase.appCheck().getToken();
      currentAppCheckToken = appCheckToken;

      // Update the center for the Places API query
      center.lat = event.latLng.lat();
      center.lng = event.latLng.lng();

      // Query for places with the new token and center
      queryPlaces();
    } catch (error) {
      console.error("Error getting App Check token:", error);
    }
  });
}

function queryPlaces() {
  const url = '/api/data'; // "http://localhost:3000/api/data"

  const body = {
    request: {
      includedTypes: ['restaurant', 'park', 'bar'],
      excludedTypes: [],
      maxResultCount: 20,
      locationRestriction: {
        circle: {
          center: {
            latitude: center.lat,
            longitude: center.lng,
          },
          radius: 4000,
        },
      },
    },
  };

  // Provides token to the backend using header: X-Firebase-AppCheck

  fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Firebase-AppCheck': currentAppCheckToken.token,
    },
    body: JSON.stringify(body),
  })
    .then((response) => response.json())
    .then((data) => {
      // display if response successful
      displayMarkers(data.places);
    })
    .catch((error) => {
      alert('No places');
      // eslint-disable-next-line no-console
      console.error('Error:', error);
    });
}


//// display places markers on map
...

server.js

  • Loads environment variables (API keys, Google project ID) from a .env file.
  • Starts the server, listening for requests on http://localhost:3000.
  • Initializes Firebase Admin SDK using Application Default Credentials (ADC).
  • Receives a reCAPTCHA token from script.js.
  • Verifies the validity of the received token.
  • If the token is valid, makes a POST request to the Google Places API with included search parameters.
  • Processes and returns the response from the Places API to the client.
const express = require('express');
const axios = require('axios');

const admin = require('firebase-admin');

// .env variables
require('dotenv').config();

// Store sensitive API keys in environment variables
const recaptchaSite = process.env.RECAPTCHA_SITE_KEY;
const recaptchaSecret = process.env.RECAPTCHA_SECRET_KEY;
const placesApiKey = process.env.PLACES_API_KEY;
const mapsApiKey = process.env.MAPS_API_KEY;

// Verify environment variables loaded (only during development)
console.log('recaptchaSite:', recaptchaSite, '\n');
console.log('recaptchaSecret:', recaptchaSecret, '\n');

const app = express();
app.use(express.json());

// Firebase Admin SDK setup with Application Default Credentials (ADC)
const { GoogleAuth } = require('google-auth-library');
admin.initializeApp({
  // credential: admin.credential.applicationDefault(), // optional: explicit ADC
});

// Main API Endpoint
app.post('/api/data', async (req, res) => {
  const appCheckToken = req.headers['x-firebase-appcheck'];

  console.log("\n", "Token", "\n", "\n", appCheckToken, "\n")

  try {
    // Verify Firebase App Check token for security
    const appCheckResult = await admin.appCheck().verifyToken(appCheckToken);

    if (appCheckResult.appId) {
      console.log('App Check verification successful!');
      placesQuery(req, res);
    } else {
      console.error('App Check verification failed.');
      res.status(403).json({ error: 'App Check verification failed.' });
    }
  } catch (error) {
    console.error('Error verifying App Check token:', error);
    res.status(500).json({ error: 'Error verifying App Check token.' });
  }
});

// Function to query Google Places API
async function placesQuery(req, res) {
  console.log('#################################');
  console.log('\n', 'placesApiKey:', placesApiKey, '\n');

  const queryObject = req.body.request;
  console.log('\n','Request','\n','\n', queryObject, '\n')

  const headers = {
    'Content-Type': 'application/json',
    'X-Goog-FieldMask': '*',
    'X-Goog-Api-Key': placesApiKey,
    'Referer': 'http://localhost:3000',  // Update for production(ie.: req.hostname)
  };

  const myUrl = 'https://places.googleapis.com/v1/places:searchNearby';

  try {
    // Authenticate with ADC
    const auth = new GoogleAuth();
    const { credential } = await auth.getApplicationDefault();

    const response = await axios.post(myUrl, queryObject, { headers, auth: credential });
   
    console.log('############### SUCCESS','\n','\n','Response','\n','\n', );
    const myBody = response.data;
    myBody.places.forEach(place => {
      console.log(place.displayName);
    });
    res.json(myBody); // Use res.json for JSON data
  } catch (error) {
    console.log('############### ERROR');
    // console.error(error); // Log the detailed error for debugging
    res.status(error.response.status).json(error.response.data); // Use res.json for errors too
  }
}

// Configuration endpoint (send safe config data to the client)
app.get('/api/config', (req, res) => {
  res.json({
    mapsApiKey: process.env.MAPS_API_KEY,
    recaptchaKey: process.env.RECAPTCHA_SITE_KEY,
  });
});

// Serve static files
app.use(express.static('static'));

// Start the server
const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Server listening on port ${port}`, '\n');
});

7. Run the application

From your chosen environment, run server from the terminal and navigate to the http://localhost:3000

npm start 

A token is created as a global variable, hidden from the user's browser window, and transmitted to the server for processing. Details of the token can be found in the server logs.

Details about server's functions and response to the Places API Nearby Search request can be found in the server logs.

Troubleshooting:

Make sure the Google Project ID is consistent in the setup:

  • in the .env file (GOOGLE_CLOUD_PROJECT variable)
  • in the terminal gcloud configuration:
gcloud config set project your-project-id
  • in the reCaptcha setup

e6bddef9d5cf5460.png

  • in the Firebase setup

7e17bfbcb8007763.png

Other

  • Create a debug token that can be used in place of the reCAPTCHA site key within script.js for testing and troubleshooting purposes.

9c0beb760d13faef.png

try {
 // Initialize Firebase first
 await firebase.initializeApp(firebaseConfig);
  // Set the debug token
  if (window.location.hostname === 'localhost') { // Only in development
    await firebase.appCheck().activate(
      'YOUR_DEBUG_FIREBASE_TOKEN', // Replace with the token from the console
      true // Set to true to indicate it's a debug token
      );
  } else {
      // Activate App Check
      await firebase.appCheck().activate(recaptchaKey);
}
  • Trying too many unsuccessful authentication, ie: using false recaptcha site key, may trigger a temporary throttling.
FirebaseError: AppCheck: Requests throttled due to 403 error. Attempts allowed again after 01d:00m:00s (appCheck/throttled).

ADC Credentials

  • Make sure you are in the right gcloud account
gcloud auth login 
  • Make sure necessary libraries are installed
npm install @googlemaps/places firebase-admin
  • Make sure in server.js Firebase library is loaded
const {GoogleAuth} = require('google-auth-library');
gcloud auth application-default login
  • Impersonate: ADC credentials saved
gcloud auth application-default login --impersonate-service-account your_project@appspot.gserviceaccount.com
  • Ultimately test locally ADC, saving following script as test.js and running in Terminal: node test.js
const {GoogleAuth} = require('google-auth-library');

async function requestTestADC() {
 try {
   // Authenticate using Application Default Credentials (ADC)
   const auth = new GoogleAuth();
   const {credential} = await auth.getApplicationDefault();

   // Check if the credential is successfully obtained
   if (credential) {
     console.log('Application Default Credentials (ADC) loaded successfully!');
     console.log('Credential:', credential); // Log the credential object
   } else {
     console.error('Error: Could not load Application Default Credentials (ADC).');
   }

   // ... rest of your code ...

 } catch (error) {
   console.error('Error:', error);
 }
}

requestTestADC();

8. That's it, Well done!

Follow-up steps

Deployment to App Engine:

  • Prepare your project for deployment to Google App Engine, making any necessary configuration changes.
  • Use the gcloud command-line tool or the App Engine console to deploy your application.

Enhance Firebase Authentication:

  • Default vs Custom Tokens: Implement Firebase custom tokens for deeper use of Firebase services.
  • Token Lifetime: Set appropriate token lifetimes, shorter for sensitive operations (custom Firebase token up to one hour), longer for general sessions (reCAPTCHA token: 30min to 7 hours).
  • Explore Alternatives to reCAPTCHA: Investigate whether DeviceCheck (iOS), SafetyNet (Android), or App Attest are suitable for your security needs.

Integrate Firebase Products:

  • Realtime Database or Firestore: If your application needs real-time data synchronization or offline capabilities, integrate with Realtime Database or Firestore.
  • Cloud Storage: Use Cloud Storage for storing and serving user-generated content like images or videos.
  • Authentication: Leverage Firebase Authentication to create user accounts, manage login sessions, and handle password resets.

Expand to Mobile:

  • Android and iOS: If you plan to have a mobile app, create versions for both Android and iOS platforms.
  • Firebase SDKs: Use the Firebase SDKs for Android and iOS to seamlessly integrate Firebase features into your mobile apps.