From f42976cd2d7179f449b5ac90b63d9914684d55c5 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Fri, 22 Aug 2025 15:00:36 -0400 Subject: [PATCH] Use updated Google user info endpoint --- README.md | 1 - examples/main.go | 3 - providers/google/google.go | 22 +++- providers/google/google_test.go | 40 ++++++- providers/gplus/gplus.go | 194 -------------------------------- providers/gplus/gplus_test.go | 78 ------------- providers/gplus/session.go | 61 ---------- providers/gplus/session_test.go | 47 -------- 8 files changed, 53 insertions(+), 393 deletions(-) delete mode 100644 providers/gplus/gplus.go delete mode 100644 providers/gplus/gplus_test.go delete mode 100644 providers/gplus/session.go delete mode 100644 providers/gplus/session_test.go diff --git a/README.md b/README.md index af0dc00b1..5d8117289 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ $ go get github.com/markbates/goth * GitHub * Gitlab * Google -* Google+ (deprecated) * Heroku * InfluxCloud * Instagram diff --git a/examples/main.go b/examples/main.go index 8bb9ddf97..0044beb8e 100644 --- a/examples/main.go +++ b/examples/main.go @@ -30,7 +30,6 @@ import ( "github.com/markbates/goth/providers/github" "github.com/markbates/goth/providers/gitlab" "github.com/markbates/goth/providers/google" - "github.com/markbates/goth/providers/gplus" "github.com/markbates/goth/providers/heroku" "github.com/markbates/goth/providers/instagram" "github.com/markbates/goth/providers/intercom" @@ -89,7 +88,6 @@ func main() { facebook.New(os.Getenv("FACEBOOK_KEY"), os.Getenv("FACEBOOK_SECRET"), "http://localhost:3000/auth/facebook/callback"), fitbit.New(os.Getenv("FITBIT_KEY"), os.Getenv("FITBIT_SECRET"), "http://localhost:3000/auth/fitbit/callback"), google.New(os.Getenv("GOOGLE_KEY"), os.Getenv("GOOGLE_SECRET"), "http://localhost:3000/auth/google/callback"), - gplus.New(os.Getenv("GPLUS_KEY"), os.Getenv("GPLUS_SECRET"), "http://localhost:3000/auth/gplus/callback"), github.New(os.Getenv("GITHUB_KEY"), os.Getenv("GITHUB_SECRET"), "http://localhost:3000/auth/github/callback"), spotify.New(os.Getenv("SPOTIFY_KEY"), os.Getenv("SPOTIFY_SECRET"), "http://localhost:3000/auth/spotify/callback"), linkedin.New(os.Getenv("LINKEDIN_KEY"), os.Getenv("LINKEDIN_SECRET"), "http://localhost:3000/auth/linkedin/callback"), @@ -179,7 +177,6 @@ func main() { "github": "Github", "gitlab": "Gitlab", "google": "Google", - "gplus": "Google Plus", "heroku": "Heroku", "instagram": "Instagram", "intercom": "Intercom", diff --git a/providers/google/google.go b/providers/google/google.go index 629afb418..ca0695463 100644 --- a/providers/google/google.go +++ b/providers/google/google.go @@ -7,14 +7,13 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "github.com/markbates/goth" "golang.org/x/oauth2" ) -const endpointProfile string = "https://www.googleapis.com/oauth2/v2/userinfo" +const endpointProfile string = "https://openidconnect.googleapis.com/v1/userinfo" // New creates a new Google provider, and sets up important connection details. // You should always call `google.New` to get a new Provider. Never try to create @@ -76,6 +75,7 @@ func (p *Provider) BeginAuth(state string) (goth.Session, error) { type googleUser struct { ID string `json:"id"` + Sub string `json:"sub"` Email string `json:"email"` Name string `json:"name"` FirstName string `json:"given_name"` @@ -100,7 +100,13 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) } - response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) + + response, err := p.Client().Do(req) if err != nil { return user, err } @@ -127,7 +133,13 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { user.NickName = u.Name user.Email = u.Email user.AvatarURL = u.Picture - user.UserID = u.ID + + if u.ID != "" { + user.UserID = u.ID + } else { + user.UserID = u.Sub + } + // Google provides other useful fields such as 'hd'; get them from RawData if err := json.Unmarshal(responseBytes, &user.RawData); err != nil { return user, err @@ -148,7 +160,7 @@ func newConfig(provider *Provider, scopes []string) *oauth2.Config { if len(scopes) > 0 { c.Scopes = append(c.Scopes, scopes...) } else { - c.Scopes = []string{"email"} + c.Scopes = []string{"openid", "email", "profile"} } return c } diff --git a/providers/google/google_test.go b/providers/google/google_test.go index f1954760a..20aa1081c 100644 --- a/providers/google/google_test.go +++ b/providers/google/google_test.go @@ -1,6 +1,7 @@ package google_test import ( + "encoding/json" "fmt" "os" "testing" @@ -31,7 +32,7 @@ func Test_BeginAuth(t *testing.T) { a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=email") + a.Contains(s.AuthURL, "scope=openid+email+profile") a.Contains(s.AuthURL, "access_type=offline") } @@ -50,7 +51,7 @@ func Test_BeginAuthWithPrompt(t *testing.T) { a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=email") + a.Contains(s.AuthURL, "scope=openid+email+profile") a.Contains(s.AuthURL, "access_type=offline") a.Contains(s.AuthURL, "prompt=test+prompts") } @@ -70,7 +71,7 @@ func Test_BeginAuthWithHostedDomain(t *testing.T) { a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=email") + a.Contains(s.AuthURL, "scope=openid+email+profile") a.Contains(s.AuthURL, "access_type=offline") a.Contains(s.AuthURL, "hd=example.com") } @@ -90,7 +91,7 @@ func Test_BeginAuthWithLoginHint(t *testing.T) { a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=email") + a.Contains(s.AuthURL, "scope=openid+email+profile") a.Contains(s.AuthURL, "access_type=offline") a.Contains(s.AuthURL, "login_hint=john%40example.com") } @@ -115,6 +116,37 @@ func Test_SessionFromJSON(t *testing.T) { a.Equal(session.AccessToken, "1234567890") } +func Test_UserIDHandling(t *testing.T) { + t.Parallel() + a := assert.New(t) + + // Test v2 endpoint response format (uses 'id' field) + v2Response := `{"id":"123456789","email":"test@example.com","name":"Test User"}` + var userV2 struct { + ID string `json:"id"` + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + } + err := json.Unmarshal([]byte(v2Response), &userV2) + a.NoError(err) + a.Equal("123456789", userV2.ID) + a.Equal("", userV2.Sub) + + // Test OpenID Connect response format (uses 'sub' field) + oidcResponse := `{"sub":"123456789","email":"test@example.com","name":"Test User"}` + var userOIDC struct { + ID string `json:"id"` + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + } + err = json.Unmarshal([]byte(oidcResponse), &userOIDC) + a.NoError(err) + a.Equal("", userOIDC.ID) + a.Equal("123456789", userOIDC.Sub) +} + func googleProvider() *google.Provider { return google.New(os.Getenv("GOOGLE_KEY"), os.Getenv("GOOGEL_SECRET"), "/foo") } diff --git a/providers/gplus/gplus.go b/providers/gplus/gplus.go deleted file mode 100644 index 832d723ad..000000000 --- a/providers/gplus/gplus.go +++ /dev/null @@ -1,194 +0,0 @@ -// Package gplus implements the OAuth2 protocol for authenticating users through Google+. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package gplus - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://accounts.google.com/o/oauth2/auth?access_type=offline" - tokenURL string = "https://accounts.google.com/o/oauth2/token" - endpointProfile string = "https://www.googleapis.com/oauth2/v2/userinfo" -) - -// New creates a new Google+ provider, and sets up important connection details. -// You should always call `gplus.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "gplus", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Google+. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - prompt oauth2.AuthCodeOption - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the gplus package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Google+ for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - var opts []oauth2.AuthCodeOption - if p.prompt != nil { - opts = append(opts, p.prompt) - } - url := p.config.AuthCodeURL(state, opts...) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Google+ and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - FirstName string `json:"given_name"` - LastName string `json:"family_name"` - Link string `json:"link"` - Picture string `json:"picture"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.Name = u.Name - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.Name - user.Email = u.Email - // user.Description = u.Bio - user.AvatarURL = u.Picture - user.UserID = u.ID - // user.Location = u.Location.Name - - return err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = []string{"profile", "email", "openid"} - } - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -// SetPrompt sets the prompt values for the GPlus OAuth call. Use this to -// force users to choose and account every time by passing "select_account", -// for example. -// See https://developers.google.com/identity/protocols/OpenIDConnect#authenticationuriparameters -func (p *Provider) SetPrompt(prompt ...string) { - if len(prompt) == 0 { - return - } - p.prompt = oauth2.SetAuthURLParam("prompt", strings.Join(prompt, " ")) -} diff --git a/providers/gplus/gplus_test.go b/providers/gplus/gplus_test.go deleted file mode 100644 index f5a3abfb9..000000000 --- a/providers/gplus/gplus_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package gplus_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/gplus" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := gplusProvider() - a.Equal(provider.ClientKey, os.Getenv("GPLUS_KEY")) - a.Equal(provider.Secret, os.Getenv("GPLUS_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := gplusProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*gplus.Session) - a.NoError(err) - a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GPLUS_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=profile+email+openid") -} - -func Test_BeginAuthWithPrompt(t *testing.T) { - // This exists because there was a panic caused by the oauth2 package when - // the AuthCodeOption passed was nil. This test uses it, Test_BeginAuth does - // not, to ensure both cases are covered. - t.Parallel() - a := assert.New(t) - - provider := gplusProvider() - provider.SetPrompt("test", "prompts") - session, err := provider.BeginAuth("test_state") - s := session.(*gplus.Session) - a.NoError(err) - a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GPLUS_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=profile+email+openid") - a.Contains(s.AuthURL, "prompt=test+prompts") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), gplusProvider()) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := gplusProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"https://accounts.google.com/o/oauth2/auth","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*gplus.Session) - a.Equal(session.AuthURL, "https://accounts.google.com/o/oauth2/auth") - a.Equal(session.AccessToken, "1234567890") -} - -func gplusProvider() *gplus.Provider { - return gplus.New(os.Getenv("GPLUS_KEY"), os.Getenv("GPLUS_SECRET"), "/foo") -} diff --git a/providers/gplus/session.go b/providers/gplus/session.go deleted file mode 100644 index 9710031f4..000000000 --- a/providers/gplus/session.go +++ /dev/null @@ -1,61 +0,0 @@ -package gplus - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Google+. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Google+ provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Google+ and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/gplus/session_test.go b/providers/gplus/session_test.go deleted file mode 100644 index 9308c977d..000000000 --- a/providers/gplus/session_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package gplus - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Equal(s.String(), s.Marshal()) -}