Securing GCP AppEngine Go1.11 Application with Identity-Aware Proxy and being able to access it.

There’s a lot have been said about how to secure it, but not that much said about what is it that you should do next? Use it, I guess? I’ve been struggling getting to know how the things may and should work and would like to share my somewhat painful experience.

Deploying your application

Assuming you have an app structured the following way:

.
├── go.mod
├── go.sum
├── server
│ ├── app.yaml
│ └── main.go
└── service.go

The app has nothing interesting so far, service just holds WarmupRequests handler, main holds routes and app.yaml is at its basic form. So just to recap.

service.go

package exampleservice

import (
"net/http"
)

type Service struct{}

func NewService() *Service {
return &Service{}
}

func (s *Service) WarmupRequestHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
return

main.go

package main

import (
"net/http"
"github.com/gorilla/mux"
"google.golang.org/appengine"

service "github.com/xxmatyuk/example-gae-go111/example_app"
)

func main() {

// Router
r := mux.NewRouter()

// Service
s := service.NewService()

// Handlers
r.HandleFunc("/_ah/warmup", s.WarmupRequestHandler).Methods("GET")
r.HandleFunc("/test-admin", s.WarmupRequestHandler).Methods("GET")
r.HandleFunc("/test-no-admin", s.WarmupRequestHandler).Methods("GET")

http.Handle("/", r)

appengine.Main()

}
func (s *Service) WarmupRequestHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
return
}

app.yaml

runtime: go111
service: default
instance_class: F2inbound_services:
- warmup
handlers:
- url: /.*
script: auto

Now, deploy it and we can go further, shall we?

glcoud app deploy server/app.yaml

Enabling the IAP protection.

So far all the steps have been described in google docs and by the society, so the following is just to re-cap. Now, as you’ve deployed your app, you can follow by the app’s root URL and get the 404 in a browser or hit the same root URL via CLI with the same luck. Mind that now both HTTP and HTTPs URLs are accessible.

Now, navigate to Security -> Identity-aware Proxy and If you haven’t set it up yet, you’ll see the following:

Just click CONFIGURE CONSENT SCREEN, fill the bunch of fields up there and get back to the IAP screen. You should see the following:

Error is fine. Just turn on the IAP and by doing new IAP web application OAuth client would be created. Now try to navigate to the root URL of your app. If you’re not as naïve as I am, there won’t be much of a surprise for you:

Ha-ha. To avoid going into that loophole, you should add your GCP user a certain role, which is IAP-Secured Web App User. Adding the role, waiting until changes are propagated, one more try, voilà, 404.

Don’t forget to add that role to any user you want to pass trough you IAP!

JWT Identity tokens

Again, so far all looked very trivial and very well-documented. Now, what if your app has an API you may want to call? We’re very secured now and we cannot just send an HTTP request to our AppEngine app without telling GCP who we are. To do so we must provide every call with the Authorization header and JWT token inside. How to get one?

Personal identity token

As you’ve already updated your role with IAP-secured Web App User permission you may wonder how can you get your personal token? The answer is in getting through the pretty standard OAuth2.0 flow.

First, you need to navigate to APIs and Services -> Credentials screen in your GCP console. You’ll see up there our Web application client, but to access it via personal token you need to create the OAuth2.0 client for you. Click create credentials and choose OAuth client ID:

then find Other application type, fill in whatever name comes to your mind or left intact:

Click Create and mind the credentials. You’ll need them soon.

We’re all set to start. First, you’ll need the code. Update the following URL with the client ID you’ve just created then follow by this URL and login with your GCP user:

https://accounts.google.com/o/oauth2/v2/auth?client_id=<OTHER_CLIENT_ID>&response_type=code&scope=openid%20email&access_type=offline&redirect_uri=urn:ietf:wg:oauth:2.0:oob

As a result you should get the code:

Now we need to get the refresh token. Fill in with the code from above and with your credentials:

curl --verbose \
--data client_id=<OTHER_CLIENT_ID> \
--data client_secret=<OTHER_CLIENT_SECRET> \
--data code=<THE_CODE> \
--data redirect_uri=urn:ietf:wg:oauth:2.0:oob \
--data grant_type=authorization_code \
https://www.googleapis.com/oauth2/v4/token

Finally, we can get the identity token. Mind the audience parameter. The audience is the client ID of our IAP-secured AppEngine App, i.e.:

Just update the command with your Web application client ID as audience, with the refresh token value from above and with your Other client credentials and you’ll get the identity token.

curl --verbose \
--data client_id=<OTHER_CLIENT_ID> \
--data client_secret=<OTHER_CLIENT_SECRET> \
--data refresh_token=<REFRESH_TOKEN> \
--data grant_type=refresh_token \
--data audience=<CLIENT_ID_OF_WEB_APP> \
https://www.googleapis.com/oauth2/v4/token

Now just hit your app root URL as the following and you’ll get the 404.

curl -v -X GET https://<YOUR_APP_URL>/ -H "Authorization: Bearer <ID_TOKEN>"

Service account identity token — glcoud tool.

Still not very promising. “What If I want to use service account?” — you may ask. Surely, you do.

But let’s change our app.yaml a bit to get a more granular understanding of security.

runtime: go111
service: default
instance_class: F2inbound_services:
- warmup
handlers:
- url: /_ah/.*
script: auto
secure: always
login: admin
- url: /test-admin
script: auto
secure: always
login: admin
- url: /.*
script: auto
secure: always

We added secure: always to serve only HTTPs traffic, all the HTTP responses would be forwarded to the secured port.

For two of the routes we added login: admin to let only AppEngine Admin privileged users. But we will illustrate that a bit later.

The first and the easiest way to get the identity token for a service account is to use gcloud tool. Assuming you have the JSON credentials of your service account, just execute two commands and you good to go. First activates the service account:

gcloud auth activate-service-account --key-file=<YOUR_JSON_CREDS_FILE>

The second gets the token(mind the right audience — it’s still a client ID):

gcloud auth print-identity-token --audiences=<IAP_SECURED_APP_CLIENT_ID>

Now, just to illustrate the changes we’ve just done in our app.yaml, create two different service accounts: one would have App Engine Admin and IAP-secured Web App user and another one would be IAP-secured Web App.

Get the creds of both and try to call our API with tokens.

curl -v -X GET https://<YOUR_APP_URL>/test-admin -H "Authorization: Bearer <ID_TOKEN>"curl -v -X GET https://<YOUR_APP_URL>/test-no-admin -H "Authorization: Bearer <ID_TOKEN>"

Both calls for the first (more privileged) service account will give 200 HTTP response, when only the second call for the second service account user will get though both of layers of protection, resulting with 403 for the first call. Mind the login: admin in our app.yaml. Only users with both roles may go though.

Service account identity token — the golang way.

You may wonder, what If glcoud is bad way of doing things? It is, in a sense. Let’s get the token ourselves, shall we?

The idea behind that is very close to the way we have got the personal identity token. The difference is that for the service account we have the private key to sign our JWT tokens, so we can build it ourselves for further OAuth2.0 talk with Google.

All you need to do is to form a JWT token, signed by the private key of a service account with some specific claims encoded. Then you need to re-sign the token via Google API.

For the claims you need to extend the standard JWT claims a bit and fill them properly. email would be your service account email, target_audience would be the IAP-secured web client (you should remember at this point)

(NOTE: jwt library implementation used all over this article is github.com/dgrijalva/jwt-go)

type customClaims struct {
Email string `json:"email"`
TargetAudience string `json:"target_audience"`
*jwt.StandardClaims
}

so the overall claims would look as the following:

claims := &customClaims{
Email: <SERVICE_ACCOUNT_EMAIL>,
TargetAudience: <WEB_APP_CLIENT_ID>,
StandardClaims: &jwt.StandardClaims{
Issuer: <SERVICE_ACCOUNT_EMAIL>,
Subject: <SERVICE_ACCOUNT_EMAIL>,
Audience: <TOKEN_URI_FROM_SERVICE_ACCOUNT_JSON>,
IssuedAt: <UNIX_TIME>,
ExpiresAt: <UNIX_TIME_PLUS_EXPIRATION>,
},
}

Then you do application/x-www-form-urlencoded POST request with only two parameters: grant_type would be the string “urn:ietf:params:oauth:grant-type:jwt-bearer” and the assertion parameter would be your JWT token to the token URL:

https://oauth2.googleapis.com/token

Please find the whole example following by the github URL:

Custom JWT validation

If that’s still not enough of a security, let’s go a bit deeper and crack that JWT ourselves, shall we? Perhaps you may want to log who calls your api (client ID or email), or you may want to implement a stricter validation on your end — whatever the use-case might be, it’s doable. To help to get the idea behind this you may want to check the doc. It didn’t help me, so sharing what I myself have learned.

Firstly, with the current setup (GAE app behind the IAP) our JWT token would be repacked. By this I mean, the IAP layer would read your token, validate it, get whatever it needs from the token claims, create a new token with a new claims and put it as another header: new token signing method would be ECDSA with the ES256 implementation and the header would be x-goog-iap-jwt-assertion.

As I’ve mentioned above, for this example I’m using github.com/dgrijalva/jwt-go implementation of JWT. It provides us with a function to parse token from request.

func ParseFromRequest(req *http.Request, extractor Extractor, keyFunc jwt.Keyfunc, options ...ParseFromRequestOption) (token *jwt.Token, err error)

Let me drive you though args it takes real quick so that you’ll get the idea behind all of the below:

  • req is a basically http request we’re dealing with
  • extractor is an interface that should be implemented by the only one function — ExtractToken, which would extract JWT token from whatever header.
  • keyFunc should return an interface, which is either would be a *rsa.PublicKey or *ecdsa.PublicKey, so eventually the parsed public key
  • options are whatever options you want to pass

With having that said, we need to extract the token from request, so the extractor may look like the following:

// TokenExtractor is a custom token extractor interfaceconst jwtGAETokenHeader = 
type TokenExtractor struct {
request.Extractor
}
func (e *TokenExtractor) ExtractToken(r *http.Request) (string, error) {
for headerKey, headerValue := range r.Header {
if strings.ToLower(headerKey) == "x-goog-iap-jwt-assertion" && len(headerValue) > 0 {
return headerValue[0], nil
} else if strings.ToLower(headerKey) == "Authorization" {
if len(headerValue[0]) > 6 && strings.ToUpper(headerValue[0][0:7]) == "BEARER" {
return headerValue[0][0:7], nil
}
return "", nil
}
}
return "", nil
}

Here I’ve tried to take into account two possible cases. The keys function should basically do two things:

  • get the current public keys (RSA or ECDSA)
  • return the key by kid value taken from JWT token header (as well as an algorithm itself, btw)

so the getting public keys part may look like the following:

func getPublicKeys(publicKeysURL string) (map[string]string, error) {
var keys map[string]strin
client := &http.Client{Timeout: 10 * time.Second}

r, err := client.Get(publicKeysURL)
if err != nil {
return keys, err
}
defer r.Body.Close()

if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
return keys, err
}
return keys, nil
}

and the returning part (assuming, we’re dealing with ECSDSA):

...kid := token.Header["kid"].(string)
publicKeys, err := getPublicKeys("PUBLIC_KEYS_URL")
if err != nil {
return nil, err
}
var keys = map[string]*ecdsa.PublicKey{}for k, v := range publicKeys {
if len(v) != 0 {
parsedKey, err := jwt.ParseECPublicKeyFromPEM([]byte(v))
if err != nil {
return nil, err
}
keys[k] = parsedKey
}
}return keys[kid], nil...

Now, we’re two steps away from the custom athorization:

  • creating the auth middleware
  • updating our handlers

The idea behind auth middleware is quite simple: you take the handler function as an argument to return the function that would take w http.ResponseWriter and r *http.Request as args and would return your handler function with those args as parameters. In between we’ll do the authentication part. Having that said:

func (s *Service) Auth(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// OUR FANCY VALIDATION
handler(w, r)
}
}

and then update our main.go with the following:

r.HandleFunc("/test-custom-auth", s.Auth(s.WarmupRequestHandler)).Methods("GET")

All set. The full example of the app may be found here:

Thanks to Patrick Hawley and Sergei Zhezniakovsky for an inspiration!

Happy and secure coding!

Software engineer, musician and artist.

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

CS 373 Spring 2022: Carlos Vela (Week of 7 Feb. — 13 Feb.)

Managing microservices and APIs with Kong and Konga

Connecting Cloud Functions with Compute Engine using Serverless VPC Access

Serverless VPC Access

Requirement activities for a software product

Rails 6 + Bootstrap 5

Why Programs Fail — a Book Review

Walking Your Way With Python — II

Flutter Swagger Generator — package to save your time

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Kirill Matyunin

Kirill Matyunin

Software engineer, musician and artist.

More from Medium

Orchestrating Microservices in Nodejs using Kubernetes

In search of a Search Engine, beyond Elasticsearch: Introducing Zinc

How to Restore Elasticsearch from a Snapshot

Unit Testing & Debugging Setup For Golang with VS Code