Minimalist security for Google Cloud Functions using Google accounts

Gareth Cronin
6 min readDec 4, 2022

--

I’ve written about my usual tiny app toolkit in the past. I generally use Firebase when I want a secure writeable API. It works particularly well for simple apps where each user essentially has their own partition of the data store (Firestore or Realtime Database). Firebase Functions also provides a very flexible way to create more complex secure APIs. It is more costly than basic Cloud Functions though, and overkill when I’m not using any other Firebase platform features.

I like using Google Sheets as a back end for master data. Spreadsheets are excellent user interfaces for maintaining master data. A spreadsheet has copy and paste, formulas, fill down/right, and a whole lot of ways to make bulk editing much easier. Google Sheets also provides a realtime, multi-user experience with version history. That’s a lot of bang for your buck.

When I’ve done used the API+Sheets approach in the past, the data has been read-only, so I just expose unsecured HTTP endpoints directly from Google Cloud Functions and consume these in my apps.

For my latest project I wanted to stretch the use of Google Sheets (and also Google Docs) a bit further. I’ll write more about the actual project some time soon, but the essential requirement is that I wanted an API that let authorised users write to spreadsheets as well. As soon as writing is involved, we obviously need some kind of authentication and authorisation.

The minimalist approach

Given all my data and compute lives in Google Cloud Platform (GCP) resources: Cloud Functions, Google Sheets, and Google Docs — I figured it was logical to secure it with same Google user accounts I was using to access the sheets and docs. I wanted to make sure that my Cloud Functions could only be called with a valid Google account session, and that only a small, defined set of Google users were authorised to make those calls.

Google have changed the way their identity platform works — deprecating the earlier system — and I found a couple of state-of-the art projects and tutorials that were along the lines of what I was wanting. This React component set is built on the new Google Identity Services SDK. The official GCP docs explain what’s available for integrating this with Cloud Functions. This tutorial introduced me to using GCP’s API Gateway to handle the OAuth for Cloud Functions.

Basic architecture

GCP API Gateway

I hadn’t used Google’s API Gateway before, but it’s very similar to the AWS one, which I am familiar with. It’s centred around an Open API definition written in YAML and is easiest to manage with the Google Cloud command line interface (CLI).

Three services need to be enabled:

gcloud services enable apigateway.googleapis.com
gcloud services enable servicemanagement.googleapis.com
gcloud services enable servicecontrol.googleapis.com

Creating a new gateway in a GCP project with the ID “myproject” goes along the lines of:

gcloud api-gateway apis create myproject-api --project=myproject
gcloud api-gateway gateways create myproject-gateway \
--api=myproject-api --api-config=myproject-api-config-v1 \
--location=us-central1 --project=myproject

A definition for the gateway can only be created, not modified. Each time you want to modify the YAML, it’s necessary to create a new config, and then update the gateway to use that config. E.g.:

# create config
gcloud api-gateway api-configs create myproject-api-config-v2 \
--api=myproject-gateway --openapi-spec=gateway.yml \
--project=myproject --backend-auth-service-account=function-invoker@myproject.iam.gserviceaccount.com

# update gateway
gcloud api-gateway gateways update myproject-gateway \
--api=myrpoject-api --api-config=myproject-api-config-v2 \
--location=us-central1 --project=myproject

One quirk I struggled with in the YAML is the way that URL parameters are handled. There is a special Google Open API extension that defines what to do with the parameters. In my case, I just wanted them added in the same order to the URL that I was forwarding to at the back end.

Another quirk is that there is no built-in support for CORS in the style of AWS. I always have a CORS function in my back end that returns the necessary headers if an OPTIONS method call comes in, but for GCP this has to be explicitly mapped in the YAML for each endpoint.

In this example, if there is a call to the gateway at/api/payroll/123 (where “123” is the paramater value) then I would like that forwarded to https://myproject/api/payroll/123. The config, including the CORS pre-flight mapping, looks like this:

  /api/payroll/{sheetid}:
post:
summary: Send payroll report
operationId: post api payroll report
parameters:
- name: sheetid
in: path
description: sheet being posted
required: true
type: string
x-google-backend:
address: https://myproject/api
path_translation: APPEND_PATH_TO_ADDRESS
responses:
"200":
description: A successful response
schema:
type: string
options:
operationId: cors send payroll
parameters:
- name: sheetid
in: path
description: sheet being queried
required: true
type: string
x-google-backend:
address: https://myproject.cloudfunctions.net/api
path_translation: APPEND_PATH_TO_ADDRESS
responses:
200:
description: Success

Adding Google OAuth2

There are a few steps to implementing minimalist security:

  • Configuring OAuth and a service account in GCP
  • Adding a Google log in component to the front end
  • Configuring the gateway for the Google identity method
  • Sending the token from the Google log in as a bearer token
  • Adding a check to my Cloud Functions for authorised users

Configuring the GCP project

The OAuth consent screen needs to be configured for the GCP project. The gateway also needs a service account to use with the function invoker permission attached. E.g.:

gcloud iam service-accounts create function-invoker --display-name="function-invoker"
gcloud projects add-iam-policy-binding myproject --member="serviceAccount:function-invoker@myproject.iam.gserviceaccount.com" --role="roles/cloudfunctions.invoker"

This service account is used when creating a gateway configuration via the backend-auth-service-account parameter.

Log in at the front end

The React component set I mentioned earlier is very easy to use. I used the provided component and jwt_decode to extract the user profile data and set it to a React state hook variable alongside the raw token for use later in calling API endpoints.

<GoogleLogin
onSuccess={credentialResponse => {

if (credentialResponse && credentialResponse.credential) {
const decoded = jwt_decode(credentialResponse.credential);
props.setUser(
{
token: credentialResponse.credential,
...decoded
}
);
}
}}
onError={() => {
console.error('Login Failed');
props.setUser(null);
}}
/>

Gateway config

The Google identity config is a top-level item in the YAML detailed here. It looks like this:

securityDefinitions:
google_id_token:
authorizationUrl: ""
flow: "implicit"
type: "oauth2"
x-google-issuer: "https://accounts.google.com"
x-google-jwks_uri: "https://www.googleapis.com/oauth2/v3/certs"
x-google-audiences: "YOUR-CLIENT-ID"

Each endpoint to be secured then just needs a reference to “google_id_token” like so:

/api/payroll/{sheetid}:
post:
summary: Send payroll report
operationId: post api payroll report
parameters:
- name: sheetid
in: path
description: sheet being posted
required: true
type: string
x-google-backend:
address: https://myproject/api
path_translation: APPEND_PATH_TO_ADDRESS
security:
- google_id_token: []
responses:
"200":
description: A successful response
schema:
type: string

Now any call to the endpoint above will fail unless a valid Google JWT is passed in the request.

Sending the Google token with API calls

I had already stored the token in the React state hook I mentioned above. I like to use Superagent for making HTTP calls. The code to send the token with the call looks like this:

const request = require('superagent');

const API_URL = 'https://myproject-gateway.uc.gateway.dev/api';

export async function getAllSheets(token) {
try {
const response = await request
.get(`${API_URL}/sheets`)
.auth(token, { type: 'bearer' });
return response.body;
}
catch (err) {
console.error('getAllSheets failed', err);
}
}

Adding the authorised user check

When Google passes the validated call from the gateway to the Cloud Function, it sends a header called X-Apigateway-Api-Userinfo. This is a Base64-encoded chunk of JSON which includes the email address of the Google user. With the help of base64 and utf-8 I used this data to make my check:

const checkAccess = (req) => {
const authHeader = req.get('X-Apigateway-Api-Userinfo');
const bytes = base64.decode(authHeader);
const text = utf8.decode(bytes);
const decoded = JSON.parse(text);
if (ALLOWED_EMAILS.includes(decoded.email)) {
return true;
}
else {
return false;
}
};

Summary

If Firebase Functions is overkill, and you are just looking for a simple way to limit access to a Cloud Function or two, this approach works well. If the authorisation is any more complex than this though, e.g. role-based access, then bringing in GCP IAM or using a tool like Auth0 is probably a better approach.

--

--

Gareth Cronin
Gareth Cronin

Written by Gareth Cronin

Technology leader in Auckland, New Zealand: start-up founder, father of two, maker of t-shirts and small software products

No responses yet