Repository: supabase/gotrue
Pull request #332 (2022-01-06 19:30:38)

fix: verify identity email
## What kind of change does this PR introduce?
* Fixes the following issue
   - User initially signs up with external provider on gotrue
   - User's email with external provider is unverified 
   - User verifies email with external provider OR user changes email with external provider & verifies it
   - User tries to sign-in with external provider on gotrue again 

## Expected behaviour 
* User should be confirmed since the email associated with the external provider has been verified already

>    - User verifies email with external provider OR user changes email with external provider & verifies it
* In the context of the latter, `auth.user.email` shouldn't be updated to reflect the new email associated with the identity
    - we don't update the email in `auth.user.email` because that column has a unique constraint on it
    - there is a possibility that a person has 2 user accounts in gotrue
        * User 1 (password-based), `foo@example.com`, `verified`  
        * User 2 (github sign-in), `bar@example.com`, `not verified` 
        * Person updates email in github to `foo@example.com` and verifies `foo@example.com`
        * When person signs in with github, if User 2's email is updated to `foo@example.com`, there will now be a conflict
        
* `auth.identities.identity_data->>'email'` should reflect the updated email 
   - Reason is because the `identity_data` column should always contain the latest user data from the external oauth provider
* `auth.identities.identity_data->>'email_verified'` should reflect the updated email verified status
   - Same as reason stated above


Patch:
diff --git a/README.md b/README.md
index d30b6f66..73d67f67 100644
--- a/README.md
+++ b/README.md
@@ -458,7 +458,7 @@ Controls the number of digits of the sms otp sent.
 
 `SMS_PROVIDER` - `string`
 
-Available options are: `twilio` and `messagebird`
+Available options are: `twilio`, `messagebird`, `textlocal`, and `vonage`
 
 Then you can use your [twilio credentials](https://www.twilio.com/docs/usage/requests-to-twilio#credentials):
 
diff --git a/api/auth.go b/api/auth.go
index f4723fa0..35d82444 100644
--- a/api/auth.go
+++ b/api/auth.go
@@ -14,8 +14,9 @@ import (
 // requireAuthentication checks incoming requests for tokens presented using the Authorization header
 func (a *API) requireAuthentication(w http.ResponseWriter, r *http.Request) (context.Context, error) {
 	token, err := a.extractBearerToken(w, r)
+	config := getConfig(r.Context())
 	if err != nil {
-		a.clearCookieToken(r.Context(), w)
+		a.clearCookieTokens(config, w)
 		return nil, err
 	}
 
@@ -64,7 +65,7 @@ func (a *API) parseJWTClaims(bearer string, r *http.Request, w http.ResponseWrit
 		return []byte(config.JWT.Secret), nil
 	})
 	if err != nil {
-		a.clearCookieToken(ctx, w)
+		a.clearCookieTokens(config, w)
 		return nil, unauthorizedError("Invalid token: %v", err)
 	}
 
diff --git a/api/external.go b/api/external.go
index 63db5f0b..621cbc96 100644
--- a/api/external.go
+++ b/api/external.go
@@ -2,6 +2,7 @@ package api
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -12,6 +13,7 @@ import (
 	"github.com/gofrs/uuid"
 	jwt "github.com/golang-jwt/jwt"
 	"github.com/netlify/gotrue/api/provider"
+	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/storage"
 	"github.com/sirupsen/logrus"
@@ -154,18 +156,9 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 			// check if identity exists
 			if identity, terr = models.FindIdentityByIdAndProvider(tx, userData.Metadata.Subject, providerType); terr != nil {
 				if models.IsNotFoundError(terr) {
-					// search user using all available emails
-					for _, e := range userData.Emails {
-						if e.Verified || config.Mailer.Autoconfirm {
-							user, terr = models.FindUserByEmailAndAudience(tx, instanceID, e.Email, aud)
-							if terr != nil && !models.IsNotFoundError(terr) {
-								return internalServerError("Error checking for duplicate users").WithInternalError(terr)
-							}
-							if user != nil {
-								emailData = e
-								break
-							}
-						}
+					user, emailData, terr = a.getUserByVerifiedEmail(tx, config, userData.Emails, instanceID, aud)
+					if terr != nil && !models.IsNotFoundError(terr) {
+						return internalServerError("Error checking for existing users").WithInternalError(terr)
 					}
 					if user != nil {
 						if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
@@ -219,6 +212,14 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 				if terr = tx.UpdateOnly(identity, "identity_data", "last_sign_in_at"); terr != nil {
 					return terr
 				}
+				// email & verified status might have changed if identity's email changed
+				emailData = provider.Email{
+					Email:    userData.Metadata.Email,
+					Verified: userData.Metadata.EmailVerified,
+				}
+				if terr = user.UpdateUserMetaData(tx, identityData); terr != nil {
+					return terr
+				}
 				if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
 					return terr
 				}
@@ -226,7 +227,16 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 
 			if !user.IsConfirmed() {
 				if !emailData.Verified && !config.Mailer.Autoconfirm {
-					return badRequestError("Please verify your email (%v) with %v", emailData.Email, providerType)
+					mailer := a.Mailer(ctx)
+					referrer := a.getReferrer(r)
+					if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer); terr != nil {
+						if errors.Is(terr, MaxFrequencyLimitError) {
+							return tooManyRequestsError("For security purposes, you can only request this once every minute")
+						}
+						return internalServerError("Error sending confirmation mail").WithInternalError(terr)
+					}
+					// email must be verified to issue a token
+					return nil
 				}
 
 				if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, nil); terr != nil {
@@ -269,6 +279,8 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 		q.Set("expires_in", strconv.Itoa(token.ExpiresIn))
 		q.Set("refresh_token", token.RefreshToken)
 		rurl += "#" + q.Encode()
+	} else {
+		rurl = a.prepErrorRedirectURL(unauthorizedError("Unverified email with %v", providerType), r, rurl)
 	}
 	http.Redirect(w, r, rurl, http.StatusFound)
 	return nil
@@ -379,6 +391,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
 		return provider.NewLinkedinProvider(config.External.Linkedin, scopes)
 	case "facebook":
 		return provider.NewFacebookProvider(config.External.Facebook, scopes)
+	case "notion":
+		return provider.NewNotionProvider(config.External.Notion)
 	case "spotify":
 		return provider.NewSpotifyProvider(config.External.Spotify, scopes)
 	case "slack":
@@ -466,3 +480,24 @@ func (a *API) createNewIdentity(conn *storage.Connection, user *models.User, pro
 
 	return identity, nil
 }
+
+// getUserByVerifiedEmail checks if one of the verified emails already belongs to a user
+func (a *API) getUserByVerifiedEmail(tx *storage.Connection, config *conf.Configuration, emails []provider.Email, instanceID uuid.UUID, aud string) (*models.User, provider.Email, error) {
+	var user *models.User
+	var emailData provider.Email
+	var err error
+
+	for _, e := range emails {
+		if e.Verified || config.Mailer.Autoconfirm {
+			user, err = models.FindUserByEmailAndAudience(tx, instanceID, e.Email, aud)
+			if err != nil && !models.IsNotFoundError(err) {
+				return user, emailData, err
+			}
+			if user != nil {
+				emailData = e
+				break
+			}
+		}
+	}
+	return user, emailData, err
+}
diff --git a/api/logout.go b/api/logout.go
index dcaef795..77b4f2fa 100644
--- a/api/logout.go
+++ b/api/logout.go
@@ -11,8 +11,9 @@ import (
 func (a *API) Logout(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
 	instanceID := getInstanceID(ctx)
+	config := getConfig(ctx)
 
-	a.clearCookieToken(ctx, w)
+	a.clearCookieTokens(config, w)
 
 	u, err := getUserFromClaims(ctx, a.db)
 	if err != nil {
diff --git a/api/provider/linkedin.go b/api/provider/linkedin.go
index 6cbb5d54..331faf45 100644
--- a/api/provider/linkedin.go
+++ b/api/provider/linkedin.go
@@ -64,7 +64,6 @@ func NewLinkedinProvider(ext conf.OAuthProviderConfiguration, scopes string) (OA
 		return nil, err
 	}
 
-	// authHost := chooseHost(ext.URL, defaultLinkedinAuthBase)
 	apiPath := chooseHost(ext.URL, defaultLinkedinAPIBase)
 
 	oauthScopes := []string{
@@ -120,6 +119,7 @@ func (g linkedinProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*
 	emails := []Email{}
 
 	if e.Elements[0].HandleTilde.EmailAddress != "" {
+		// linkedin only returns the primary email which is verified for the r_emailaddress scope.
 		emails = append(emails, Email{
 			Email:    e.Elements[0].HandleTilde.EmailAddress,
 			Primary:  true,
@@ -129,11 +129,12 @@ func (g linkedinProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*
 
 	return &UserProvidedData{
 		Metadata: &Claims{
-			Issuer:  g.APIPath,
-			Subject: u.ID,
-			Name:    strings.TrimSpace(GetName(u.FirstName) + " " + GetName(u.LastName)),
-			Picture: u.AvatarURL.DisplayImage.Elements[0].Identifiers[0].Identifier,
-			Email:   e.Elements[0].HandleTilde.EmailAddress,
+			Issuer:        g.APIPath,
+			Subject:       u.ID,
+			Name:          strings.TrimSpace(GetName(u.FirstName) + " " + GetName(u.LastName)),
+			Picture:       u.AvatarURL.DisplayImage.Elements[0].Identifiers[0].Identifier,
+			Email:         e.Elements[0].HandleTilde.EmailAddress,
+			EmailVerified: true,
 
 			// To be deprecated
 			AvatarURL:  u.AvatarURL.DisplayImage.Elements[0].Identifiers[0].Identifier,
diff --git a/api/provider/notion.go b/api/provider/notion.go
new file mode 100644
index 00000000..5971b308
--- /dev/null
+++ b/api/provider/notion.go
@@ -0,0 +1,114 @@
+package provider
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/netlify/gotrue/conf"
+	"golang.org/x/oauth2"
+)
+
+const (
+	defaultNotionApiBase = "api.notion.com"
+	notionApiVersion     = "2021-08-16"
+)
+
+type notionProvider struct {
+	*oauth2.Config
+	APIPath string
+}
+
+type notionUser struct {
+	Bot struct {
+		Owner struct {
+			User struct {
+				ID        string `json:"id"`
+				Name      string `json:"name"`
+				AvatarURL string `json:"avatar_url"`
+				Person    struct {
+					Email string `json:"email"`
+				} `json:"person"`
+			} `json:"user"`
+		} `json:"owner"`
+	} `json:"bot"`
+}
+
+// NewNotionProvider creates a Notion account provider.
+func NewNotionProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, error) {
+	if err := ext.Validate(); err != nil {
+		return nil, err
+	}
+
+	authHost := chooseHost(ext.URL, defaultNotionApiBase)
+
+	return &notionProvider{
+		Config: &oauth2.Config{
+			ClientID:     ext.ClientID,
+			ClientSecret: ext.Secret,
+			Endpoint: oauth2.Endpoint{
+				AuthURL:  authHost + "/v1/oauth/authorize",
+				TokenURL: authHost + "/v1/oauth/token",
+			},
+			RedirectURL: ext.RedirectURI,
+		},
+		APIPath: authHost,
+	}, nil
+}
+
+func (g notionProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
+	return g.Exchange(oauth2.NoContext, code)
+}
+
+func (g notionProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
+	var u notionUser
+
+	// Perform http request, because we need to set the Notion-Version header
+	req, err := http.NewRequest("GET", g.APIPath+"/v1/users/me", nil)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// set headers
+	req.Header.Set("Notion-Version", notionApiVersion)
+	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 u.Bot.Owner.User.Person.Email == "" {
+		return nil, errors.New("unable to find email with notion provider")
+	}
+
+	return &UserProvidedData{
+		Metadata: &Claims{
+			Issuer:        g.APIPath,
+			Subject:       u.Bot.Owner.User.ID,
+			Name:          u.Bot.Owner.User.Name,
+			Picture:       u.Bot.Owner.User.AvatarURL,
+			Email:         u.Bot.Owner.User.Person.Email,
+			EmailVerified: true, // Notion dosen't provide data on if email is verified.
+
+			// To be deprecated
+			AvatarURL:  u.Bot.Owner.User.AvatarURL,
+			FullName:   u.Bot.Owner.User.Name,
+			ProviderId: u.Bot.Owner.User.ID,
+		},
+		Emails: []Email{{
+			Email:    u.Bot.Owner.User.Person.Email,
+			Verified: true, // Notion dosen't provide data on if email is verified.
+			Primary:  true,
+		}},
+	}, nil
+}
diff --git a/api/settings.go b/api/settings.go
index ab988eb0..c53f751d 100644
--- a/api/settings.go
+++ b/api/settings.go
@@ -12,6 +12,7 @@ type ProviderSettings struct {
 	Google    bool `json:"google"`
 	Linkedin  bool `json:"linkedin"`
 	Facebook  bool `json:"facebook"`
+	Notion    bool `json:"notion"`
 	Spotify   bool `json:"spotify"`
 	Slack     bool `json:"slack"`
 	Twitch    bool `json:"twitch"`
@@ -48,6 +49,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
 			Google:    config.External.Google.Enabled,
 			Linkedin:  config.External.Linkedin.Enabled,
 			Facebook:  config.External.Facebook.Enabled,
+			Notion:    config.External.Notion.Enabled,
 			Spotify:   config.External.Spotify.Enabled,
 			Slack:     config.External.Slack.Enabled,
 			Twitch:    config.External.Twitch.Enabled,
diff --git a/api/signup.go b/api/signup.go
index ead7a64a..6270e954 100644
--- a/api/signup.go
+++ b/api/signup.go
@@ -205,7 +205,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 			}
 
 			if cookie != "" && config.Cookie.Duration > 0 {
-				if terr = a.setCookieToken(config, token.Token, cookie == useSessionCookie, w); terr != nil {
+				if terr = a.setCookieTokens(config, token, cookie == useSessionCookie, w); terr != nil {
 					return internalServerError("Failed to set JWT cookie. %s", terr)
 				}
 			}
diff --git a/api/sms_provider/sms_provider.go b/api/sms_provider/sms_provider.go
index 50181545..11b3b3f2 100644
--- a/api/sms_provider/sms_provider.go
+++ b/api/sms_provider/sms_provider.go
@@ -16,6 +16,10 @@ func GetSmsProvider(config conf.Configuration) (SmsProvider, error) {
 		return NewTwilioProvider(config.Sms.Twilio)
 	case "messagebird":
 		return NewMessagebirdProvider(config.Sms.Messagebird)
+	case "textlocal":
+		return NewTextlocalProvider(config.Sms.Textlocal)
+	case "vonage":
+		return NewVonageProvider(config.Sms.Vonage)
 	default:
 		return nil, fmt.Errorf("Sms Provider %s could not be found", name)
 	}
diff --git a/api/sms_provider/textlocal.go b/api/sms_provider/textlocal.go
new file mode 100644
index 00000000..c45cab70
--- /dev/null
+++ b/api/sms_provider/textlocal.go
@@ -0,0 +1,83 @@
+package sms_provider
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/netlify/gotrue/conf"
+)
+
+const (
+	defaultTextLocalApiBase = "https://api.textlocal.in"
+)
+
+type TextlocalProvider struct {
+	Config  *conf.TextlocalProviderConfiguration
+	APIPath string
+}
+
+type TextlocalError struct {
+	Code    int    `json:"code"`
+	Message string `json:"message"`
+}
+
+type TextlocalResponse struct {
+	Status string           `json:"status"`
+	Errors []TextlocalError `json:"errors"`
+}
+
+// Creates a SmsProvider with the Textlocal Config
+func NewTextlocalProvider(config conf.TextlocalProviderConfiguration) (SmsProvider, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
+	}
+
+	apiPath := defaultTextLocalApiBase + "/send"
+	return &TextlocalProvider{
+		Config:  &config,
+		APIPath: apiPath,
+	}, nil
+}
+
+// Send an SMS containing the OTP with Textlocal's API
+func (t TextlocalProvider) SendSms(phone string, message string) error {
+	body := url.Values{
+		"sender":  {t.Config.Sender},
+		"apikey":  {t.Config.ApiKey},
+		"message": {message},
+		"numbers": {phone},
+	}
+
+	client := &http.Client{}
+	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
+	if err != nil {
+		return err
+	}
+
+	r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	res, err := client.Do(r)
+	if err != nil {
+		return err
+	}
+	defer res.Body.Close()
+
+	resp := &TextlocalResponse{}
+	derr := json.NewDecoder(res.Body).Decode(resp)
+	if derr != nil {
+		return derr
+	}
+	
+	if len(resp.Errors) == 0 {
+		return errors.New("Textlocal error: Internal Error")
+	}
+
+	if resp.Status != "success" {
+		return fmt.Errorf("Textlocal error: %v (code: %v)", resp.Errors[0].Message, resp.Errors[0].Code)
+	}
+
+	return nil
+}
diff --git a/api/sms_provider/vonage.go b/api/sms_provider/vonage.go
new file mode 100644
index 00000000..e266dbcc
--- /dev/null
+++ b/api/sms_provider/vonage.go
@@ -0,0 +1,84 @@
+package sms_provider
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/netlify/gotrue/conf"
+)
+
+const (
+	defaultVonageApiBase = "https://rest.nexmo.com"
+)
+
+type VonageProvider struct {
+	Config  *conf.VonageProviderConfiguration
+	APIPath string
+}
+
+type VonageResponseMessage struct {
+	Status    string `json:"status"`
+	ErrorText string `json:"error-text"`
+}
+
+type VonageResponse struct {
+	Messages []VonageResponseMessage `json:"messages"`
+}
+
+// Creates a SmsProvider with the Vonage Config
+func NewVonageProvider(config conf.VonageProviderConfiguration) (SmsProvider, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
+	}
+
+	apiPath := defaultVonageApiBase + "/sms/json"
+	return &VonageProvider{
+		Config:  &config,
+		APIPath: apiPath,
+	}, nil
+}
+
+// Send an SMS containing the OTP with Vonage's API
+func (t VonageProvider) SendSms(phone string, message string) error {
+	body := url.Values{
+		"from":       {t.Config.From},
+		"to":         {phone},
+		"text":       {message},
+		"api_key":    {t.Config.ApiKey},
+		"api_secret": {t.Config.ApiSecret},
+	}
+
+	client := &http.Client{}
+	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
+	if err != nil {
+		return err
+	}
+
+	r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	res, err := client.Do(r)
+	if err != nil {
+		return err
+	}
+	defer res.Body.Close()
+
+	resp := &VonageResponse{}
+	derr := json.NewDecoder(res.Body).Decode(resp)
+	if derr != nil {
+		return derr
+	}
+
+	if len(resp.Messages) <= 0 {
+		return errors.New("Vonage error: Internal Error")
+	}
+
+	// A status of zero indicates success; a non-zero value means something went wrong.
+	if resp.Messages[0].Status != "0" {
+		return fmt.Errorf("Vonage error: %v (status: %v)", resp.Messages[0].ErrorText, resp.Messages[0].Status)
+	}
+
+	return nil
+}
diff --git a/api/token.go b/api/token.go
index 49014ac4..6f40c6cd 100644
--- a/api/token.go
+++ b/api/token.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"crypto/sha256"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"strconv"
@@ -200,7 +201,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri
 		}
 
 		if cookie != "" && config.Cookie.Duration > 0 {
-			if terr = a.setCookieToken(config, token.Token, cookie == useSessionCookie, w); terr != nil {
+			if terr = a.setCookieTokens(config, token, cookie == useSessionCookie, w); terr != nil {
 				return internalServerError("Failed to set JWT cookie. %s", terr)
 			}
 		}
@@ -256,7 +257,7 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 	}
 
 	if token.Revoked {
-		a.clearCookieToken(ctx, w)
+		a.clearCookieTokens(config, w)
 		if config.Security.RefreshTokenRotationEnabled {
 			// Revoke all tokens in token family
 			err = a.db.Transaction(func(tx *storage.Connection) error {
@@ -275,6 +276,7 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 
 	var tokenString string
 	var newToken *models.RefreshToken
+	var newTokenResponse *AccessTokenResponse
 
 	err = a.db.Transaction(func(tx *storage.Connection) error {
 		var terr error
@@ -293,7 +295,14 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 		}
 
 		if cookie != "" && config.Cookie.Duration > 0 {
-			if terr = a.setCookieToken(config, tokenString, cookie == useSessionCookie, w); terr != nil {
+			newTokenResponse = &AccessTokenResponse{
+				Token:        tokenString,
+				TokenType:    "bearer",
+				ExpiresIn:    config.JWT.Exp,
+				RefreshToken: newToken.Token,
+				User:         user,
+			}
+			if terr = a.setCookieTokens(config, newTokenResponse, cookie == useSessionCookie, w); terr != nil {
 				return internalServerError("Failed to set JWT cookie. %s", terr)
 			}
 		}
@@ -303,13 +312,7 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 		return err
 	}
 	metering.RecordLogin("token", user.ID, instanceID)
-	return sendJSON(w, http.StatusOK, &AccessTokenResponse{
-		Token:        tokenString,
-		TokenType:    "bearer",
-		ExpiresIn:    config.JWT.Exp,
-		RefreshToken: newToken.Token,
-		User:         user,
-	})
+	return sendJSON(w, http.StatusOK, newTokenResponse)
 }
 
 // IdTokenGrant implements the id_token grant type flow
@@ -517,10 +520,22 @@ func (a *API) issueRefreshToken(ctx context.Context, conn *storage.Connection, u
 	}, nil
 }
 
-func (a *API) setCookieToken(config *conf.Configuration, tokenString string, session bool, w http.ResponseWriter) error {
+// setCookieTokens sets the access_token & refresh_token in the cookies
+func (a *API) setCookieTokens(config *conf.Configuration, token *AccessTokenResponse, session bool, w http.ResponseWriter) error {
+	// don't need to catch error here since we always set the cookie name
+	_ = a.setCookieToken(config, "access-token", token.Token, session, w)
+	_ = a.setCookieToken(config, "refresh-token", token.RefreshToken, session, w)
+	return nil
+}
+
+func (a *API) setCookieToken(config *conf.Configuration, name string, tokenString string, session bool, w http.ResponseWriter) error {
+	if name == "" {
+		return errors.New("Failed to set cookie, invalid name")
+	}
+	cookieName := config.Cookie.Key + "-" + name
 	exp := time.Second * time.Duration(config.Cookie.Duration)
 	cookie := &http.Cookie{
-		Name:     config.Cookie.Key,
+		Name:     cookieName,
 		Value:    tokenString,
 		Secure:   true,
 		HttpOnly: true,
@@ -535,10 +550,18 @@ func (a *API) setCookieToken(config *conf.Configuration, tokenString string, ses
 	return nil
 }
 
-func (a *API) clearCookieToken(ctx context.Context, w http.ResponseWriter) {
-	config := getConfig(ctx)
+func (a *API) clearCookieTokens(config *conf.Configuration, w http.ResponseWriter) {
+	a.clearCookieToken(config, "access-token", w)
+	a.clearCookieToken(config, "refresh-token", w)
+}
+
+func (a *API) clearCookieToken(config *conf.Configuration, name string, w http.ResponseWriter) {
+	cookieName := config.Cookie.Key
+	if name != "" {
+		cookieName += "-" + name
+	}
 	http.SetCookie(w, &http.Cookie{
-		Name:     config.Cookie.Key,
+		Name:     cookieName,
 		Value:    "",
 		Expires:  time.Now().Add(-1 * time.Hour * 10),
 		MaxAge:   -1,
diff --git a/api/verify.go b/api/verify.go
index d3e332db..3cfe1165 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -125,8 +125,8 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
 			return terr
 		}
 
-		if cookie != "" && config.Cookie.Duration > 0 {
-			if terr = a.setCookieToken(config, token.Token, cookie == useSessionCookie, w); terr != nil {
+		if config.Cookie.Duration > 0 {
+			if terr = a.setCookieTokens(config, token, cookie == useSessionCookie, w); terr != nil {
 				return internalServerError("Failed to set JWT cookie. %s", terr)
 			}
 		}
diff --git a/conf/configuration.go b/conf/configuration.go
index b74e1ccb..f30ed282 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -88,6 +88,7 @@ type ProviderConfiguration struct {
 	Github      OAuthProviderConfiguration `json:"github"`
 	Gitlab      OAuthProviderConfiguration `json:"gitlab"`
 	Google      OAuthProviderConfiguration `json:"google"`
+	Notion      OAuthProviderConfiguration `json:"notion"`
 	Linkedin    OAuthProviderConfiguration `json:"linkedin"`
 	Spotify     OAuthProviderConfiguration `json:"spotify"`
 	Slack       OAuthProviderConfiguration `json:"slack"`
@@ -131,6 +132,8 @@ type SmsProviderConfiguration struct {
 	Template     string                           `json:"template"`
 	Twilio       TwilioProviderConfiguration      `json:"twilio"`
 	Messagebird  MessagebirdProviderConfiguration `json:"messagebird"`
+	Textlocal    TextlocalProviderConfiguration   `json:"textlocal"`
+	Vonage       VonageProviderConfiguration      `json:"vonage"`
 }
 
 type TwilioProviderConfiguration struct {
@@ -144,6 +147,17 @@ type MessagebirdProviderConfiguration struct {
 	Originator string `json:"originator" split_words:"true"`
 }
 
+type TextlocalProviderConfiguration struct {
+	ApiKey string `json:"api_key" split_words:"true"`
+	Sender string `json:"sender" split_words:"true"`
+}
+
+type VonageProviderConfiguration struct {
+	ApiKey    string `json:"api_key" split_words:"true"`
+	ApiSecret string `json:"api_secret" split_words:"true"`
+	From      string `json:"from" split_words:"true"`
+}
+
 type CaptchaConfiguration struct {
 	Enabled  bool   `json:"enabled" default:"false"`
 	Provider string `json:"provider" default:"hcaptcha"`
@@ -366,3 +380,26 @@ func (t *MessagebirdProviderConfiguration) Validate() error {
 	}
 	return nil
 }
+
+func (t *TextlocalProviderConfiguration) Validate() error {
+	if t.ApiKey == "" {
+		return errors.New("Missing Textlocal API key")
+	}
+	if t.Sender == "" {
+		return errors.New("Missing Textlocal sender")
+	}
+  return nil
+}
+  
+func (t *VonageProviderConfiguration) Validate() error {
+	if t.ApiKey == "" {
+		return errors.New("Missing Vonage API key")
+	}
+	if t.ApiSecret == "" {
+		return errors.New("Missing Vonage API secret")
+	}
+	if t.From == "" {
+		return errors.New("Missing Vonage 'from' parameter")
+	}
+	return nil
+}
diff --git a/example.env b/example.env
index 437f92a4..60f009c9 100644
--- a/example.env
+++ b/example.env
@@ -24,7 +24,7 @@ GOTRUE_SMTP_PASS=""
 GOTRUE_SMTP_ADMIN_EMAIL=""
 GOTRUE_SMTP_SENDER_NAME=""
 
-# Mailer config 
+# Mailer config
 GOTRUE_MAILER_AUTOCONFIRM="true"
 GOTRUE_MAILER_URLPATHS_CONFIRMATION="/verify"
 GOTRUE_MAILER_URLPATHS_INVITE="/verify"
@@ -102,6 +102,12 @@ GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID=""
 GOTRUE_EXTERNAL_FACEBOOK_SECRET=""
 GOTRUE_EXTERNAL_FACEBOOK_REDIRECT_URI="https://localhost:9999/callback"
 
+# Notion OAuth config
+GOTRUE_EXTERNAL_NOTION_ENABLED="false"
+GOTRUE_EXTERNAL_NOTION_CLIENT_ID=""
+GOTRUE_EXTERNAL_NOTION_SECRET=""
+GOTRUE_EXTERNAL_NOTION_REDIRECT_URI="https://localhost:9999/callback"
+
 # Twitter OAuth1 config
 GOTRUE_EXTERNAL_TWITTER_ENABLED="false"
 GOTRUE_EXTERNAL_TWITTER_CLIENT_ID=""
@@ -143,6 +149,11 @@ GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID=""
 GOTRUE_SMS_TEMPLATE="This is from supabase. Your code is {{ .Code }} ."
 GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY=""
 GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR=""
+GOTRUE_SMS_TEXTLOCAL_API_KEY=""
+GOTRUE_SMS_TEXTLOCAL_SENDER=""
+GOTRUE_SMS_VONAGE_API_KEY=""
+GOTRUE_SMS_VONAGE_API_SECRET=""
+GOTRUE_SMS_VONAGE_FROM=""
 
 # Captcha config
 GOTRUE_SECURITY_CAPTCHA_ENABLED="false"
diff --git a/mailer/mailer.go b/mailer/mailer.go
index b0d6fe8d..49657276 100644
--- a/mailer/mailer.go
+++ b/mailer/mailer.go
@@ -26,6 +26,7 @@ type Mailer interface {
 // NewMailer returns a new gotrue mailer
 func NewMailer(instanceConfig *conf.Configuration) Mailer {
 	if instanceConfig.SMTP.Host == "" {
+		logrus.Infof("Noop mailer being used for %v", instanceConfig.SiteURL)
 		return &noopMailer{}
 	}
 


Test patch:
diff --git a/api/external_github_test.go b/api/external_github_test.go
index 8ea1311d..f80b182a 100644
--- a/api/external_github_test.go
+++ b/api/external_github_test.go
@@ -194,5 +194,10 @@ func (ts *ExternalTestSuite) TestSignupExternalGitHubErrorWhenVerifiedFalse() {
 
 	u := performAuthorization(ts, "github", code, "")
 
-	assertAuthorizationFailure(ts, u, "Please verify your email (github@example.com) with github", "invalid_request", "")
+	v, err := url.ParseQuery(u.Fragment)
+	ts.Require().NoError(err)
+	ts.Equal("unauthorized_client", v.Get("error"))
+	ts.Equal("401", v.Get("error_code"))
+	ts.Equal("Unverified email with github", v.Get("error_description"))
+	assertAuthorizationFailure(ts, u, "", "", "")
 }
diff --git a/api/external_notion_test.go b/api/external_notion_test.go
new file mode 100644
index 00000000..be82e09e
--- /dev/null
+++ b/api/external_notion_test.go
@@ -0,0 +1,170 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+
+	jwt "github.com/golang-jwt/jwt"
+)
+
+const (
+	notionUser           string = `{"bot":{"owner":{"user":{"id":"notionTestId","name":"Notion Test","avatar_url":"http://example.com/avatar","person":{"email":"notion@example.com"},"verified_email":true}}}}`
+	notionUserWrongEmail string = `{"bot":{"owner":{"user":{"id":"notionTestId","name":"Notion Test","avatar_url":"http://example.com/avatar","person":{"email":"other@example.com"},"verified_email":true}}}}`
+	notionUserNoEmail    string = `{"bot":{"owner":{"user":{"id":"notionTestId","name":"Notion Test","avatar_url":"http://example.com/avatar","verified_email":true}}}}}`
+)
+
+func (ts *ExternalTestSuite) TestSignupExternalNotion() {
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=notion", 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.Notion.RedirectURI, q.Get("redirect_uri"))
+	ts.Equal(ts.Config.External.Notion.ClientID, q.Get("client_id"))
+	ts.Equal("code", q.Get("response_type"))
+
+	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.Config.JWT.Secret), nil
+	})
+	ts.Require().NoError(err)
+
+	ts.Equal("notion", claims.Provider)
+	ts.Equal(ts.Config.SiteURL, claims.SiteURL)
+}
+
+func NotionTestSignupSetup(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 "/v1/oauth/token":
+			*tokenCount++
+			ts.Equal(code, r.FormValue("code"))
+			ts.Equal("authorization_code", r.FormValue("grant_type"))
+			ts.Equal(ts.Config.External.Notion.RedirectURI, r.FormValue("redirect_uri"))
+
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, `{"access_token":"notion_token","expires_in":100000}`)
+		case "/v1/users/me":
+			*userCount++
+			ts.Contains(r.Header, "Authorization")
+			ts.Contains(r.Header, "Notion-Version")
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, user)
+		default:
+			w.WriteHeader(500)
+			ts.Fail("unknown notion oauth call %s", r.URL.Path)
+		}
+	}))
+
+	ts.Config.External.Notion.URL = server.URL
+
+	return server
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalNotion_AuthorizationCode() {
+	ts.Config.DisableSignup = false
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "notion", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "notion@example.com", "Notion Test", "notionTestId", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalNotionDisableSignupErrorWhenNoUser() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "notion", code, "")
+
+	assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "notion@example.com")
+}
+func (ts *ExternalTestSuite) TestSignupExternalNotionDisableSignupErrorWhenEmptyEmail() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUserNoEmail)
+	defer server.Close()
+
+	u := performAuthorization(ts, "notion", code, "")
+
+	assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "notion@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalNotionDisableSignupSuccessWithPrimaryEmail() {
+	ts.Config.DisableSignup = true
+
+	ts.createUser("notionTestId", "notion@example.com", "Notion Test", "http://example.com/avatar", "")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "notion", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "notion@example.com", "Notion Test", "notionTestId", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalNotionSuccessWhenMatchingToken() {
+	// name and avatar should be populated from Notion API
+	ts.createUser("notionTestId", "notion@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "notion", code, "invite_token")
+
+	fmt.Printf("%+v\n", u)
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "notion@example.com", "Notion Test", "notionTestId", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalNotionErrorWhenNoMatchingToken() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "notion", "invite_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalNotionErrorWhenWrongToken() {
+	ts.createUser("notionTestId", "notion@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "notion", "wrong_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalNotionErrorWhenEmailDoesntMatch() {
+	ts.createUser("notionTestId", "notion@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := NotionTestSignupSetup(ts, &tokenCount, &userCount, code, notionUserWrongEmail)
+	defer server.Close()
+
+	u := performAuthorization(ts, "notion", code, "invite_token")
+
+	assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
+}
diff --git a/api/settings_test.go b/api/settings_test.go
index 683232ac..c706bd48 100644
--- a/api/settings_test.go
+++ b/api/settings_test.go
@@ -32,6 +32,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
 	require.True(t, p.Bitbucket)
 	require.True(t, p.Discord)
 	require.True(t, p.Facebook)
+	require.True(t, p.Notion)
 	require.True(t, p.Spotify)
 	require.True(t, p.Slack)
 	require.True(t, p.Google)
diff --git a/hack/test.env b/hack/test.env
index dbd2ca92..fb1c56c3 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -49,6 +49,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_NOTION_ENABLED=true
+GOTRUE_EXTERNAL_NOTION_CLIENT_ID=testclientid
+GOTRUE_EXTERNAL_NOTION_SECRET=testsecret
+GOTRUE_EXTERNAL_NOTION_REDIRECT_URI=https://identity.services.netlify.com/callback
 GOTRUE_EXTERNAL_SPOTIFY_ENABLED=true
 GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_SPOTIFY_SECRET=testsecret