gRPC Service to Service on Cloud Run with Authentication

Part 2

Edi Wiraya
Google Cloud - Community
7 min readJun 22, 2023

--

This is Part 2 of the article series.

Link to Part 1: gRPC service to service on Cloud Run
Link to Part 3: gRPC service to service on Cloud Run and private networking

Wait! I’m not interested in the story, just show me the code. https://github.com/edyw/cloudrun-grpc

git clone https://github.com/edyw/cloudrun-grpc.git

Part 2: gRPC service to service, with Cloud Run authentication enabled

We want to secure gRPC service to service call, in this scenario we configure go-contact to authenticate and authorize both go-api and node-api services, making sure no other services or clients accessing the gRPC on go-contact.

Cloud Run has feature to enforce IAM users authorization, we will enable this on go-contact service and grant Cloud Run Invoker roles/run.invoker
to go-api and node-api using their IAM Service Account.

It is a security best practice to assign different Service Account to each Cloud Run, so we can selectively grant necessary access to each service.

Because calling go-contact gRPC service from go-api and node-api will not automatically include the credentials of the respective Cloud Run’s Service Account, we need to change the code to use gRPC and other libraries to extract the Service Account and attach it to gRPC call.

If you code half way and come here to copy/paste this part, go to Step 2.4 for Go, Step 2.5 for Node JS

For the rest of Part 2 we will cover the these steps:

  • Step 2.1 Change go-contact Cloud Run to enable authentication and test access
  • Step 2.2 Create Service Account for all Cloud Run services
  • Step 2.3 Grant go-api and node-api Service Account to access go-contact service
  • Step 2.4 Change Go code on go-api to add Service Account credentials
  • Step 2.5 Change Node JS code on node-api to add Service Account credentials
  • Step 2.6 Deploy the latest go-api and node-api with their Service Account
  • Step 2.7 End to end test

Step 2.1

Change go-contact Cloud Run to enable authentication and test access

In Part 1, we deployed Cloud Run service with --allow-unauthenticated parameter, this will add allUsers to roles/run.invoker. Let’s enable authentication by removing that IAM binding:

// Set Project ID if you haven't done it
gcloud config set project ${PROJECT_ID}

gcloud run services remove-iam-policy-binding go-contact \
--region=${REGION} \
--member=allUsers \
--role=roles/run.invoker

Replace $REGION with your Cloud Run service region, eg. asia-southeast1. ${PROJECT_ID} is the Google Cloud project ID.

You can also do this through Google Cloud Console:

If you still have go-api or node-api services from Part 1, you can test it out:

curl ${CLOUD_RUN_SERVICE_URL}/contact/555-110022 

go-api should return: Service Unavailable% and node-api should return: {}%

Logs on the Cloud Run explains the reason, and it is expected.

Step 2.2

Create Service Account for all Cloud Run service

In order to identify the callers, let’s create Service Accounts.

gcloud iam service-accounts create sa-run-go-api
gcloud iam service-accounts create sa-run-node-api

The Service Account is created in this format: ${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com. We will be using this naming convention as Service Account for the rest of article.

Step 2.3

Grant go-api and node-api Service Account to access go-contact service

gcloud run services add-iam-policy-binding go-contact \
--region=${REGION} \
--member=serviceAccount:${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com \
--role=roles/run.invoker

${SA_NAME} is the Service Account name, sa-run-go-api and sa-run-node-api.

You need to do for each Service Account if you have both go-api and node-api.

Step 2.4

Change Go code on go-api to add Service Account credentials

We are going to create a new route to call go-contact with authentication, let’s name it GET /contact-auth/:phone

/go-api/main.go

...
func main() {
r := gin.Default()
r.GET("/contact/:phone", getContactHandler)
r.GET("/contact-auth/:phone", getContactAuthHandler) // New route
r.Run(":8081")
}
...

The new handler function getContactAuthHandler will call the same getContact function from Part 1, but this time we need to use a context ctx with Service Account credentials.

/go-api/main.go

import {
...
"google.golang.org/api/idtoken"
grpcMetadata "google.golang.org/grpc/metadata"
...
}

...
...

func getContactAuthHandler(c *gin.Context) {

ctx, err := getAuthContext()
if err != nil {
log.Printf("%v", err)
c.JSON(http.StatusInternalServerError, err)
return
}

contactReply, err := getContact(c.Param("phone"), ctx)
if err != nil {
log.Printf("%v", err)
c.JSON(http.StatusInternalServerError, err)
} else {
log.Printf("%v", contactReply)
c.JSON(http.StatusOK, contactReply)
}
}

func getAuthContext() (context.Context, error) {
ctx := context.Background()
tokenSource, err := idtoken.NewTokenSource(ctx, "https://"+contactServerHost)
if err != nil {
return nil, err
}
token, err := tokenSource.Token()
if err != nil {
return nil, err
}
return grpcMetadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token.AccessToken), nil
}

...
...

getAuthContext() returns context with credentials from idtoken library. This library automatically gets Service Account credentials that the Cloud Run service is running, so in this case it is the credentials of sa-run-go-api@${PROJECT_ID}.iam.gserviceaccount.com, and we append it to the context as gRPC Metadata.

The rest of the process follows the same getContact function for unauthenticated version in Part 1.

For complete code: https://github.com/edyw/cloudrun-grpc

Step 2.5

Change Node JS code on node-api to add Service Account credentials

We are going to create a new route to call go-contact with authentication, let’s name it GET /contact-auth/:phone

/node-api/index.js

const express = require('express')
// import the new getContactAuthHandler we are going create next step
const { getContactHandler, getContactAuthHandler } = require('./contact-handler')

const app = express()
app.get('/contact/:phone', getContactHandler)
app.get('/contact-auth/:phone', getContactAuthHandler) // Add this route
app.listen('8082', () => {
console.log('node-api listening..')
})

/node-api/contact-handler.js

...
...

const getContactAuthHandler = async (req, res) => {
// Prepare grpc metadata with Service Account credentials
// from getIdTokenFromMetadataServer function
var metadata = new grpc.Metadata();
metadata.add('authorization', 'Bearer ' + await getIdTokenFromMetadataServer())

// From here it is the same as Part 1,
// with exception of additional metadata when calling getContact
const client = new services.ContactClient(contactServerHost + ':443', grpc.credentials.createSsl())
const grpcReq = new messages.ContactRequest()
grpcReq.setPhonenumber(req.params.phone)

// Notice the metadata with Credentials when calling getContact
client.getContact(grpcReq, metadata, (err, grpcReply) => {
console.log('grpcReply: ' + JSON.stringify(grpcReply, ' ', 2))
res.send(grpcReply.getName())
})
}


// Google Auth library fetch idtoken
const getIdTokenFromMetadataServer = async () => {
const googleAuth = new GoogleAuth()
const googleClient = await googleAuth.getClient()

token = await googleClient.fetchIdToken('https://' + contactServerHost)
console.log('token: ' + JSON.stringify(token, ' ', 2)) // For debug
return token
}

module.exports = {
getContactHandler,
getContactAuthHandler // Export the new function
}

Similar to Go version, getIdTokenFromMetadataServer uses Google Auth library to fetch idtoken which automatically identify the Service Account assigned to the Cloud Run. The token is stored to gRPC metadata variable, and pass it over to getContact gRPC call.

For complete code: https://github.com/edyw/cloudrun-grpc

Step 2.6

Deploy the latest go-api and node-api with their Service Account

Deploy go-api, execute this from main directory:

docker build . -f ./go-api/Dockerfile --tag ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-api

docker push ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-api

gcloud run deploy go-api --image=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/go-api \
--region=${REGION} \
--project=${PROJECT_ID} \
--allow-unauthenticated \
--port=8081 \
--service-account=sa-run-go-api@${PROJECT_ID}.iam.gserviceaccount.com

For node-api:

docker build . -f ./node-api/Dockerfile --tag ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/node-api

docker push ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/node-api

gcloud run deploy node-api --image=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/node-api \
--region=${REGION} \
--project=${PROJECT_ID} \
--allow-unauthenticated \
--port=8082 \
--service-account=sa-run-node-api@${PROJECT_ID}.iam.gserviceaccount.com

${REPO} is the Google Artifact Registry repository used to store container image. Refer to Part 1 for the detail.

Step 2.7

End to end test

Final verification before testing it end to end. We check the Google Cloud Console.

Verify go-contact Cloud Run:

  • Authentication: Require authentication
  • Permission: Cloud Run Invoker roles/run.invoker has both Service Accounts added

Verify node-api (do th same for go-api) Cloud Run:

  • Service Account is set to sa-run-node-api@${PROJECT_ID}.iam.gserviceaccount.com

Test the service using go-api or node-api Cloud Run Service URL with Service Account credentials:

curl ${CLOUD_RUN_SERVICE_URL}/contact-auth/555-110022

You should get: Mike F%

Test without Service Account credentials:

curl ${CLOUD_RUN_SERVICE_URL}/contact/555-110022

You should get: Service Unavailable% or {}%

Hooray!

--

--