Update markbates/goth library (#3533) (#3539)

Signed-off-by: Lauris Bukšis-Haberkorns <lauris@nix.lv>
This commit is contained in:
Lauris BH 2018-02-19 14:10:38 +02:00 committed by Bo-Yi Wu
parent ade183957d
commit 8327300809
11 changed files with 284 additions and 160 deletions

View file

@ -8,6 +8,10 @@ protocol providers, as long as they implement the `Provider` and `Session` inter
This package was inspired by [https://github.com/intridea/omniauth](https://github.com/intridea/omniauth). This package was inspired by [https://github.com/intridea/omniauth](https://github.com/intridea/omniauth).
## Goth Needs a New Maintainer
[https://blog.gobuffalo.io/goth-needs-a-new-maintainer-626cd47ca37b](https://blog.gobuffalo.io/goth-needs-a-new-maintainer-626cd47ca37b) - TL;DR: I, @markbates, won't be responding to any more issues, PRs, etc... for this package. A new maintainer needs to be found ASAP. Is this you?
## Installation ## Installation
```text ```text
@ -18,6 +22,8 @@ $ go get github.com/markbates/goth
* Amazon * Amazon
* Auth0 * Auth0
* Azure AD
* Battle.net
* Bitbucket * Bitbucket
* Box * Box
* Cloud Foundry * Cloud Foundry
@ -26,6 +32,7 @@ $ go get github.com/markbates/goth
* Digital Ocean * Digital Ocean
* Discord * Discord
* Dropbox * Dropbox
* Eve Online
* Facebook * Facebook
* Fitbit * Fitbit
* GitHub * GitHub
@ -38,6 +45,7 @@ $ go get github.com/markbates/goth
* Lastfm * Lastfm
* Linkedin * Linkedin
* Meetup * Meetup
* MicrosoftOnline
* OneDrive * OneDrive
* OpenID Connect (auto discovery) * OpenID Connect (auto discovery)
* Paypal * Paypal
@ -50,7 +58,9 @@ $ go get github.com/markbates/goth
* Twitch * Twitch
* Twitter * Twitter
* Uber * Uber
* VK
* Wepay * Wepay
* Xero
* Yahoo * Yahoo
* Yammer * Yammer
@ -77,11 +87,45 @@ $ ./examples
Now open up your browser and go to [http://localhost:3000](http://localhost:3000) to see the example. Now open up your browser and go to [http://localhost:3000](http://localhost:3000) to see the example.
To actually use the different providers, please make sure you configure them given the system environments as defined in the examples/main.go file To actually use the different providers, please make sure you set environment variables. Example given in the examples/main.go file
## Security Notes
By default, gothic uses a `CookieStore` from the `gorilla/sessions` package to store session data.
As configured, this default store (`gothic.Store`) will generate cookies with `Options`:
```go
&Options{
Path: "/",
Domain: "",
MaxAge: 86400 * 30,
HttpOnly: true,
Secure: false,
}
```
To tailor these fields for your application, you can override the `gothic.Store` variable at startup.
The follow snippet show one way to do this:
```go
key := "" // Replace with your SESSION_SECRET or similar
maxAge := 86400 * 30 // 30 days
isProd := false // Set to true when serving over https
store := sessions.NewCookieStore([]byte(key))
store.MaxAge(maxAge)
store.Options.Path = "/"
store.Options.HttpOnly = true // HttpOnly should always be enabled
store.Options.Secure = isProd
gothic.Store = store
```
## Issues ## Issues
Issues always stand a significantly better chance of getting fixed if the are accompanied by a Issues always stand a significantly better chance of getting fixed if they are accompanied by a
pull request. pull request.
## Contributing ## Contributing
@ -94,50 +138,3 @@ Would I love to see more providers? Certainly! Would you love to contribute one?
4. Commit your changes (git commit -am 'Add some feature') 4. Commit your changes (git commit -am 'Add some feature')
5. Push to the branch (git push origin my-new-feature) 5. Push to the branch (git push origin my-new-feature)
6. Create new Pull Request 6. Create new Pull Request
## Contributors
* Mark Bates
* Tyler Bunnell
* Corey McGrillis
* willemvd
* Rakesh Goyal
* Andy Grunwald
* Glenn Walker
* Kevin Fitzpatrick
* Ben Tranter
* Sharad Ganapathy
* Andrew Chilton
* sharadgana
* Aurorae
* Craig P Jolicoeur
* Zac Bergquist
* Geoff Franks
* Raphael Geronimi
* Noah Shibley
* lumost
* oov
* Felix Lamouroux
* Rafael Quintela
* Tyler
* DenSm
* Samy KACIMI
* dante gray
* Noah
* Jacob Walker
* Marin Martinic
* Roy
* Omni Adams
* Sasa Brankovic
* dkhamsing
* Dante Swift
* Attila Domokos
* Albin Gilles
* Syed Zubairuddin
* Johnny Boursiquot
* Jerome Touffe-Blin
* bryanl
* Masanobu YOSHIOKA
* Jonathan Hall
* HaiMing.Yin
* Sairam Kunala

View file

@ -8,10 +8,18 @@ See https://github.com/markbates/goth/examples/main.go to see this in action.
package gothic package gothic
import ( import (
"bytes"
"compress/gzip"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"math/rand"
"net/http" "net/http"
"net/url"
"os" "os"
"strings"
"time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
@ -27,15 +35,21 @@ var defaultStore sessions.Store
var keySet = false var keySet = false
var gothicRand *rand.Rand
func init() { func init() {
key := []byte(os.Getenv("SESSION_SECRET")) key := []byte(os.Getenv("SESSION_SECRET"))
keySet = len(key) != 0 keySet = len(key) != 0
Store = sessions.NewCookieStore([]byte(key))
cookieStore := sessions.NewCookieStore([]byte(key))
cookieStore.Options.HttpOnly = true
Store = cookieStore
defaultStore = Store defaultStore = Store
gothicRand = rand.New(rand.NewSource(time.Now().UnixNano()))
} }
/* /*
BeginAuthHandler is a convienence handler for starting the authentication process. BeginAuthHandler is a convenience handler for starting the authentication process.
It expects to be able to get the name of the provider from the query parameters It expects to be able to get the name of the provider from the query parameters
as either "provider" or ":provider". as either "provider" or ":provider".
@ -65,8 +79,16 @@ var SetState = func(req *http.Request) string {
return state return state
} }
return "state" // If a state query param is not passed in, generate a random
// base64-encoded nonce so that the state on the auth URL
// is unguessable, preventing CSRF attacks, as described in
//
// https://auth0.com/docs/protocols/oauth2/oauth-state#keep-reading
nonceBytes := make([]byte, 64)
for i := 0; i < 64; i++ {
nonceBytes[i] = byte(gothicRand.Int63() % 256)
}
return base64.URLEncoding.EncodeToString(nonceBytes)
} }
// GetState gets the state returned by the provider during the callback. // GetState gets the state returned by the provider during the callback.
@ -87,7 +109,6 @@ I would recommend using the BeginAuthHandler instead of doing all of these steps
yourself, but that's entirely up to you. yourself, but that's entirely up to you.
*/ */
func GetAuthURL(res http.ResponseWriter, req *http.Request) (string, error) { func GetAuthURL(res http.ResponseWriter, req *http.Request) (string, error) {
if !keySet && defaultStore == Store { if !keySet && defaultStore == Store {
fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.") fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.")
} }
@ -130,7 +151,7 @@ as either "provider" or ":provider".
See https://github.com/markbates/goth/examples/main.go to see this in action. See https://github.com/markbates/goth/examples/main.go to see this in action.
*/ */
var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.User, error) { var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
defer Logout(res, req)
if !keySet && defaultStore == Store { if !keySet && defaultStore == Store {
fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.") fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.")
} }
@ -155,6 +176,11 @@ var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.Us
return goth.User{}, err return goth.User{}, err
} }
err = validateState(req, sess)
if err != nil {
return goth.User{}, err
}
user, err := provider.FetchUser(sess) user, err := provider.FetchUser(sess)
if err == nil { if err == nil {
// user can be found with existing session data // user can be found with existing session data
@ -173,7 +199,43 @@ var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.Us
return goth.User{}, err return goth.User{}, err
} }
return provider.FetchUser(sess) gu, err := provider.FetchUser(sess)
return gu, err
}
// validateState ensures that the state token param from the original
// AuthURL matches the one included in the current (callback) request.
func validateState(req *http.Request, sess goth.Session) error {
rawAuthURL, err := sess.GetAuthURL()
if err != nil {
return err
}
authURL, err := url.Parse(rawAuthURL)
if err != nil {
return err
}
originalState := authURL.Query().Get("state")
if originalState != "" && (originalState != req.URL.Query().Get("state")) {
return errors.New("state token mismatch")
}
return nil
}
// Logout invalidates a user session.
func Logout(res http.ResponseWriter, req *http.Request) error {
session, err := Store.Get(req, SessionName)
if err != nil {
return err
}
session.Options.MaxAge = -1
session.Values = make(map[interface{}]interface{})
err = session.Save(req, res)
if err != nil {
return errors.New("Could not delete user session ")
}
return nil
} }
// GetProviderName is a function used to get the name of a provider // GetProviderName is a function used to get the name of a provider
@ -184,36 +246,96 @@ var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.Us
var GetProviderName = getProviderName var GetProviderName = getProviderName
func getProviderName(req *http.Request) (string, error) { func getProviderName(req *http.Request) (string, error) {
provider := req.URL.Query().Get("provider")
if provider == "" { // get all the used providers
if p, ok := mux.Vars(req)["provider"]; ok { providers := goth.GetProviders()
// loop over the used providers, if we already have a valid session for any provider (ie. user is already logged-in with a provider), then return that provider name
for _, provider := range providers {
p := provider.Name()
session, _ := Store.Get(req, p+SessionName)
value := session.Values[p]
if _, ok := value.(string); ok {
return p, nil return p, nil
} }
} }
if provider == "" {
provider = req.URL.Query().Get(":provider") // try to get it from the url param "provider"
if p := req.URL.Query().Get("provider"); p != "" {
return p, nil
} }
if provider == "" {
return provider, errors.New("you must select a provider") // try to get it from the url param ":provider"
if p := req.URL.Query().Get(":provider"); p != "" {
return p, nil
} }
return provider, nil
// try to get it from the context's value of "provider" key
if p, ok := mux.Vars(req)["provider"]; ok {
return p, nil
}
// try to get it from the go-context's value of "provider" key
if p, ok := req.Context().Value("provider").(string); ok {
return p, nil
}
// if not found then return an empty string with the corresponding error
return "", errors.New("you must select a provider")
} }
func storeInSession(key string, value string, req *http.Request, res http.ResponseWriter) error { func storeInSession(key string, value string, req *http.Request, res http.ResponseWriter) error {
session, _ := Store.Get(req, key + SessionName) session, _ := Store.Get(req, SessionName)
session.Values[key] = value if err := updateSessionValue(session, key, value); err != nil {
return err
}
return session.Save(req, res) return session.Save(req, res)
} }
func getFromSession(key string, req *http.Request) (string, error) { func getFromSession(key string, req *http.Request) (string, error) {
session, _ := Store.Get(req, key + SessionName) session, _ := Store.Get(req, SessionName)
value, err := getSessionValue(session, key)
value := session.Values[key] if err != nil {
if value == nil {
return "", errors.New("could not find a matching session for this request") return "", errors.New("could not find a matching session for this request")
} }
return value.(string), nil return value, nil
}
func getSessionValue(session *sessions.Session, key string) (string, error) {
value := session.Values[key]
if value == nil {
return "", fmt.Errorf("could not find a matching session for this request")
}
rdata := strings.NewReader(value.(string))
r, err := gzip.NewReader(rdata)
if err != nil {
return "", err
}
s, err := ioutil.ReadAll(r)
if err != nil {
return "", err
}
return string(s), nil
}
func updateSessionValue(session *sessions.Session, key, value string) error {
var b bytes.Buffer
gz := gzip.NewWriter(&b)
if _, err := gz.Write([]byte(value)); err != nil {
return err
}
if err := gz.Flush(); err != nil {
return err
}
if err := gz.Close(); err != nil {
return err
}
session.Values[key] = b.String()
return nil
} }

View file

@ -9,9 +9,9 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"fmt"
"github.com/markbates/goth" "github.com/markbates/goth"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"fmt"
) )
const ( const (

View file

@ -8,15 +8,16 @@ import (
"net/http" "net/http"
"strings" "strings"
"fmt"
"github.com/markbates/goth" "github.com/markbates/goth"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"fmt"
) )
const ( const (
authURL = "https://www.dropbox.com/1/oauth2/authorize" authURL = "https://www.dropbox.com/oauth2/authorize"
tokenURL = "https://api.dropbox.com/1/oauth2/token" tokenURL = "https://api.dropbox.com/oauth2/token"
accountURL = "https://api.dropbox.com/1/account/info" accountURL = "https://api.dropbox.com/2/users/get_current_account"
) )
// Provider is the implementation of `goth.Provider` for accessing Dropbox. // Provider is the implementation of `goth.Provider` for accessing Dropbox.
@ -86,7 +87,7 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName)
} }
req, err := http.NewRequest("GET", accountURL, nil) req, err := http.NewRequest("POST", accountURL, nil)
if err != nil { if err != nil {
return user, err return user, err
} }

View file

@ -11,12 +11,12 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"fmt"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt"
"github.com/markbates/goth"
"golang.org/x/oauth2"
) )
const ( const (

View file

@ -11,9 +11,9 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"fmt"
"github.com/markbates/goth" "github.com/markbates/goth"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"fmt"
) )
// These vars define the Authentication, Token, and Profile URLS for Gitlab. If // These vars define the Authentication, Token, and Profile URLS for Gitlab. If

View file

@ -11,9 +11,9 @@ import (
"net/url" "net/url"
"strings" "strings"
"fmt"
"github.com/markbates/goth" "github.com/markbates/goth"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"fmt"
) )
const ( const (

View file

@ -1,17 +1,17 @@
package openidConnect package openidConnect
import ( import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"io/ioutil"
"net/http" "net/http"
"strings" "strings"
"fmt"
"encoding/json"
"encoding/base64"
"io/ioutil"
"errors"
"golang.org/x/oauth2"
"github.com/markbates/goth"
"time" "time"
"bytes"
) )
const ( const (
@ -93,8 +93,8 @@ func New(clientKey, secret, callbackURL, openIDAutoDiscoveryURL string, scopes .
NameClaims: []string{NameClaim}, NameClaims: []string{NameClaim},
NickNameClaims: []string{NicknameClaim, PreferredUsernameClaim}, NickNameClaims: []string{NicknameClaim, PreferredUsernameClaim},
EmailClaims: []string{EmailClaim}, EmailClaims: []string{EmailClaim},
AvatarURLClaims:[]string{PictureClaim}, AvatarURLClaims: []string{PictureClaim},
FirstNameClaims:[]string{GivenNameClaim}, FirstNameClaims: []string{GivenNameClaim},
LastNameClaims: []string{FamilyNameClaim}, LastNameClaims: []string{FamilyNameClaim},
LocationClaims: []string{AddressClaim}, LocationClaims: []string{AddressClaim},

View file

@ -1,12 +1,12 @@
package openidConnect package openidConnect
import ( import (
"encoding/json"
"errors" "errors"
"github.com/markbates/goth" "github.com/markbates/goth"
"encoding/json" "golang.org/x/oauth2"
"strings" "strings"
"time" "time"
"golang.org/x/oauth2"
) )
// Session stores data during the auth process with the OpenID Connect provider. // Session stores data during the auth process with the OpenID Connect provider.

View file

@ -9,10 +9,11 @@ import (
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"fmt"
"github.com/markbates/goth" "github.com/markbates/goth"
"github.com/mrjones/oauth" "github.com/mrjones/oauth"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"fmt"
) )
var ( var (
@ -107,7 +108,7 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
response, err := p.consumer.Get( response, err := p.consumer.Get(
endpointProfile, endpointProfile,
map[string]string{"include_entities": "false", "skip_status": "true"}, map[string]string{"include_entities": "false", "skip_status": "true", "include_email": "true"},
sess.AccessToken) sess.AccessToken)
if err != nil { if err != nil {
return user, err return user, err
@ -126,6 +127,9 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
user.Name = user.RawData["name"].(string) user.Name = user.RawData["name"].(string)
user.NickName = user.RawData["screen_name"].(string) user.NickName = user.RawData["screen_name"].(string)
if user.RawData["email"] != nil {
user.Email = user.RawData["email"].(string)
}
user.Description = user.RawData["description"].(string) user.Description = user.RawData["description"].(string)
user.AvatarURL = user.RawData["profile_image_url"].(string) user.AvatarURL = user.RawData["profile_image_url"].(string)
user.UserID = user.RawData["id_str"].(string) user.UserID = user.RawData["id_str"].(string)

58
vendor/vendor.json vendored
View file

@ -654,64 +654,64 @@
"revisionTime": "2017-10-25T03:15:54Z" "revisionTime": "2017-10-25T03:15:54Z"
}, },
{ {
"checksumSHA1": "O3KUfEXQPfdQ+tCMpP2RAIRJJqY=", "checksumSHA1": "q9MD1ienC+kmKq5i51oAktQEV1E=",
"path": "github.com/markbates/goth", "path": "github.com/markbates/goth",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "MkFKwLV3icyUo4oP0BgEs+7+R1Y=", "checksumSHA1": "+nosptSgGb2qCAR6CSHV2avwmNg=",
"path": "github.com/markbates/goth/gothic", "path": "github.com/markbates/goth/gothic",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "crNSlQADjX6hcxykON2tFCqY4iw=", "checksumSHA1": "pJ+Cws/TU22K6tZ/ALFOvvH1K5U=",
"path": "github.com/markbates/goth/providers/bitbucket", "path": "github.com/markbates/goth/providers/bitbucket",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "1Kp4DKkJNVn135Xg8H4a6CFBNy8=", "checksumSHA1": "bKokLof0Pkk5nEhW8NdbfcVzuqk=",
"path": "github.com/markbates/goth/providers/dropbox", "path": "github.com/markbates/goth/providers/dropbox",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "cGs1da29iOBJh5EAH0icKDbN8CA=", "checksumSHA1": "VzbroIA9R00Ig3iGnOlZLU7d4ls=",
"path": "github.com/markbates/goth/providers/facebook", "path": "github.com/markbates/goth/providers/facebook",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "P6nBZ850aaekpOcoXNdRhK86bH8=", "checksumSHA1": "P6nBZ850aaekpOcoXNdRhK86bH8=",
"path": "github.com/markbates/goth/providers/github", "path": "github.com/markbates/goth/providers/github",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "o/109paSRy9HqV87gR4zUZMMSzs=", "checksumSHA1": "ld488t+yGoTwtmiCSSggEX4fxVk=",
"path": "github.com/markbates/goth/providers/gitlab", "path": "github.com/markbates/goth/providers/gitlab",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "cX6kR9y94BWFZvI/7UFrsFsP3FQ=", "checksumSHA1": "qXEulD7vnwY9hFrxh91Pm5YrvTM=",
"path": "github.com/markbates/goth/providers/gplus", "path": "github.com/markbates/goth/providers/gplus",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "sMYKhqAUZXM1+T/TjlMhWh8Vveo=", "checksumSHA1": "wsOBzyp4LKDhfCPmX1LLP7T0S3U=",
"path": "github.com/markbates/goth/providers/openidConnect", "path": "github.com/markbates/goth/providers/openidConnect",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "1w0V6jYXaGlEtZcMeYTOAAucvgw=", "checksumSHA1": "o6RqMbbE8QNZhNT9TsAIRMPI8tg=",
"path": "github.com/markbates/goth/providers/twitter", "path": "github.com/markbates/goth/providers/twitter",
"revision": "90362394a367f9d77730911973462a53d69662ba", "revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2017-02-23T14:12:10Z" "revisionTime": "2018-02-15T02:27:40Z"
}, },
{ {
"checksumSHA1": "61HNjGetaBoMp8HBOpuEZRSim8g=", "checksumSHA1": "61HNjGetaBoMp8HBOpuEZRSim8g=",