diff --git a/accesstoken.go b/accesstoken.go
index 1b4b072..07ca8ca 100644
--- a/accesstoken.go
+++ b/accesstoken.go
@@ -4,12 +4,15 @@ package finchgo
import (
"context"
+ "errors"
"net/http"
"github.com/Finch-API/finch-api-go/internal/apijson"
"github.com/Finch-API/finch-api-go/internal/param"
"github.com/Finch-API/finch-api-go/internal/requestconfig"
"github.com/Finch-API/finch-api-go/option"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
)
// AccessTokenService contains methods and other services that help with
@@ -32,9 +35,39 @@ func NewAccessTokenService(opts ...option.RequestOption) (r *AccessTokenService)
// Exchange the authorization code for an access token
func (r *AccessTokenService) New(ctx context.Context, body AccessTokenNewParams, opts ...option.RequestOption) (res *CreateAccessTokenResponse, err error) {
opts = append(r.Options[:], opts...)
+
+ opts = append(opts[:], func(rc *requestconfig.RequestConfig) error {
+ bodyClientID := gjson.Get(string(rc.Buffer), "client_id")
+ if !bodyClientID.Exists() {
+ if rc.ClientID == "" {
+ return errors.New("client_id must be provided as an argument or with the FINCH_CLIENT_ID environment variable")
+ }
+ updatedBody, err := sjson.Set(string(rc.Buffer), "client_id", rc.ClientID)
+ if err != nil {
+ return err
+ }
+ rc.Buffer = []byte(updatedBody)
+ }
+
+ bodyClientSecret := gjson.Get(string(rc.Buffer), "client_secret")
+ if !bodyClientSecret.Exists() {
+ if rc.ClientSecret == "" {
+ return errors.New("client_secret must be provided as an argument or with the FINCH_CLIENT_SECRET environment variable")
+ }
+ updatedBody, err := sjson.Set(string(rc.Buffer), "client_secret", rc.ClientSecret)
+ if err != nil {
+ return err
+ }
+ rc.Buffer = []byte(updatedBody)
+ }
+
+ return nil
+ })
+
path := "auth/token"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
return
+
}
type CreateAccessTokenResponse struct {
diff --git a/api.md b/api.md
index c692f3f..fbf9806 100644
--- a/api.md
+++ b/api.md
@@ -9,6 +9,13 @@
- shared.OperationSupportMatrix
- shared.Paging
+# finchgo
+
+Methods:
+
+- client.GetAuthURL(products string, redirectUri string, sandbox bool, opts ...option.RequestOption) (string, error)
+- client.WithAccessToken(accessToken string) (Client, error)
+
# AccessTokens
Response Types:
@@ -176,6 +183,11 @@ Response Types:
- finchgo.PaymentEvent
- finchgo.WebhookEvent
+Methods:
+
+- client.Webhooks.Unwrap(payload []byte, headers http.Header, secret string, now time.Time) (WebhookEvent, error)
+- client.Webhooks.VerifySignature(payload []byte, headers http.Header, secret string, now time.Time) error
+
# RequestForwarding
Response Types:
diff --git a/client.go b/client.go
index 22fc8b7..78a0d2e 100644
--- a/client.go
+++ b/client.go
@@ -3,8 +3,15 @@
package finchgo
import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/url"
"os"
+ "strconv"
+ "github.com/Finch-API/finch-api-go/internal/requestconfig"
"github.com/Finch-API/finch-api-go/option"
)
@@ -60,3 +67,82 @@ func NewClient(opts ...option.RequestOption) (r *Client) {
return
}
+
+// DEPRECATED: use client.accessTokens().create instead.
+func (r *Client) GetAccessToken(ctx context.Context, code string, redirectUri string, opts ...option.RequestOption) (res string, err error) {
+ opts = append(r.Options[:], opts...)
+ opts = append(opts[:], option.WithHeaderDel("authorization"))
+
+ path := "/auth/token"
+
+ var result map[string]string
+ cfg, err := requestconfig.NewRequestConfig(ctx, http.MethodPost, path, nil, &result, opts...)
+ if err != nil {
+ return "", err
+ }
+ if cfg.ClientID == "" {
+ return "", errors.New("expected ClientID to be set in order to call GetAccessToken")
+ }
+ if cfg.ClientSecret == "" {
+ return "", errors.New("expected ClientSecret to be set in order to call GetAccessToken")
+ }
+
+ body := struct {
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret"`
+ Code string `json:"code"`
+ RedirectURI string `json:"redirect_uri"`
+ }{
+ ClientID: cfg.ClientID,
+ ClientSecret: cfg.ClientSecret,
+ Code: code,
+ RedirectURI: redirectUri,
+ }
+ cfg.Apply(func(rc *requestconfig.RequestConfig) (err error) {
+ rc.Buffer, err = json.Marshal(body)
+ rc.Request.Header.Set("Content-Type", "application/json")
+ return err
+ })
+
+ err = cfg.Execute()
+ if err != nil {
+ return "", err
+ }
+ accessToken, ok := result["access_token"]
+ if !ok {
+ return "", errors.New("access_token not found in response")
+ }
+
+ return accessToken, nil
+}
+
+// Returns the authorization URL which can be visited in order to obtain an
+// authorization code from Finch. The authorization code can then be exchanged for
+// an access token for the Finch API by calling getAccessToken().
+func (r *Client) GetAuthURL(products string, redirectUri string, sandbox bool, opts ...option.RequestOption) (res string, err error) {
+ opts = append(r.Options[:], opts...)
+ cfg := requestconfig.RequestConfig{}
+ cfg.Apply(opts...)
+
+ if cfg.ClientID == "" {
+ return "", errors.New("expected the ClientID to be set in order to call GetAuthUrl")
+ }
+ u, err := url.Parse("https://connect.tryfinch.com/authorize")
+ if err != nil {
+ return "", err
+ }
+ q := u.Query()
+ q.Set("client_id", cfg.ClientID)
+ q.Set("products", products)
+ q.Set("redirect_uri", redirectUri)
+ q.Set("sandbox", strconv.FormatBool(sandbox))
+ u.RawQuery = q.Encode()
+ return u.String(), nil
+}
+
+// Returns a copy of the current Finch client with the given access token for
+// authentication.
+func (r *Client) WithAccessToken(accessToken string) (res Client, err error) {
+ opts := append(r.Options[:], option.WithAccessToken(accessToken))
+ return Client{Options: opts}, nil
+}
diff --git a/examples/auth/main.go b/examples/auth/main.go
new file mode 100644
index 0000000..4fcc730
--- /dev/null
+++ b/examples/auth/main.go
@@ -0,0 +1,29 @@
+package main
+
+import (
+ "context"
+ "fmt"
+
+ finch "github.com/Finch-API/finch-api-go"
+ "github.com/Finch-API/finch-api-go/option"
+)
+
+func main() {
+ client := finch.NewClient(option.WithClientID("foo-client-id"), option.WithClientSecret("foo-client-secret"))
+
+ url, err := client.GetAuthURL("products", "https://example.com/redirect", false)
+ if err != nil {
+ panic(err.Error())
+ }
+ fmt.Printf("auth url: %s\n", url)
+
+ accessTokenResponse, err := client.AccessTokens.New(context.TODO(), finch.AccessTokenNewParams{
+ Code: finch.F("my-code"),
+ RedirectUri: finch.F("https://example.com/redirect"),
+ })
+ if err != nil {
+ panic(err.Error())
+ }
+ fmt.Printf("access token: %s\n", accessTokenResponse.AccessToken)
+
+}
diff --git a/finchgo.go b/finchgo.go
new file mode 100644
index 0000000..59c6281
--- /dev/null
+++ b/finchgo.go
@@ -0,0 +1,12 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package finchgo
+
+type GetAccessTokenParams struct {
+}
+
+type GetAuthURLParams struct {
+}
+
+type WithAccessTokenParams struct {
+}
diff --git a/webhook.go b/webhook.go
index c5b936b..837ea89 100644
--- a/webhook.go
+++ b/webhook.go
@@ -3,7 +3,16 @@
package finchgo
import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net/http"
"reflect"
+ "strconv"
+ "strings"
+ "time"
"github.com/Finch-API/finch-api-go/internal/apijson"
"github.com/Finch-API/finch-api-go/internal/shared"
@@ -28,6 +37,83 @@ func NewWebhookService(opts ...option.RequestOption) (r *WebhookService) {
return
}
+// Validates that the given payload was sent by Finch and parses the payload.
+func (r *WebhookService) Unwrap(payload []byte, headers http.Header, secret string, now time.Time) (res WebhookEvent, err error) {
+ err = r.VerifySignature(payload, headers, secret, now)
+ if err != nil {
+ return nil, err
+ }
+
+ event := WebhookEvent(nil)
+ err = apijson.UnmarshalRoot(payload, &event)
+ if err != nil {
+ return nil, err
+ }
+ return event, nil
+}
+
+// Validates whether or not the webhook payload was sent by Finch.
+//
+// An error will be raised if the webhook payload was not sent by Finch.
+func (r *WebhookService) VerifySignature(payload []byte, headers http.Header, secret string, now time.Time) (err error) {
+ parsedSecret, err := base64.StdEncoding.DecodeString(secret)
+ if err != nil {
+ return fmt.Errorf("invalid webhook secret: %s", err)
+ }
+
+ id := headers.Get("finch-event-id")
+ if len(id) == 0 {
+ return errors.New("could not find finch-event-id header")
+ }
+ sign := headers.Values("finch-signature")
+ if len(sign) == 0 {
+ return errors.New("could not find finch-signature header")
+ }
+ unixtime := headers.Get("finch-timestamp")
+ if len(unixtime) == 0 {
+ return errors.New("could not find finch-timestamp header")
+ }
+
+ timestamp, err := strconv.ParseInt(unixtime, 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid timestamp header: %s, %s", unixtime, err)
+ }
+
+ if timestamp < now.Unix()-300 {
+ return errors.New("webhook timestamp too old")
+ }
+ if timestamp > now.Unix()+300 {
+ return errors.New("webhook timestamp too new")
+ }
+
+ mac := hmac.New(sha256.New, parsedSecret)
+ mac.Write([]byte(id))
+ mac.Write([]byte("."))
+ mac.Write([]byte(unixtime))
+ mac.Write([]byte("."))
+ mac.Write(payload)
+ expected := mac.Sum(nil)
+
+ for _, part := range sign {
+ parts := strings.Split(part, ",")
+ if len(parts) != 2 {
+ continue
+ }
+ if parts[0] != "v1" {
+ continue
+ }
+ signature, err := base64.StdEncoding.DecodeString(parts[1])
+ if err != nil {
+ continue
+ }
+ if hmac.Equal(signature, expected) {
+ return nil
+ }
+ }
+
+ return errors.New("None of the given webhook signatures match the expected signature")
+}
+
type AccountUpdateEvent struct {
Data AccountUpdateEventData `json:"data"`
EventType AccountUpdateEventEventType `json:"event_type"`
@@ -1630,3 +1716,9 @@ func init() {
},
)
}
+
+type WebhookUnwrapParams struct {
+}
+
+type WebhookVerifySignatureParams struct {
+}
diff --git a/webhook_test.go b/webhook_test.go
new file mode 100644
index 0000000..ced3c2e
--- /dev/null
+++ b/webhook_test.go
@@ -0,0 +1,28 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package finchgo_test
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/Finch-API/finch-api-go"
+)
+
+func TestVerifySignature(t *testing.T) {
+ secret := "5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH"
+
+ payload := `{"company_id":"720be419-0293-4d32-a707-32179b0827ab"}`
+
+ header := http.Header{}
+ header.Add("Finch-Event-Id", "msg_2Lh9KRb0pzN4LePd3XiA4v12Axj")
+ header.Add("finch-timestamp", "1676312382")
+ header.Add("finch-signature", "v1,m7y0TV2C+hlHxU42wCieApTSTaA8/047OAplBqxIV/s=")
+
+ client := finchgo.NewClient()
+ err := client.Webhooks.VerifySignature([]byte(payload), header, secret, time.Unix(1676312382, 0))
+ if err != nil {
+ t.Fatalf("did not expect error %s", err.Error())
+ }
+}