Share this page to your:
Mastodon

In my current project I'm using a GCP Cloud Function with an HTTP trigger. Cloud Functions are one of Google Cloud Platform's serverless offerings. They can be configured to do different things, but what I need is something that triggers from HTTP, the sort of thing your browser might send, though I don't expect this particular function to be used by a browser.

I'm not going to go into the details of my function, this post is about how I secured it. It is really easy to deploy these things so they are accessible to the entire world. You can check the incoming call in the function and reject any that don't look like they should be there. But actually GCP will do that for you.

Now initially I did deploy it with public access. You just issue this command:

gcloud functions add-iam-policy-binding FUNCTION_NAME \
 --member="allUsers" \
 --role="roles/cloudfunctions.invoker"

see Google docs

But I wanted to change from allUsers to allValidatedUsers which would make GCP look for a valid JWT attached to the HTTP request.

A JWT (JSON Web Token) is neat mechanism, though it is kind of convoluted. It is part of the OAuth2 security standard, so it is very well supported. Once you have a JWT you attach it to your HTTP request like this:

curl --location --request POST 'https://CLOUD_FUNCTION_URL?Content-Type=application/json' \
--header 'Authorization: bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjI1N2Y2YTU4MjhkMWU0YTNhNmEwM2ZjZDFhMjQ2MWRiOTU5M2U2MjQiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJodHRwczovL3VzLWNlbnRyYWwxLWJpbGxpbmctdGVzdC0yMzY4MDQuY2xvdWRmdW5jdGlvbnMubmV0L2dvcHVic3ViIiwiYXpwIjoidGVzdGhhcm5lc3Mtc2FAYmlsbGluZy10ZXN0LTIzNjgwNC5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsImVtYWlsIjoidGVzdGhhcm5lc3Mtc2FAYmlsbGluZy10ZXN0LTIzNjgwNC5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJleHAiOjE1ODYxMjY3MzgsImlhdCI6MTU4NjEyMzEzOCwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tIiwic3ViIjoiMTAzNzM5OTg4NjQwODY0NjQ2OTQ1In0.MyKqA6t0n2q0GeVAk69LAf5FzgVcMISQ3_1W1J6iTwMb7eDLJbkFlaWjbjgQo3wtcOTypgR5Xd9I0t-izuWvPN_kYDkr5X94FwIovUUe9hnZd3MKDxeWCb_rknVbdKBVY2fBmvs7MX3eCnfkxXK0ZEmsdhB1EBry9_8vNgV28T3z80aqaisli8yDbQLcHLtcR9C0zlY0yw52xp7aHEB0v79yXft3J2HNUNNVuyMknQmCst-8uFveZE3g19eGl7FZWvtR1z_4iYVl_eIhHxFM5VE_cZUg3PbPKZFTDigSwFeSWcDBt56BYJg-0wT_cKqm9keUr54ZRj6cujPCZp5dIg' \
--header 'Content-Type: application/json' \
--header 'Content-Type: text/plain' \
--data-raw '{
...
}

The JWT is that huge string in the Authorization header. Because the JWT is so long it has line wrapped, but it is really just one long line. It is an encrypted string, using the secret key associated with your GCP account. Naturally GCP knows how to decrypt that so when this request arrives it unpacks it and checks that it looks okay. If it does then the request is passed into the function. Nice eh?

The information in the JWT includes who the current user is and also an expiry time. Some JWTs last only a few milliseconds and, once expired, they are no longer valid.

One of the advantages of the JWT is that it can be passed on from one call to another. For example my Cloud Function may call another Cloud Function. It can include the same JWT, and it doesn't need to generate a new one, eliminating a possible bottleneck as we shall see.

But how did we get that JWT? Easy, you just make sure you are logged into GCP and do this:

gcloud auth print-identity-token

That will print the huge string you saw above, or one similar. What it does under the covers is send a different HTTP request to a different URL with enough information to do the generation. If you use the gcloud command like that you don't have to care much about how it happens. But I need to call my Cloud Function from Java, that means I need to find out more about that initial request.

I started by looking at this code which pretty much does what I want but I struggled to make it work. The class described there is an interceptor for Spring's RestTemplate. But my initial tests were to ensure getGoogleIdToken() worked so I made that method public and called it from a unit test. I'll go through the steps:

  • Download a JSON key file for the user you want to use. You get these from GCP. I set up a Service Account and downloaded the key for that and put it in my current working directory.
  • Define an environment variable GOOGLE_APPLICATION_CREDENTIALS pointing to that json file. Mine was ./testharness-sa.json.
  • Make method getGoogleIdToken() public and write a unit test that calls it.

My unit test looks like this:

        GCPAuthenticationInterceptor g = new GCPAuthenticationInterceptor("MY_CLIENT_ID");
        DecodedJWT googleJwt = g.getGoogleIdToken();
        System.out.println("--header 'Authorization: bearer " + googleJwt.getToken()+"' \\");

Okay, a test with no assertions yet. But I wanted to see what the token looked like. The output can be easily pasted into a curl command like the one I showed earlier for testing. For the MY_CLIENT_ID I used the content of the clientId field I found in the json file.

And it refused to work. I kept getting an error: Invalid JWT: Failed audience check and when trying variations on this I got an error referring to a bad scope.

This took me a while to figure out, which is why I am writing about it. It turns out that the scope message was not relevant and the withAudience() method used in the code was also not relevant. What mattered was the clientId. This is used in the end of this section of code:

return JWT.create()
    .withKeyId(credentials.getPrivateKeyId())
    .withIssuer(credentials.getClientEmail())
    .withSubject(credentials.getClientEmail())
    .withAudience(OAUTH_TOKEN_AUDIENCE)
    .withIssuedAt(new Date(now))
    .withExpiresAt(new Date(now + EXPIRATION_TIME_IN_MILLIS))
    .withClaim("target_audience", clientId)
    .sign(algorithm);

It's used in that withClaim() method. What I needed to pass was not a clientId in this case but the URL of my Cloud Function, ie the URL I was trying to secure. Once I put that in place the errors went away and I got a JWT back when I passed this initial JWT in a message to https://www.googleapis.com/oauth2/v4/token.

Now that the interceptor was getting a JWT I needed to integrate it into the RestTemplate. The configuration looks like this:

    @Bean
    public RestTemplate getRestTemplate() {
        return restTemplateBuilder().build();
    }

    @Bean
    @DependsOn(value = {"customRestTemplateCustomizer"})
    public RestTemplateBuilder restTemplateBuilder()
    {
        return new RestTemplateBuilder(customRestTemplateCustomizer());
    }

    @Bean
    public CustomRestTemplateCustomizer customRestTemplateCustomizer()
    {
        return new CustomRestTemplateCustomizer();
    }

    public class CustomRestTemplateCustomizer implements RestTemplateCustomizer
    {
        @Override
        public void customize(RestTemplate restTemplate)
        {
            try {
                restTemplate.setInterceptors(Collections.singletonList(new GCPAuthenticationInterceptor(CLOUD_FUNCTION_URL)));
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }

This is a snippet of code from a class with a @Configuration annotation so Spring loads these beans automatically. Notice the CLOUD_FUNCTION_URL constant being passed to GCPAuthenticationInterceptor.

I did change the value of the IAM_SCOPE constant in the GCPAuthenticationInterceptor but it did not seem to matter what it was. I ended up using https://www.googleapis.com/auth/cloud-platform because I figured it would cover most things, but changing it from cloud-platform to email did not stop it working.

If you need a more in-depth tutorial on Spring Security try this one.

Previous Post Next Post