From 1e13d33c52b6c648c83de2f021f54034fc96d9c1 Mon Sep 17 00:00:00 2001 From: Jeremy Dormitzer Date: Fri, 29 Jan 2021 01:34:38 -0500 Subject: [PATCH] Use link tokens instead of public key for linking/relinking accounts (#2) * Use link tokens instead of public key for linking/relinking accounts * Format * Let language and countries be configurable - Language and countries are configurable, but autodetected by default - Remove Linker.ClientOpts Co-authored-by: Mark Hudnall --- README.md | 4 +- go.mod | 5 +- go.sum | 11 ++ main.go | 99 +++++++++--- pkg/plaid_cli/linker.go | 334 +++++++++++++++++++--------------------- 5 files changed, 250 insertions(+), 203 deletions(-) diff --git a/README.md b/README.md index ed8e105..2cebe51 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,10 @@ plaid-cli will look at the following environment variables for API credentials: ```sh PLAID_CLIENT_ID= -PLAID_PUBLIC_KEY= PLAID_SECRET= PLAID_ENVIRONMENT=development +PLAID_LANGUAGE=en # optional, detected using system's locale +PLAID_COUNTRIES=US # optional, detected using system's locale ``` I recommend setting and exporting these on shell startup. @@ -39,7 +40,6 @@ API credentials can also be specified using a config file located at ```toml [plaid] client_id = "" -public_key = "" secret = "" environment = "development" ``` diff --git a/go.mod b/go.mod index f8a6fe0..c800e5c 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,12 @@ module github.com/landakram/plaid-cli go 1.14 require ( + github.com/Xuanwo/go-locale v1.0.0 github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/manifoldco/promptui v0.7.0 github.com/mitchellh/mapstructure v1.3.2 // indirect github.com/pelletier/go-toml v1.8.0 // indirect - github.com/plaid/plaid-go v0.0.0-20200529200923-9627743aa512 + github.com/plaid/plaid-go v0.0.0-20210112002311-0cf0e6f0ea3e github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/afero v1.2.2 // indirect github.com/spf13/cast v1.3.1 // indirect @@ -16,6 +17,6 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.7.0 github.com/stretchr/testify v1.6.1 // indirect - golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect + golang.org/x/text v0.3.3 gopkg.in/ini.v1 v1.57.0 // indirect ) diff --git a/go.sum b/go.sum index b04a20a..9b75cdb 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/Xuanwo/go-locale v1.0.0 h1:oqC32Kyiu2XZq+fxtwEg0mWiv9WyDhyHu+sT5cDkgME= +github.com/Xuanwo/go-locale v1.0.0/go.mod h1:kB9tcLfr4Sp+ByIE9SE7vbUkXkGQqel2XH3EHpL0haA= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= @@ -107,6 +109,7 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= @@ -157,6 +160,8 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/plaid/plaid-go v0.0.0-20200529200923-9627743aa512 h1:XmKvXLOGi20FfHv20HQYhct0y4vLOcSaT+cMz4MIDw0= github.com/plaid/plaid-go v0.0.0-20200529200923-9627743aa512/go.mod h1:c7cDT1Lkcr0AgKJGVIG+oCa07jOrrg4Um8nduQ1eQN0= +github.com/plaid/plaid-go v0.0.0-20210112002311-0cf0e6f0ea3e h1:8uVgUfPCS63Ys/8BawzsnDZem8NQsCUlo2yKO0uSqac= +github.com/plaid/plaid-go v0.0.0-20210112002311-0cf0e6f0ea3e/go.mod h1:c7cDT1Lkcr0AgKJGVIG+oCa07jOrrg4Um8nduQ1eQN0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= @@ -178,7 +183,9 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -285,10 +292,14 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3 h1:qDJKu1y/1SjhWac4BQZjLljqvqiWUhjmDMnonmVGDAU= +golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go index 1c6b297..eedfec5 100644 --- a/main.go +++ b/main.go @@ -20,8 +20,39 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + + "github.com/Xuanwo/go-locale" + "golang.org/x/text/language" ) +func sliceToMap(slice []string) map[string]bool { + set := make(map[string]bool, len(slice)) + for _, s := range slice { + set[s] = true + } + return set +} + +// See https://plaid.com/docs/link/customization/#language-and-country +var plaidSupportedCountries = []string{"US", "CA", "GB", "IE", "ES", "FR", "NL"} +var plaidSupportedLanguages = []string{"en", "fr", "es", "nl"} + +func AreValidCountries(countries []string) bool { + supportedCountries := sliceToMap(plaidSupportedCountries) + for _, c := range countries { + if !supportedCountries[c] { + return false + } + } + + return true +} + +func IsValidLanguageCode(lang string) bool { + supportedLanguages := sliceToMap(plaidSupportedLanguages) + return supportedLanguages[lang] +} + func main() { log.SetFlags(0) @@ -53,6 +84,41 @@ func main() { viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) viper.AutomaticEnv() + tag, err := locale.Detect() + if err != nil { + tag = language.AmericanEnglish + } + + region, _ := tag.Region() + base, _ := tag.Base() + + var country string + if region.IsCountry() { + country = region.String() + } else { + country = "US" + } + + lang := base.String() + + viper.SetDefault("plaid.countries", []string{country}) + countriesOpt := viper.GetStringSlice("plaid.countries") + var countries []string + for _, c := range countriesOpt { + countries = append(countries, strings.ToUpper(c)) + } + + viper.SetDefault("plaid.language", lang) + lang = viper.GetString("plaid.language") + + if !AreValidCountries(countries) { + log.Fatalln("⚠️ Invalid countries. Please configure `plaid.countries` (using an envvar, PLAID_COUNTRIES, or in plaid-cli's config file) to a subset of countries that Plaid supports. Plaid supports the following countries: ", plaidSupportedCountries) + } + + if !IsValidLanguageCode(lang) { + log.Fatalln("⚠️ Invalid language code. Please configure `plaid.language` (using an envvar, PLAID_LANGUAGE, or in plaid-cli's config file) to a language that Plaid supports. Plaid supports the following languages: ", plaidSupportedLanguages) + } + viper.SetDefault("plaid.environment", "development") plaidEnvStr := strings.ToLower(viper.GetString("plaid.environment")) @@ -69,7 +135,6 @@ func main() { opts := plaid.ClientOptions{ viper.GetString("plaid.client_id"), viper.GetString("plaid.secret"), - viper.GetString("plaid.public_key"), plaidEnv, &http.Client{}, } @@ -80,7 +145,7 @@ func main() { log.Fatal(err) } - linker := plaid_cli.NewLinker(data, client, opts) + linker := plaid_cli.NewLinker(data, client, countries, lang) linkCommand := &cobra.Command{ Use: "link [ITEM-ID-OR-ALIAS]", @@ -102,13 +167,18 @@ func main() { itemOrAlias = itemID } - tokenPair, err = linker.Relink(itemOrAlias, port) + err = linker.Relink(itemOrAlias, port) + log.Println("Institution relinked!") + return } else { tokenPair, err = linker.Link(port) + if err != nil { + log.Fatalln(err) + } + data.Tokens[tokenPair.ItemID] = tokenPair.AccessToken + err = data.Save() } - data.Tokens[tokenPair.ItemID] = tokenPair.AccessToken - err = data.Save() if err != nil { log.Fatalln(err) } @@ -334,7 +404,7 @@ func main() { IncludeOptionalMetadata: withOptionalMetadataFlag, IncludeStatus: withStatusFlag, } - resp, err := client.GetInstitutionByIDWithOptions(instID, opts) + resp, err := client.GetInstitutionByIDWithOptions(instID, countries, opts) if err != nil { return err } @@ -374,9 +444,10 @@ Configuration: plaid-cli will look at the following environment variables for API credentials: PLAID_CLIENT_ID= - PLAID_PUBLIC_KEY= PLAID_SECRET= PLAID_ENVIRONMENT=development + PLAID_LANGUAGE=en # optional, detected using system's locale + PLAID_COUNTRIES=US # optional, detected using system's locale I recommend setting and exporting these on shell startup. @@ -385,7 +456,6 @@ Configuration: [plaid] client_id = "" - public_key = "" secret = "" environment = "development" @@ -416,11 +486,6 @@ Configuration: rootCommand.Help() os.Exit(1) } - if !viper.IsSet("plaid.public_key") { - log.Println("⚠️ PLAID_PUBLIC_KEY not set. Please see the configuration instructions below.") - rootCommand.Help() - os.Exit(1) - } rootCommand.Execute() } @@ -455,16 +520,10 @@ func WithRelinkOnAuthError(itemID string, data *plaid_cli.Data, linker *plaid_cl if e.ErrorCode == "ITEM_LOGIN_REQUIRED" { log.Println("Login expired. Relinking...") - // TODO: this relink logic duplicated from the link command above - port := viper.GetString("link.port") - var tokenPair *plaid_cli.TokenPair - - tokenPair, err = linker.Relink(itemID, port) + err = linker.Relink(itemID, port) - data.Tokens[tokenPair.ItemID] = tokenPair.AccessToken - err = data.Save() if err != nil { return err } diff --git a/pkg/plaid_cli/linker.go b/pkg/plaid_cli/linker.go index 052e338..8e21cc1 100644 --- a/pkg/plaid_cli/linker.go +++ b/pkg/plaid_cli/linker.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/http" + "os" "text/template" "github.com/plaid/plaid-go/plaid" @@ -12,11 +13,13 @@ import ( ) type Linker struct { - Results chan string - Errors chan error - Client *plaid.Client - ClientOpts plaid.ClientOptions - Data *Data + Results chan string + RelinkResults chan bool + Errors chan error + Client *plaid.Client + Data *Data + countries []string + lang string } type TokenPair struct { @@ -24,25 +27,52 @@ type TokenPair struct { AccessToken string } -func (l *Linker) Relink(itemID string, port string) (*TokenPair, error) { +func (l *Linker) Relink(itemID string, port string) error { token := l.Data.Tokens[itemID] - res, err := l.Client.CreatePublicToken(token) + hostname, err := os.Hostname() if err != nil { - return nil, err + log.Fatal(err) } - - return l.link(port, handleRelink(l, res.PublicToken)) + resp, err := l.Client.CreateLinkToken(plaid.LinkTokenConfigs{ + User: &plaid.LinkTokenUser{ + ClientUserID: hostname, + }, + ClientName: "plaid-cli", + CountryCodes: l.countries, + Language: l.lang, + AccessToken: token, + }) + if err != nil { + log.Fatal(err) + } + return l.relink(port, resp.LinkToken) } func (l *Linker) Link(port string) (*TokenPair, error) { - return l.link(port, handleLink(l)) + hostname, err := os.Hostname() + if err != nil { + log.Fatal(err) + } + resp, err := l.Client.CreateLinkToken(plaid.LinkTokenConfigs{ + User: &plaid.LinkTokenUser{ + ClientUserID: hostname, + }, + ClientName: "plaid-cli", + Products: []string{"transactions"}, + CountryCodes: l.countries, + Language: l.lang, + }) + if err != nil { + log.Fatal(err) + } + return l.link(port, resp.LinkToken) } -func (l *Linker) link(port string, serveLink func(w http.ResponseWriter, r *http.Request)) (*TokenPair, error) { +func (l *Linker) link(port string, linkToken string) (*TokenPair, error) { log.Println(fmt.Sprintf("Starting Plaid Link on port %s...", port)) go func() { - http.HandleFunc("/link", serveLink) + http.HandleFunc("/link", handleLink(l, linkToken)) err := http.ListenAndServe(fmt.Sprintf(":%s", port), nil) if err != nil { l.Errors <- err @@ -57,6 +87,7 @@ func (l *Linker) link(port string, serveLink func(w http.ResponseWriter, r *http case err := <-l.Errors: return nil, err case publicToken := <-l.Results: + res, err := l.exchange(publicToken) if err != nil { return nil, err @@ -71,41 +102,54 @@ func (l *Linker) link(port string, serveLink func(w http.ResponseWriter, r *http } } +func (l *Linker) relink(port string, linkToken string) error { + log.Println(fmt.Sprintf("Starting Plaid Link on port %s...", port)) + + go func() { + http.HandleFunc("/relink", handleRelink(l, linkToken)) + err := http.ListenAndServe(fmt.Sprintf(":%s", port), nil) + if err != nil { + l.Errors <- err + } + }() + + url := fmt.Sprintf("http://localhost:%s/relink", port) + log.Println(fmt.Sprintf("Your browser should open automatically. If it doesn't, please visit %s to continue linking!", url)) + open.Run(url) + + select { + case err := <-l.Errors: + return err + case <-l.RelinkResults: + return nil + } +} + func (l *Linker) exchange(publicToken string) (plaid.ExchangePublicTokenResponse, error) { return l.Client.ExchangePublicToken(publicToken) } -func NewLinker(data *Data, client *plaid.Client, clientOpts plaid.ClientOptions) *Linker { +func NewLinker(data *Data, client *plaid.Client, countries []string, lang string) *Linker { return &Linker{ - Results: make(chan string), - Errors: make(chan error), - Client: client, - ClientOpts: clientOpts, - Data: data, + Results: make(chan string), + RelinkResults: make(chan bool), + Errors: make(chan error), + Client: client, + Data: data, + countries: countries, + lang: lang, } } -func handleLink(linker *Linker) func(w http.ResponseWriter, r *http.Request) { +func handleLink(linker *Linker, linkToken string) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: t := template.New("link") t, _ = t.Parse(linkTemplate) - var env string - switch linker.ClientOpts.Environment { - case plaid.Development: - env = "development" - case plaid.Production: - env = "production" - case plaid.Sandbox: - env = "sandbox" - default: - env = "development" - } d := LinkTmplData{ - PublicKey: linker.ClientOpts.PublicKey, - Environment: env, + LinkToken: linkToken, } t.Execute(w, d) case http.MethodPost: @@ -125,47 +169,31 @@ func handleLink(linker *Linker) func(w http.ResponseWriter, r *http.Request) { } type LinkTmplData struct { - PublicKey string - Environment string + LinkToken string } type RelinkTmplData struct { - PublicToken string - PublicKey string - Environment string + LinkToken string } -func handleRelink(linker *Linker, publicToken string) func(w http.ResponseWriter, r *http.Request) { +func handleRelink(linker *Linker, linkToken string) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: t := template.New("relink") t, _ = t.Parse(relinkTemplate) - var env string - switch linker.ClientOpts.Environment { - case plaid.Development: - env = "development" - case plaid.Production: - env = "production" - case plaid.Sandbox: - env = "sandbox" - default: - env = "development" - } d := RelinkTmplData{ - PublicToken: publicToken, - PublicKey: linker.ClientOpts.PublicKey, - Environment: env, + LinkToken: linkToken, } t.Execute(w, d) case http.MethodPost: r.ParseForm() - token := r.Form.Get("public_token") - if token != "" { - linker.Results <- token + err := r.Form.Get("error") + if err != "" { + linker.Errors <- errors.New(err) } else { - linker.Errors <- errors.New("Empty public_token") + linker.RelinkResults <- true } fmt.Fprintf(w, "ok") @@ -179,19 +207,19 @@ var linkTemplate string = ` @@ -201,52 +229,28 @@ var linkTemplate string = `