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()) + } +}