Controlling gRPC JSON gateway cookies

I’ve recently been experimenting with go-based gRPC, combined with the gRPC gateway, which provides a simple way to create a JSON REST interface to gRPC based services. The examples I’ve found haven’t described how to add some additional common functionality to the REST API like authentication or session management. I’ll describe here what I did to allow my API to create, read, and delete cookies at the gateway. I brought this together into a demo repo that shows the concept.

My requirements:

  • Ability to take data returned from gRPC calls and insert it into a cookie and/or session.
  • Limit knowledge any middleware has of the gRPC API (URLs, method names, etc.) — react only to metadata set by the gRPC calls.
  • Use a standard session library (Gorilla sessions) in a standard way.

gRPC methods can’t set HTTP response cookies directly — they’re running in a different process and don’t have access to the response object. Normally Gorilla/HTTP middleware could be used to set cookies in an HTTP response, but this type of middleware can’t access data from the gRPC response. I describe here how I worked around these issues.

Controlling the response with gRPC metadata

In my demo example, I want to pass a user ID back and forth between the gRPC calls and the gateway. I’d like a SignIn call to set a user ID that the gateway will then add to the session (encoded into a cookie). On subsequent calls, the gateway should pass the user ID from the session to the gRPC call so that it knows the authenticated user ID. Finally a SignOut call should tell the gateway to remove the session.

The gRPC framework provides a mechanism to pass metadata to and from gRPC calls. This is described well by the gRPC Metadata documentation page. The metadata mechanism makes it possible to send extra data into some or all gRPC calls and also makes it possible for calls to send extra data back to the client.

In our case, the gRPC client is the JSON gateway, so we need to inject code into the gRPC call chain at the right points there to send and retrieve metadata. The JSON gateway supports a few extension points that allow the request or response to be modified in different ways:

  • runtime.WithForwardResponseOption — called after the gRPC response has been received by the gateway, before the JSON response has been written. Allows you to add or modify HTTP response headers with knowledge of the response message and any metadata sent back from the gRPC call.
  • runtime.WithIncomingHeaderMatcher — called before the gRPC call is made, deciding which headers should be sent along as metadata.
  • runtime.WithOutgoingHeaderMatcher — called after the gRPC call returns, before headers are written, deciding which metadata fields should be send back as HTTP response headers.
  • runtime.WithMetadata — called before the gRPC call is made, creating metadata fields out of the http.Request object.

WithForwardResponseOption can manipulate the http.Response object (setting cookies and/or writing output). Unfortunately, these filters are only given the http.Response and not the http.Request. The Gorilla session library requires both the request and response (to check the request cookies). To work around this, we can use a standard HTTP middleware function to pass the http.Request object into the Context so that the forward response filter can access it.

SignIn — Session creation

To sign in, the gRPC SignIn call (in authapi.go), calls SetUserIDInContext. This sends back a header to the JSON gateway with the ID.

func SetUserIDInContext(ctx context.Context, userID _int_) {
  // create a header that the gateway will watch for
  header := metadata.Pairs("gateway-session-userId", strconv.Itoa(userID))

  // send the header back to the gateway
  grpc.SendHeader(ctx, header)
}

Next, the forward response filter (in gateway.go), needs to react to this header by creating a session with Gorilla’s session library:

md, ok := runtime.ServerMetadataFromContext(ctx)
if !ok {
  return fmt.Errorf("Failed to extract ServerMetadata from context")
}

// did the gRPC method set a user ID in the metadata?
userID, err := getUserIDFromServerMetadata(md)
if err != nil {
  return err
}

if userID != 0 {
  rlog.Debugf("gRPC call set userId to %d", userID)

  // pull the request from context (set in middleware above)
  request := getRequestFromContext(ctx)

  // create or get the session
  session, err := sessionStore.New(request, defaultSessionID)

  if err != nil {
    rlog.Error(err, "couldn't create a session")
    return err
  }

  session.Options.MaxAge = sessionLength
  session.Options.Path = "/"  
  
  // create a session for the user.  This session is converted by gorilla
  // into a session cookie

  userIDSession := &Session{
    UserID: userID,
  }

  // put the userId into session
  session.Values["userId"] = userIDSession

  // save the session, creating a cookie from it
  if err := sessionStore.Save(request, response, session); err != nil {
    rlog.Error(err, "couldn't save the session as a cookie")
    return err
  }
}

Since the forward response filter is only passed the response, we need additional middleware to temporarily save the http.Request object for the forward response filter to get it:

func (middleware *gatewayMiddleware) Middleware(next http.Handler) http.Handler {

  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    if ctx == nil {
      ctx = context.Background()
    }

    ctx = context.WithValue(ctx, requestContextKey, r)
    next.ServeHTTP(w, r.WithContext(ctx))
  })
}

SignOut — Session removal

The gRPC SignOut method sets a flag telling the gateway to delete the session if it exists. This action is similar to session creation so I won’t show it here — it reads the flag from the metadata, and if it’s set will set the MaxAge of the session to be -1, removing the session.

Passing the user ID to gRPC calls

Finally, we want the user ID from the session to be passed to any gRPC calls. This is done with a metadata annotator, passed to the WithMetadata option.

// look up session and pass userId in to context if it exists
func gatewayMetadataAnnotator(_ context.Context, r *http.Request) metadata.MD {

  session, err := sessionStore.Get(r, defaultSessionID)
  if err != nil {
    // no session, or invalid session, so pass along no extra metadata
    return metadata.Pairs()
  }

  if userIDSessionValue, ok := session.Values["userId"]; ok {
    // convert back to a Session
    userIDSession := userIDSessionValue.(*Session)
    userID := userIDSession.UserID
    // set user ID from session in the gRPC metadata
    return metadata.Pairs("userId", strconv.Itoa(userID))
  }

  // otherwise pass no extra metadata along
  return metadata.Pairs()
}

Any gRPC call can now lookup the authenticated user ID in the Context when needed.

Demo source code

I put a working demo of all of this is on github: https://github.com/youngderekm/grpc-cookies-example

Feel free to comment if there is a cleaner way of doing this that I missed.