Introduction
This post started as a simple guide on how to configure the Fastify web framework to run inside a Google Cloud Function, but its focus quickly switched to a deeper analysis on the authentication to Google Cloud Functions (GCF from now on).
GCF is the FaaS (Function as a Service) platform provided by Google. It’s a serverless execution environment for building and connecting cloud services. With Cloud Functions you write simple, single-purpose functions that are attached to events emitted from your cloud infrastructure and services.
There are two distinct types of Google Cloud Functions provided by the Google Cloud Platform:
- HTTP functions. Invoked by HTTP requests
- Event-driven functions. They can be either background functions or CloudEvent functions that are invoked indirectly in response to events, such as a message on a Pub/Sub topic, a change in a Cloud Storage bucket, or a Firebase event.
Build the Fastify app
Fastify is a web framework for NodeJS, highly focused on providing the best developer experience with the least overhead and a powerful plugin architecture. In the last few years it has quickly become one of the most popular NodeJS web frameworks reaching 17.500 stars on GitHub. Fastify v3.0 is the latest version and has been available since July 2020.
The Fastify documentation already provides instructions on how to configure the framework in order to run on Serverless environments, but it doesn’t explicitly mention how to configure the framework to target the GCF platform. However it’s not much different than running code in a AWS Lambda function or in a Google Cloud Run environment.
Notice: As the official documentation states, Fastify is not designed to run on serverless environments and probably it doesn’t even make sense to use a full featured web framework inside a stateless function, even though it’s totally possible.
First of all let’s create the main function that builds the application:
// app.js
'use strict'
const fastify = require('fastify')
function build (opts = {}) {
const app = fastify(opts)
app.register(require('./routes.js'))
return app
}
module.exports = build
As you can see, we’re declaring the fastify
app (with the options passed as parameter) and we immediately register the application routes. For simplicity let’s suppose that we only have the home
route:
// routes.js
'use strict'
module.exports = async function (fastify, opts) {
fastify.register(require('./routes/home'))
}
In the route we only return the application name and version number, retrieved from the package.json
file.
// home.js
const packageJson = require('./package.json')
async function home (fastify, options) {
fastify.get('/', async (request, reply) => {
return {
name: packageJson.name,
version: packageJson.version
}
})
}
module.exports = home
Now we can define a server.js
file that uses the application in a local dev environment:
'use strict'
const server = require('./app')({
logger: true
})
server.listen(process.env.PORT || 3000, (err, address) => {
if (err) {
server.log.error(err.message)
process.exit(1)
}
})
So far so good! We can start our server using node server.js
and we can see the response by visiting the http://127.0.0.1:3000
.
Install the Google Cloud Functions Framework
The easiest way to add support for the GCF platform to our simple Fastify app is to use the Google Functions Framework.
The Functions Framework is an abstraction layer which aims to unmarshal incoming HTTP requests into language-specific function invocations. This library lets you write lightweight functions that run in many different environments, including:
- Your local development machine
- Cloud Functions
- Cloud Run
- Knative-based environments
The Functions Framework is available for several runtimes, including Go, Java, PHP and Python. As you can imagine, we’re interested in the Node.js version, which is based on the popular Express web framework.
Wait! Are you telling me that we are going to run a web framework (Fastify) inside another web framework (Express)? That doesn’t sound good!
Yes, precisely! I’m aware that this is not a good idea in production environments, however we can continue the analysis for instructional purposes. By the way, do you know that you can also run Express inside Fastify? 🤯
Notice:
GCF supports the Node.js 10, 12 and 14 (public preview) runtimes. These runtimes are based on the Functions Framework. On Cloud Functions, the Functions Framework is completely optional: if you don’t add it to your package.json
, it will be installed automatically during the deployment.
Let’s install it
$ npm install @google-cloud/functions-framework
then we can add a separate index.js
file as the entrypoint.
Notice:
Cloud Functions uses the main
field in your package.json
file as the entry point. If the main field is not specified, Cloud Functions by default loads code from index.js
.
Fastify lets you create a custom http server based on a factory, so we can attach the handler exposed by the functions framework to Fastify. We can also reuse the initialization code from the app.js
file:
'use strict'
const http = require('http')
let handleRequest = null
const serverFactory = (handler, opts) => {
handleRequest = handler
return http.createServer()
}
const app = require('./app')({
serverFactory,
logger: process.env.NODE_ENV === "development"
})
// see https://github.com/fastify/fastify/issues/946#issuecomment-766319521
// this is necessary if you want to support JSON inputs in POST/PATCH requests
app.addContentTypeParser('application/json', {}, (req, body, done) => {
done(null, body.body);
});
exports.fastifyFunction = (req, res) => {
app.ready(err => {
if (err) throw err
handleRequest(req, res)
})
}
Cloud Functions have a name property that is set at deploy time, and once set, it cannot be changed. The name of the function is used as its identifier and it must be unique within a region.
In our case the name of our function will be fastifyFunction
, as you can see from the function exported at the end of the file.
The functions framework also lets you test this setup locally:
$ npx @google-cloud/functions-framework --target=fastifyFunction
Serving function...
Function: fastifyFunction
URL: http://localhost:8080/
As you can see, by default your function will be accessible at localhost:8080
unless you explicitly define a value for the PORT
environment variable.
Deploy the app on Google Cloud Functions
The fastest way to deploy code on the Google Cloud Platform is to use the Google Cloud SDK from the command line. Follow the install instructions for your OS and then make sure to run gcloud init
to configure the integration with your GCP project.
Once configured you have to make sure that billing is enabled for your Cloud project and that you have the Cloud Functions and Cloud Build APIs enabled.
Deployments work by uploading an archive containing your function’s source code to a Google Cloud Storage bucket. Once the source code has been uploaded, Cloud Build automatically builds your code into a container image and pushes that image to Container Registry. Cloud Functions uses that image to create the container that executes your function.
To deploy the function using the gcloud
tool you can run the following command
$ gcloud functions deploy fastifyFunction --runtime nodejs12 --trigger-http --allow-unauthenticated
--runtime
defines the name of the runtime you intend to use. In this case we want Node.js 12 1--trigger-http
declares that we want to trigger the function via HTTP--allow-unauthenticated
specifies that the function does not require authentication. By default http functions require authentication.
Once the deploy process finishes you can take note of the httpsTrigger.url
property and invoke the function from curl
or from your browser. If you forgot the endpoint you can describe the function anytime to inspect its properties:
$ gcloud functions describe fastifyFunction
The url should look like this:
https://GCP_REGION-PROJECT_ID.cloudfunctions.net/fastifyFunction
Alternatively you can directly invoke functions using the gcloud
CLI interface
$ gcloud functions call fastifyFunction
Notes about billing
Make sure to carefully review the pricing page for the GCF platform. It’s not uncommon nowadays to read stories of how companies nearly went bankrupt after burning a ton of money into Cloud providers.
The pricing for Google Cloud Functions include the following:
- Invocation
- Compute Time
- Networking
- Cloud Build pricing
- Cloud Storage pricing
- Container Registry pricing
- Cloud Logging pricing
Almost all these services provide a free tier. Make sure to review the pricing documents for further details.
Add Authentication
Now let’s see how we can add authentication to our cloud function, so we can control who’s invoking it.
One of the options at our disposal is adding a Cloud Endpoint with the Extensible Service Proxy V2 (ESPv2) as an API Gateway in front of it. This solution is too complex for this scenario, so I haven’t explored it.
More options are documented in the authenticating page, based on the subject that needs authentication:
- developers: only specific users can invoke the function during development and testing processes
- function-to-function: only authorized functions can invoke your function
- end-users: users that need access to an application from mobile or web clients
The first step is to make sure that your function requires authentication. Log into the Google Cloud Console and remove the allUsers
member from your cloud function. Alternatively you can also use the command line to remove the member from the IAM role
$ gcloud functions remove-iam-policy-binding fastifyFunction --member=allUsers --role=roles/cloudfunctions.invoker
Notice:
Redeploying the function from the command line without the --allow-unauthenticated
flag will not work because the permission has already been set.
Now you can see that invoking the function will fail with a 403 error:
$ curl https://GCP_REGION-PROJECT_ID.cloudfunctions.net/fastifyFunction
<html><head>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<title>403 Forbidden</title>
</head>
<body text=#000000 bgcolor=#ffffff>
<h1>Error: Forbidden</h1>
<h2>Your client does not have permission to get URL <code>/fastifyFunction</code> from this server.</h2>
<h2></h2>
</body></html>
OAuth and OIDC
Google APIs use the OAuth 2.0 protocol for authentication and authorization. Google supports common OAuth 2.0 scenarios such as those for web server, client-side, installed, and limited-input device applications.
Explaining the flow of an OAuth 2 (authorization) and OpenID Connect (identification) application is out of the scope of the article, but their knowledge is required in order to understand how to interact with Google services. A good walkthrough to the topic is available on the Okta Blog.
Developers with the cloudfunctions.functions.invoke
permission can easily invoke Cloud Functions from their local machine, given that they are authenticated with their service account, using the command line and the gcloud
utility:
$ curl https://REGION-PROJECT_ID.cloudfunctions.net/FUNCTION_NAME -H "Authorization: bearer $(gcloud auth print-identity-token)"
Notice:
In order for this request to work, the role assigned to the developer must contain the cloudfunctions.functions.invoke
permission. By default, the Cloud Functions Admin and Cloud Functions Developer roles have this permission.
The command gcloud auth print-identity-token
is used to establish an OAuth/OpenID flow between your machine and the authorization server at Google. By inspecting the network traffic you can clearly see how your computer tries a DNS query for the metadata server metadata.google.internal
, but since we’re running the command from outside the Google Cloud Platform, the authorization server is going to be oauth2.googleapis.com
.
The result of the commad is an OpenID token that can be used as bearer token for the cloud function invocation. OIDC tokens are actually signed JSON Web Tokens (JWT)2 and are used primarily to assert identity and not to provide any implicit authorization against a resource, unlike OAuth tokens, which do provide access.
Authenticate from a PHP Application
So far, so good! But if you’re reading this post you’re probably interested in how to invoke a Cloud Function from your our own application. In this case you have to set up a service account that is going to interact with the function on behalf of your application.
A service account is a special type of Google account intended to represent a non-human user that needs to authenticate and be authorized to access data in Google APIs.
If your code is already running from the Google Cloud Platform (for example from Compute Engine), a service account is already attached to the component automatically. This service account is customizable for a better right segregation and for applying the least privilege principle on each component.
Instead, if your code is not running on GCP, you must have a service account key file. This comes as a public/private key pair in the form of a JSON file.
Warning: These keys are created, downloadable, and managed by users. This means that you are solely responsible for them and you need to make sure that you have processes in place to address key management requirements such as storage, distribution, revocation, rotation and protection from unauthorized users.
Create a new service account and make sure to assign it at least the roles roles/cloudfunctions.invoker
and roles/iam.serviceAccountTokenCreator
3. After doing that, download the JSON key file and store it somewhere safe on your machine.
Now it’s time to write the actual application code. The Google Documentation is a good starting point, but it doesn’t provide a full example of how obtaining an OIDC token from a local service account key file.
This is pretty much the reason I’m writing this post. I needed to invoke a Cloud Function from a PHP application and I was not able to find a good example for this purpose.
The easiest way is to use the Google Auth Library for PHP, which is Google’s officially supported PHP client library for using OAuth 2.0 authorization and authentication with Google APIs.
Define an environment variable GOOGLE_APPLICATION_CREDENTIALS
that points to your Service Account Credentials JSON file:
<?php
putenv('GOOGLE_APPLICATION_CREDENTIALS=/path/to/my/credentials.json');
Finally write the code that is going to fetch an ID token to access your Cloud Function:
<?php
// this is the URL associated with a Cloud Function or a CloudRun App
$targetAudience = 'https://GCP_REGION-PROJECT_ID.cloudfunctions.net/fastifyFunction'
// this is a Guzzle-compatible middleware which performs the OAuth flow
$middleware = ApplicationDefaultCredentials::getIdTokenMiddleware($targetAudience);
$stack = HandlerStack::create();
$stack->push($middleware);
// create the Guzzle HTTP client
$client = new Client([
'handler' => $stack,
'auth' => 'google_auth',
// Cloud Run, IAP, or custom resource URL
'base_uri' => 'https://https://GCP_REGION-PROJECT_ID.cloudfunctions.net',
]);
// make the request
$response = $client->get('/fastifyFunction');
// and show the result!
print_r((string) $response->getBody());
The previous code is basically a streamlined version of the following, which best outlines the OAuth flow:
<?php
$jsonKey = json_decode(file_get_contents('/path/to/my/credentials.json'), true);
$creds = new \Google\Auth\OAuth2([
'audience' => $jsonKey['token_uri'],
'issuer' => $jsonKey['client_email'],
'signingAlgorithm' => 'RS256',
'signingKey' => $jsonKey['private_key'],
'signingKeyId' => $jsonKey['private_key_id'],
'sub' => $jsonKey['client_email'],
'tokenCredentialUri' => $jsonKey['token_uri'],
]);
$targetAudience = 'https://GCP_REGION-PROJECT_ID.cloudfunctions.net/fastifyFunction'
$creds->setAdditionalClaims([
'target_audience' => $targetAudience
]);
$request = $creds->generateCredentialsRequest();
// create the Guzzle HTTP client
$client = new Client();
$response = $client->send($request);
$body = json_decode($response->getBody()->getContents(), true);
$idToken = $body['id_token'];
// new Guzzle HTTP Client
$client = new Client([
'base_uri' => 'https://GCP_REGION-PROJECT_ID.cloudfunctions.net'
]);
$response = $guzzle->get('/fastifyFunction', [
'headers' => [
'Authorization' => $idToken
]
]);
$data = json_decode($response->getBody()->getContents());
That’s it! Hope you like it.
At the time of writing NodeJS 14 was not available yet. ↩︎