Handle Authentication For Cloud Function Client In Golang

Ngô Việt Khánh Huy
Level Up Coding
Published in
5 min readApr 26, 2020

--

Note: I’ve published an open-source library go-cloudfunction-auth for this purpose. If you run into the same need, you can just go ahead with the library (see the sample here). In the scope of this post, I will talk more about how I investigate and resolve the problem.

A few weeks ago, I needed to integrate my backend with another service deployed on Cloud Function. Originally, I thought that this integration should be straight forward since my backend and the new service are both backed by Google’s services. Authenticating to Cloud Function should be straightforward using my google service accounts.

This assumption is correct with Javascript. google-auth-library works pretty well to support this case. The JS code below works perfectly:

const {GoogleAuth} = require('google-auth-library');

const targetAudience = "cloud-function-url"

async function run() {
const auth = new GoogleAuth();

const client = await auth.getIdTokenClient(targetAudience);
const res = await client.request({ url });
console.info(res.data);
}

It does not work!

Google also has OAuth2 library in Golang, which contains sub package google to support authentication for google’s services. After a while reading the docs, I came up with the below code. But it doesn’t seem to work…

import	"golang.org/x/oauth2/google"
func getToken() (err error) {
scope := "https://www.googleapis.com/auth/cloud-platform"
client, err := google.DefaultClient(context.Background(), scope)
if err != nil {
return
}
res, err := client.Get("cloud-function-url")
if err != nil {
return
}
fmt.Println(res)
return
}

Comparing with the Nodejs code, I thought the code above is missing targetAudience param while it could be a mandatory parameter. Hmm, make sense. After a while dive deeper into the library, I ended with this code. It still doesn't work!

baseUrl := "your-cloudfunction-baseurl"
ctx := context.Background()
targetAudience := baseUrl
credentials, err := google.FindDefaultCredentials(ctx)
if err != nil {
fmt.Printf("cannot get credentials: %v", err)
os.Exit(1)
}

tokenSrc, err := google.JWTAccessTokenSourceFromJSON(credentials.JSON, targetAudience)
if err != nil {
fmt.Printf("cannot create jwt source: %v", err)
os.Exit(1)
}

client := oauth2.NewClient(context.Background(), tokenSrc)
if err != nil {
return
}
res, err := client.Get(baseUrl + "sub-url")
if err != nil {
return
}

After a while double-checking to make sure the service account was set up correctly in my local environment, I know that this part can take much longer than my expectation. I need to dive deeper into the OAuth2 code.

Investigation

Since I have my Nodejs code working, I started to compare the JWT tokens generated by both sides. Here is the structure of the JWT token from GCP which allow me to authenticate the Cloud Function call:

{
alg: "RS256",
kid: "...",
typ: "JWT"
}.
{
aud: "<my cloud function base url>",
azp: "<my service account email>",
email: "<my service account email>",
email_verified: true,
exp: <time>,
iat: <time>,
iss: "https://accounts.google.com",
sub: "<a string of digits>"
}.
[signature]

And here is the JWT I received from my Golang code:

{
alg: "RS256",
kid: "...",
typ: "JWT"
}.
{
iss: "<my service account email>",
aud: "<my cloud function base url>",
exp: <time>,
iat: <time>,
sub: "<my service account email>"
}.
[signature]

The structures differ a lot. But at least I found a light. The invalid login can due to the invalid structure of the JWT. I started to implement a modified version of google’s library to change the JWT structure follow my Nodejs output.

After a while coding, I made the output structures matched. Unfortunately, it still doesn’t work. What’s the problem here? I was feeling lost. After a while, it seems better to leave the IDE and grab my coffee.

The issue seems more complicated than I thought. I decided to dive deeper into Google’s OAuth2 protocol. The auth flow has two main steps as the diagram below:

There are two different JWTs generated in this flow. The first one created and signed by the client code, whereas the second one produced by Google’s servers. Google’s returned JWT should be the final one and attached to HTTP clients’ request headers. The Nodejs library does similarly. When I added a breakpoint to travel along with its execution flow, I can see it generates a signed JWT, calls to Google, and captures the responded JWT for later requests.

The Golang library does not. It produces a JWT and uses this value directly. That flow may work for other Google’s products, where Google API accepts the token generated from our service accounts. My cloud function, on the other hand, is a custom API and requires a signed JWT from Google.

At this point, I can figure out two problems with the Golang library:

  • It does not follow Google’s OAuth2 flow. While this implementation may work for other services, it does not work with Cloud Function.
  • Structure of output JWTs of Golang library does not match with the first JWT generated by Nodejs library. I will need some custom code for this purpose.

Solution

The investigation is correct. I managed to have my custom code worked. Below is a summary of what I did:

  • Generate and sign a JWT locally. The token should include the audience field, which set to our cloud function's base URL.
  • Send this JWT to Google’s follow their spec, detach returned token from the response.
  • Attach this final token to all HTTP clients’ headers

Here are some main parts from my code:

const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"

func JWTAccessTokenSourceFromJSON(jsonKey []byte, audience string) (oauth2.TokenSource, error) {
cfg, err := google.JWTConfigFromJSON(jsonKey)
if err != nil {
return nil, fmt.Errorf("google: could not parse JSON key: %v", err)
}
pk, err := internal.ParseKey(cfg.PrivateKey)
if err != nil {
return nil, fmt.Errorf("google: could not parse key: %v", err)
}
ts := &jwtAccessTokenSource{
email: cfg.Email,
audience: audience,
pk: pk,
pkID: cfg.PrivateKeyID,
}
tok, err := ts.Token()
if err != nil {
return nil, err
}
return oauth2.ReuseTokenSource(tok, ts), nil
}

type TokenResponse struct {
IdToken string `json:"id_token"`
}

func Authenticate(tokenSource oauth2.TokenSource) (token oauth2.Token, err error) {
jwt, err := tokenSource.Token()
if err != nil {
return
}

client := &http.Client{Timeout: time.Second * 10}
payload := strings.NewReader("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=" + jwt.AccessToken)
req, _ := http.NewRequest("POST", GOOGLE_TOKEN_URL, payload)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err := client.Do(req)
if err != nil {
return
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return
}
tokenRes := &TokenResponse{}
err = json.Unmarshal(body, tokenRes)
if err != nil {
fmt.Println(err.Error())
}
token = oauth2.Token{
AccessToken: tokenRes.IdToken,
}
return
}

func NewClient(jwtSource oauth2.TokenSource) *http.Client {
token, err := Authenticate(jwtSource)
if err != nil {
fmt.Printf("cannot authenticate with google: %v", err)
os.Exit(1)
}

return &http.Client{
Transport: &oauth2.Transport{
Base: http.DefaultClient.Transport,
Source: &googleTokenSource{
GoogleToken: &token,
},
},
}
}

Conclusion

This part took me much longer than I planned. Since my other integration with Nodejs run pretty smoothly, I thought it also would be straightforward with Golang, especially when Golang is Google’s language. Surprisingly, I can’t find any good documents or conversations on the internet.

On the positive side, diving deeper into this issue gain me more understanding about JWT generation and OAuth2 flow. I published my code as an open-source library go-cloudfunction-auth. I hope this article and the library can help other people if they ran into a similar problem.

Originally published at https://huynvk.dev.

--

--