Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MAP-24] Add Golang webhook server example #63

Merged
merged 9 commits into from
Apr 14, 2022
1 change: 1 addition & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ jobs:
with:
go-version: '^1.14'
- run: cd go/examples/sign-request && go build
- run: cd go/examples/webhook-server && go build
4 changes: 4 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ signature, err := tlsigning.SignWithPem(Kid, privateKeyBytes).
Sign()
```

See [full example](./examples/sign-request/).

## Verifying webhooks
The `VerifyWithJwks` function can be used to verify webhook `Tl-Signature` header signatures.

Expand Down Expand Up @@ -43,3 +45,5 @@ Install the package with:
```shell
go get github.com/Truelayer/truelayer-signing/go
```

See [webhook server example](./examples/webhook-server/).
19 changes: 19 additions & 0 deletions go/examples/webhook-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Golang webhook server example
A http server than can receive and verify signed TrueLayer webhooks.

## Run
Run the server.
```sh
go run main.go
```

Send a valid webhook that was signed for path `/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b`.
```sh
curl -iX POST -H "Content-Type: application/json" \
-H "X-Tl-Webhook-Timestamp: 2022-03-11T14:00:33Z" \
-H "Tl-Signature: eyJhbGciOiJFUzUxMiIsImtpZCI6IjFmYzBlNTlmLWIzMzUtNDdjYS05OWE5LTczNzQ5NTc1NmE1OCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGVhZGVycyI6IngtdGwtd2ViaG9vay10aW1lc3RhbXAiLCJqa3UiOiJodHRwczovL3dlYmhvb2tzLnRydWVsYXllci5jb20vLndlbGwta25vd24vandrcyJ9..AE_QsBRhnsMkcRzd42wvY1e2HruUhkOgjuZKktGH_WmbD7rBzoaEHUuF36IxyyvCbLajd3MBExNmzjbrOQsGaspwAI5DcGVMFLKUhB7ZzUlTP9up3eNUrdwWyyfBWDQb-qmEuLnrhFDJvgCXEqlV5OLrt-O7LaRAJ4f9KHsZLQ_j2vPC" \
-d "{\"event_type\":\"payout_settled\",\"event_schema_version\":1,\"event_id\":\"8fb9fb4e-bb2b-400b-af64-59e5dde74bad\",\"event_body\":{\"transaction_id\":\"c34c8721-66a9-49f6-a229-284efcf88a02\",\"settled_at\":\"2022-03-11T14:00:32.933000Z\"}}" \
http://localhost:7000/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b
```

Modifying the `X-Tl-Webhook-Timestamp` header, the body or the path will cause the above signature to be invalid.
9 changes: 9 additions & 0 deletions go/examples/webhook-server/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module webhook-server

go 1.18

require (
github.com/Truelayer/truelayer-signing/go v0.1.4 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anyone knows about a better http cache...
This is not actively maintained but it does the job.

github.com/wk8/go-ordered-map v0.2.0 // indirect
)
13 changes: 13 additions & 0 deletions go/examples/webhook-server/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
github.com/Truelayer/truelayer-signing/go v0.1.4 h1:tdYr7J8orPEUlrVJ4g922V0OVr+Px4QDOxaHbOcbI7w=
github.com/Truelayer/truelayer-signing/go v0.1.4/go.mod h1:SWksk9wzvQRhn0rb8Q0drHt9N0w67pvTg0PWBdQ+cWk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/wk8/go-ordered-map v0.2.0 h1:KlvGyHstD1kkGZkPtHCyCfRYS0cz84uk6rrW/Dnhdtk=
github.com/wk8/go-ordered-map v0.2.0/go.mod h1:9ZIbRunKbuvfPKyBP1SIKLcXNlv74YCOZ3t3VTS6gRk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
90 changes: 90 additions & 0 deletions go/examples/webhook-server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package main

import (
"fmt"
"io"
"log"
"net/http"

tlsigning "github.com/Truelayer/truelayer-signing/go"
"github.com/gregjones/httpcache"
)

func main() {
tp := httpcache.NewMemoryCacheTransport()
client := http.Client{Transport: tp}

http.HandleFunc("/hook/d7a2c49d-110a-4ed2-a07d-8fdb3ea6424b", receiveHook(&client))

log.Println("Starting server on: 7000")

log.Fatal(http.ListenAndServe(":7000", nil))
}

func receiveHook(client *http.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method is not supported.", http.StatusMethodNotAllowed)
return
}

err := verifyHook(client, r)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusUnauthorized)
}

// handle verified hook

w.WriteHeader(http.StatusAccepted)
}
}

func verifyHook(client *http.Client, r *http.Request) error {
tlSignature := r.Header.Get("Tl-Signature")
if len(tlSignature) == 0 {
return fmt.Errorf("missing Tl-Signature header")
}

jwsHeader, err := tlsigning.ExtractJwsHeader(tlSignature)
if err != nil {
return err
}
if len(jwsHeader.Jku) == 0 {
return fmt.Errorf("jku missing")
}

defer r.Body.Close()
webhookBody, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("webhook body missing")
}

// ensure jku is an expected TrueLayer url
if jwsHeader.Jku != "https://webhooks.truelayer.com/.well-known/jwks" && jwsHeader.Jku != "https://webhooks.truelayer-sandbox.com/.well-known/jwks" {
return fmt.Errorf("unpermitted jku %s", jwsHeader.Jku)
}

// fetch jwks (cached according to cache-control headers)
resp, err := client.Get(jwsHeader.Jku)
if err != nil {
return fmt.Errorf("failed to fetch jwks")
}
defer resp.Body.Close()
jwks, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("jwks missing")
}

// verify signature using the jwks
return tlsigning.VerifyWithJwks(jwks).Method(http.MethodPost).Path(r.RequestURI).Headers(getHeadersMap(r.Header)).Body(webhookBody).Verify(tlSignature)
}

func getHeadersMap(requestHeaders map[string][]string) map[string][]byte {
headers := make(map[string][]byte)
for key, values := range requestHeaders {
// take first value
headers[key] = []byte(values[0])
}
return headers
}