Goa websocket with subprotocols (auth)

Sat, Sep 7, 2024 3-minute read

Goa websocket with subprotocols (auth)

How I figured out to pass an API key to my websocket endpoint using goa and React. It wasn’t straight forward!

Firstly, you have to setup an websocket.Upgrader{} and assign CheckOrigin and Subprotocols values.

upgrader := &websocket.Upgrader{}
upgrader.CheckOrigin = func(r *http.Request) bool { return true } // setup authorised origins; this is a demo

// You have to tell it that Sec-Websocket-Protocol and your custom auth header are valid subprotocols
// or it won't work! and you will get an error like this:
// Error during WebSocket handshake: Sent non-empty 'Sec-WebSocket-Protocol' header but no response was received
upgrader.Subprotocols = []string{"Sec-Websocket-Protocol", "x-api-key"}

Then you have to add some headers to your request. But before we do that, in Goa we need a custom middleware to read the subprotocol. Using Authorization: Bearer asdadad, or in my case x-api-key: 123 like a traditional HTTP request doesn’t work.

# authorization (subprotocol) header on a websocket 101 Switching
Sec-WebSocket-Protocol: x-api-key, key_000000000000

Because its x-api-key, key_000000000000, you have to split the string and do some manipulation. Things you don’t have to do in normal HTTP requests.

Here’s how I do it using a Goa middleware

// set custom context keys
type xApiKeyWSType string
const apiKeyCtxKey xApiKeyWSType = "x-api-key"

func WebsocketConnection() func(http.Handler) http.Handler {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			req := r
            // if not a websocket connection, continue
			if upgrade := r.Header.Get("Connection"); upgrade != "Upgrade" {
				// Not a websocket connection
				h.ServeHTTP(w, req)
			}
            // Get the header with the key
			if auth := r.Header.Get("Sec-WebSocket-Protocol"); auth != "" {
                // the header will return a string: x-api-key, key_000000000000
                // we need to split, then trim white space
				header := strings.Split(auth, ",")
				header[1] = strings.TrimSpace(header[1])
				ctx := context.WithValue(r.Context(), apiKeyCtxKey, header[1])
				req = r.WithContext(ctx)
			}
			h.ServeHTTP(w, req)
		})
	}
}

Then in your security scheme, you’ll need to get that context key we just set.

func (a *ApiKey) Validate(
	ctx context.Context,
	key string,
	scheme *security.APIKeyScheme,
	db *repository.Queries,
) (context.Context, error) {
	if key == "" {
		key = GetXApiKeyWS(ctx)
	}
    // truncated but do something with the key.
    // a typical HTTP request will have the key available already but
    // we need to grab it via the GetXApiKeyWS function
	return ctx, nil
}

It took me a few hours of reading issues, code, websocket specs to figure out why wscat and insomnia could connect but not React. Inspecting the headers was a big clue - no response headers were set from the server. But, if I connected to a globally available unauthenticated server such as wss://echo.websocket.events they were.

It’s all working now and apart from this I’m really happy with how easy it is to get going with Goa and websockets. From a design.go/DSL prespective its simple. I just replaced Result with StreamingResult in my Services' Method. Had to plumb in the upgrader but it was simple. The hard part was making sure access was still secure behind my API keys (and soon to be JWTs).

Tags:

#goa #websockets #react #go