Repository: supabase/gotrue
Pull request #135 (2021-07-09 05:10:31)

Feat: Twitch Provider
See #118 


Patch:
diff --git a/README.md b/README.md
index 5bf2c796..5663aaec 100644
--- a/README.md
+++ b/README.md
@@ -172,7 +172,7 @@ The default group to assign all new users to.
 
 ### External Authentication Providers
 
-We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google` and `twitter` for external authentication.
+We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch` and `twitter` for external authentication.
 Use the names as the keys underneath `external` to configure each separately.
 
 ```properties
@@ -203,7 +203,7 @@ The URI a OAuth2 provider will redirect to with the `code` and `state` values.
 
 The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab` only. Defaults to `https://gitlab.com`.
 
-#### Apple OAuth 
+#### Apple OAuth
 
 To try out external authentication with Apple locally, you will need to do the following:
 1. Remap localhost to \<my_custom_dns \> in your `/etc/hosts` config.
@@ -427,6 +427,7 @@ GoTrue exposes the following endpoints:
       "github": true,
       "gitlab": true,
       "google": true,
+      "twitch": true,
       "twitter": true
     },
     "disable_signup": false,
@@ -544,7 +545,7 @@ GoTrue exposes the following endpoints:
 
   Magic Link. Will deliver a link (e.g. `/verify?type=magiclink&token=fgtyuf68ddqdaDd`) to the user based on
   email address which they can use to redeem an access_token.
-  
+
   By default Magic Links can only be sent once every 60 seconds
 
   ```json
@@ -558,14 +559,14 @@ GoTrue exposes the following endpoints:
   ```json
   {}
   ```
-  
+
   when clicked the magic link will redirect the user to `<SITE_URL>#access_token=x&refresh_token=y&expires_in=z&token_type=bearer&type=magiclink` (see `/verify` above)
 
 ### **POST /recover**
 
   Password recovery. Will deliver a password recovery mail to the user based on
   email address.
-  
+
   By default recovery links can only be sent once every 60 seconds
 
   ```json
@@ -685,18 +686,17 @@ GoTrue exposes the following endpoints:
 
   query params:
   ```
-  provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | twitter
+  provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | twitch | twitter
   scopes=<optional additional scopes depending on the provider (email and name are requested by default)>
   ```
- 
+
   Redirects to provider and then to `/callback`
-  
+
   For apple specific setup see: https://github.com/supabase/gotrue#apple-oauth
-  
+
 ### **GET /callback**
 
   External provider should redirect to here
- 
-  Redirects to `<GOTRUE_SITE_URL>#access_token=<access_token>&refresh_token=<refresh_token>&provider_token=<provider_oauth_token>&expires_in=3600&provider=<provider_name>`
-  If additional scopes were requested then `provider_token` will be populated, you can use this to fetch additional data from the provider or interact with their services 
 
+  Redirects to `<GOTRUE_SITE_URL>#access_token=<access_token>&refresh_token=<refresh_token>&provider_token=<provider_oauth_token>&expires_in=3600&provider=<provider_name>`
+  If additional scopes were requested then `provider_token` will be populated, you can use this to fetch additional data from the provider or interact with their services
diff --git a/api/external.go b/api/external.go
index 97873ef2..298cb220 100644
--- a/api/external.go
+++ b/api/external.go
@@ -352,6 +352,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
 		return provider.NewGoogleProvider(config.External.Google, scopes)
 	case "facebook":
 		return provider.NewFacebookProvider(config.External.Facebook, scopes)
+	case "twitch":
+		return provider.NewTwitchProvider(config.External.Twitch, scopes)
 	case "twitter":
 		return provider.NewTwitterProvider(config.External.Twitter, scopes)
 	case "saml":
diff --git a/api/provider/twitch.go b/api/provider/twitch.go
new file mode 100644
index 00000000..edcea510
--- /dev/null
+++ b/api/provider/twitch.go
@@ -0,0 +1,129 @@
+package provider
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"io/ioutil"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/netlify/gotrue/conf"
+	"golang.org/x/oauth2"
+)
+
+// Twitch
+
+const (
+	defaultTwitchAuthBase = "id.twitch.tv"
+	defaultTwitchAPIBase  = "api.twitch.tv"
+)
+
+type twitchProvider struct {
+	*oauth2.Config
+	APIHost string
+}
+
+type twitchUsers struct {
+	Data []struct {
+		ID              string    `json:"id"`
+		Login           string    `json:"login"`
+		DisplayName     string    `json:"display_name"`
+		Type            string    `json:"type"`
+		BroadcasterType string    `json:"broadcaster_type"`
+		Description     string    `json:"description"`
+		ProfileImageURL string    `json:"profile_image_url"`
+		OfflineImageURL string    `json:"offline_image_url"`
+		ViewCount       int       `json:"view_count"`
+		Email           string    `json:"email"`
+		CreatedAt       time.Time `json:"created_at"`
+	} `json:"data"`
+}
+
+// NewTwitchProvider creates a Twitch account provider.
+func NewTwitchProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
+	if err := ext.Validate(); err != nil {
+		return nil, err
+	}
+
+	apiHost := chooseHost(ext.URL, defaultTwitchAPIBase)
+	authHost := chooseHost(ext.URL, defaultTwitchAuthBase)
+
+	oauthScopes := []string{
+		"user:read:email",
+	}
+
+	if scopes != "" {
+		oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
+	}
+
+	return &twitchProvider{
+		Config: &oauth2.Config{
+			ClientID:     ext.ClientID,
+			ClientSecret: ext.Secret,
+			Endpoint: oauth2.Endpoint{
+				AuthURL:  authHost + "/oauth2/authorize",
+				TokenURL: authHost + "/oauth2/token",
+			},
+			RedirectURL: ext.RedirectURI,
+			Scopes:      oauthScopes,
+		},
+		APIHost: apiHost,
+	}, nil
+}
+
+func (t twitchProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
+	return t.Exchange(context.Background(), code)
+}
+
+func (t twitchProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
+	var u twitchUsers
+
+	// Perform http request, because we neeed to set the Client-Id header
+	req, err := http.NewRequest("GET", t.APIHost+"/helix/users", nil)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// set headers
+	req.Header.Set("Client-Id", t.Config.ClientID)
+	req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+
+	client := &http.Client{}
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	body, _ := ioutil.ReadAll(resp.Body)
+	json.Unmarshal(body, &u)
+	defer resp.Body.Close()
+
+	if len(u.Data) == 0 {
+		return nil, errors.New("unable to find user with twitch provider")
+	}
+
+	user := u.Data[0]
+
+	if user.Email == "" {
+		return nil, errors.New("unable to find email with twitch provider")
+	}
+
+	data := &UserProvidedData{
+		Metadata: map[string]string{
+			nameKey:      user.Login,
+			aliasKey:     user.DisplayName,
+			avatarURLKey: user.ProfileImageURL,
+		},
+		Emails: []Email{{
+			Email:    user.Email,
+			Verified: true,
+			Primary:  true,
+		}},
+	}
+
+	return data, nil
+}
diff --git a/api/settings.go b/api/settings.go
index 169d18c9..4e597630 100644
--- a/api/settings.go
+++ b/api/settings.go
@@ -11,6 +11,7 @@ type ProviderSettings struct {
 	GitLab    bool `json:"gitlab"`
 	Google    bool `json:"google"`
 	Facebook  bool `json:"facebook"`
+	Twitch    bool `json:"twitch"`
 	Twitter   bool `json:"twitter"`
 	Email     bool `json:"email"`
 	SAML      bool `json:"saml"`
@@ -40,6 +41,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
 			GitLab:    config.External.Gitlab.Enabled,
 			Google:    config.External.Google.Enabled,
 			Facebook:  config.External.Facebook.Enabled,
+			Twitch:    config.External.Twitch.Enabled,
 			Twitter:   config.External.Twitter.Enabled,
 			Email:     !config.External.Email.Disabled,
 			SAML:      config.External.Saml.Enabled,
diff --git a/conf/configuration.go b/conf/configuration.go
index 1a7e1519..f6bc1b39 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -89,6 +89,7 @@ type ProviderConfiguration struct {
 	Google      OAuthProviderConfiguration `json:"google"`
 	Facebook    OAuthProviderConfiguration `json:"facebook"`
 	Twitter     OAuthProviderConfiguration `json:"twitter"`
+	Twitch      OAuthProviderConfiguration `json:"twitch"`
 	Email       EmailProviderConfiguration `json:"email"`
 	Saml        SamlProviderConfiguration  `json:"saml"`
 	RedirectURL string                     `json:"redirect_url"`


Test patch:
diff --git a/api/external_test.go b/api/external_test.go
index 3663e0e6..356da113 100644
--- a/api/external_test.go
+++ b/api/external_test.go
@@ -100,7 +100,7 @@ func performAuthorization(ts *ExternalTestSuite, provider string, code string, i
 
 func assertAuthorizationSuccess(ts *ExternalTestSuite, u *url.URL, tokenCount int, userCount int, email string, name string, avatar string) {
 	// ensure redirect has #access_token=...
-	v, err := url.ParseQuery(u.Fragment)
+	v, err := url.ParseQuery(u.RawQuery)
 	ts.Require().NoError(err)
 	ts.Require().Empty(v.Get("error_description"))
 	ts.Require().Empty(v.Get("error"))
@@ -122,7 +122,7 @@ func assertAuthorizationSuccess(ts *ExternalTestSuite, u *url.URL, tokenCount in
 
 func assertAuthorizationFailure(ts *ExternalTestSuite, u *url.URL, errorDescription string, errorType string, email string) {
 	// ensure new sign ups error
-	v, err := url.ParseQuery(u.Fragment)
+	v, err := url.ParseQuery(u.RawQuery)
 	ts.Require().NoError(err)
 	ts.Require().Equal(errorDescription, v.Get("error_description"))
 	ts.Require().Equal(errorType, v.Get("error"))
diff --git a/api/external_twitch_test.go b/api/external_twitch_test.go
new file mode 100644
index 00000000..d6325d99
--- /dev/null
+++ b/api/external_twitch_test.go
@@ -0,0 +1,171 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+
+	jwt "github.com/dgrijalva/jwt-go"
+)
+
+func (ts *ExternalTestSuite) TestSignupExternalTwitch() {
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=twitch", nil)
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	ts.Require().Equal(http.StatusFound, w.Code)
+	u, err := url.Parse(w.Header().Get("Location"))
+	ts.Require().NoError(err, "redirect url parse failed")
+	q := u.Query()
+	ts.Equal(ts.Config.External.Twitch.RedirectURI, q.Get("redirect_uri"))
+	ts.Equal(ts.Config.External.Twitch.ClientID, q.Get("client_id"))
+	ts.Equal("code", q.Get("response_type"))
+	ts.Equal("user:read:email", q.Get("scope"))
+
+	claims := ExternalProviderClaims{}
+	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
+	_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
+		return []byte(ts.API.config.OperatorToken), nil
+	})
+	ts.Require().NoError(err)
+
+	ts.Equal("twitch", claims.Provider)
+	ts.Equal(ts.Config.SiteURL, claims.SiteURL)
+}
+
+func TwitchTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/oauth2/token":
+			*tokenCount++
+			ts.Equal(code, r.FormValue("code"))
+			ts.Equal("authorization_code", r.FormValue("grant_type"))
+			ts.Equal(ts.Config.External.Twitch.RedirectURI, r.FormValue("redirect_uri"))
+
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, `{"access_token":"Twitch_token","expires_in":100000}`)
+		case "/helix/users":
+			*userCount++
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, user)
+		default:
+			w.WriteHeader(500)
+			ts.Fail("unknown Twitch oauth call %s", r.URL.Path)
+		}
+	}))
+
+	ts.Config.External.Twitch.URL = server.URL
+
+	return server
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalTwitch_AuthorizationCode() {
+	ts.Config.DisableSignup = false
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	TwitchUser := `{"data":[{"id":"1","login":"Twitch Test","display_name":"Twitch Test","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
+	server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "twitch", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "twitch@example.com", "Twitch Test", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalTwitchDisableSignupErrorWhenNoUser() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
+	server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "twitch", code, "")
+
+	assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "twitch@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalTwitchDisableSignupErrorWhenEmptyEmail() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":""}]}`
+	server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
+	defer server.Close()
+
+
+	u := performAuthorization(ts, "twitch", code, "")
+
+	assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "twitch@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalTwitchDisableSignupSuccessWithPrimaryEmail() {
+	ts.Config.DisableSignup = true
+
+	ts.createUser("twitch@example.com", "Twitch Test", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8", "")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
+	server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "twitch", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "twitch@example.com", "Twitch Test", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchSuccessWhenMatchingToken() {
+	// name and avatar should be populated from Twitch API
+	ts.createUser("twitch@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
+	server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "twitch", code, "invite_token")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "twitch@example.com", "Twitch Test", "https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchErrorWhenNoMatchingToken() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
+	server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "twitch", "invite_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchErrorWhenWrongToken() {
+	ts.createUser("twitch@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"twitch@example.com"}]}`
+	server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "twitch", "wrong_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalTwitchErrorWhenEmailDoesntMatch() {
+	ts.createUser("twitch@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	TwitchUser := `{"data":[{"id":"1","login":"Twitch user","display_name":"Twitch user","type":"","broadcaster_type":"","description":"","profile_image_url":"https://s.gravatar.com/avatar/23463b99b62a72f26ed677cc556c44e8","offline_image_url":"","email":"other@example.com"}]}`
+	server := TwitchTestSignupSetup(ts, &tokenCount, &userCount, code, TwitchUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "twitch", code, "invite_token")
+
+	assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
+}
diff --git a/hack/test.env b/hack/test.env
index ff1caa26..6b6e850e 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -34,6 +34,10 @@ GOTRUE_EXTERNAL_GOOGLE_ENABLED=true
 GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_GOOGLE_SECRET=testsecret
 GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://identity.services.netlify.com/callback
+GOTRUE_EXTERNAL_TWITCH_ENABLED=true
+GOTRUE_EXTERNAL_TWITCH_CLIENT_ID=testclientid
+GOTRUE_EXTERNAL_TWITCH_SECRET=testsecret
+GOTRUE_EXTERNAL_TWITCH_REDIRECT_URI=https://identity.services.netlify.com/callback
 GOTRUE_EXTERNAL_SAML_ENABLED=true
 GOTRUE_EXTERNAL_SAML_METADATA_URL=
 GOTRUE_EXTERNAL_SAML_API_BASE=http://localhost