Repository: supabase/gotrue
Pull request #981 (2023-02-23 09:16:21)

feat: support for whatsapp as a channel for sending OTPs 
## What kind of change does this PR introduce?

We wish to support WhatsApp as a channel for sending OTPs due to the added security provided and more. For this first pass, we will only support Twilio. 

Companion PR:
- Frontend:  https://github.com/supabase/gotrue-js/pull/616

For now, we only support Twilio API. We can slowly ungate more providers as needed. There should be no change to the API guarantees otherwise - if the user does not specify `whatsapp` the API will default to SMS.


I've excluded `reauthenticate` and `resend` methods from whatsapp support as we don't currently use them in the client library. I'm not sure whether we plan on supporting them long term so have  left them with SMS as default in order to reduce complexity. Let me know if anyone feels otherwise and we can consider amending.

For now, we also expose the `updateUser` endpoint only on the API level and not at the client library so as to minimize potential breaking changes

**Uncertainties**
- For Supabase, I'll need to check if users can (and want to) use the same Whatsapp and SMS number for sending via Twilio. Otherwise, we may need to introduce additional configuration variables as a last resort.

Testing
- This was manually test and and an existing check for testing channel parameters was added


Patch:
diff --git a/internal/api/errors.go b/internal/api/errors.go
index 435bac5d..d15075d6 100644
--- a/internal/api/errors.go
+++ b/internal/api/errors.go
@@ -20,6 +20,8 @@ var (
 	UserExistsError   error = errors.New("user already exists")
 )
 
+const InvalidChannelError = "Invalid channel, supported values are 'sms' or 'whatsapp'"
+
 var oauthErrorMap = map[int]string{
 	http.StatusBadRequest:          "invalid_request",
 	http.StatusUnauthorized:        "unauthorized_client",
diff --git a/internal/api/helpers.go b/internal/api/helpers.go
index 8d8fdf30..e57e7779 100644
--- a/internal/api/helpers.go
+++ b/internal/api/helpers.go
@@ -69,7 +69,7 @@ func (a *API) requestAud(ctx context.Context, r *http.Request) string {
 		return claims.Audience
 	}
 
-	// Finally, return the default of none of the above methods are successful
+	// Finally, return the default if none of the above methods are successful
 	return config.JWT.Aud
 }
 
diff --git a/internal/api/otp.go b/internal/api/otp.go
index 998338c0..ac19474a 100644
--- a/internal/api/otp.go
+++ b/internal/api/otp.go
@@ -18,12 +18,14 @@ type OtpParams struct {
 	Phone      string                 `json:"phone"`
 	CreateUser bool                   `json:"create_user"`
 	Data       map[string]interface{} `json:"data"`
+	Channel    string                 `json:"channel"`
 }
 
 // SmsParams contains the request body params for sms otp
 type SmsParams struct {
-	Phone string                 `json:"phone"`
-	Data  map[string]interface{} `json:"data"`
+	Phone   string                 `json:"phone"`
+	Channel string                 `json:"channel"`
+	Data    map[string]interface{} `json:"data"`
 }
 
 // Otp returns the MagicLink or SmsOtp handler based on the request body params
@@ -46,6 +48,9 @@ func (a *API) Otp(w http.ResponseWriter, r *http.Request) error {
 	if params.Email != "" && params.Phone != "" {
 		return badRequestError("Only an email address or phone number should be provided")
 	}
+	if params.Email != "" && params.Channel != "" {
+		return badRequestError("Channel should only be specified with Phone OTP")
+	}
 
 	if ok, err := a.shouldCreateUser(r, params); !ok {
 		return badRequestError("Signups not allowed for otp")
@@ -83,6 +88,14 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read sms otp params: %v", err)
 	}
+	// For backwards compatibility, we default to SMS if params Channel is not specified
+	if params.Phone != "" && params.Channel == "" {
+		params.Channel = sms_provider.SMSProvider
+	}
+	if params.Phone != "" && !sms_provider.IsValidMessageChannel(params.Channel, *config) {
+		return badRequestError(InvalidChannelError)
+	}
+
 	if params.Data == nil {
 		params.Data = make(map[string]interface{})
 	}
@@ -117,6 +130,7 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 			Phone:    params.Phone,
 			Password: password,
 			Data:     params.Data,
+			Channel:  params.Channel,
 		}
 		newBodyContent, err := json.Marshal(signUpParams)
 		if err != nil {
@@ -133,7 +147,8 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 			}
 
 			signUpParams := &SignupParams{
-				Phone: params.Phone,
+				Phone:   params.Phone,
+				Channel: params.Channel,
 			}
 			newBodyContent, err := json.Marshal(signUpParams)
 			if err != nil {
@@ -150,14 +165,16 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 	}
 
 	err = db.Transaction(func(tx *storage.Connection) error {
-		if err := models.NewAuditLogEntry(r, tx, user, models.UserRecoveryRequestedAction, "", nil); err != nil {
+		if err := models.NewAuditLogEntry(r, tx, user, models.UserRecoveryRequestedAction, "", map[string]interface{}{
+			"channel": params.Channel,
+		}); err != nil {
 			return err
 		}
 		smsProvider, terr := sms_provider.GetSmsProvider(*config)
 		if terr != nil {
 			return badRequestError("Error sending sms: %v", terr)
 		}
-		if err := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider); err != nil {
+		if err := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel); err != nil {
 			return badRequestError("Error sending sms otp: %v", err)
 		}
 		return nil
diff --git a/internal/api/phone.go b/internal/api/phone.go
index f49fcf09..fe1f76e7 100644
--- a/internal/api/phone.go
+++ b/internal/api/phone.go
@@ -44,7 +44,7 @@ func formatPhoneNumber(phone string) string {
 }
 
 // sendPhoneConfirmation sends an otp to the user's phone number
-func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection, user *models.User, phone, otpType string, smsProvider sms_provider.SmsProvider) error {
+func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection, user *models.User, phone, otpType string, smsProvider sms_provider.SmsProvider, channel string) error {
 	config := a.config
 
 	var token *string
@@ -87,7 +87,7 @@ func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection,
 		message = strings.Replace(config.Sms.Template, "{{ .Code }}", otp, -1)
 	}
 
-	if serr := smsProvider.SendSms(phone, message); serr != nil {
+	if serr := smsProvider.SendMessage(phone, message, channel); serr != nil {
 		*token = oldToken
 		return serr
 	}
diff --git a/internal/api/reauthenticate.go b/internal/api/reauthenticate.go
index 587b22d1..86703292 100644
--- a/internal/api/reauthenticate.go
+++ b/internal/api/reauthenticate.go
@@ -49,7 +49,7 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error {
 			if terr != nil {
 				return badRequestError("Error sending sms: %v", terr)
 			}
-			return a.sendPhoneConfirmation(ctx, tx, user, phone, phoneReauthenticationOtp, smsProvider)
+			return a.sendPhoneConfirmation(ctx, tx, user, phone, phoneReauthenticationOtp, smsProvider, sms_provider.SMSProvider)
 		}
 		return nil
 	})
diff --git a/internal/api/resend.go b/internal/api/resend.go
index a9ce1766..7629870a 100644
--- a/internal/api/resend.go
+++ b/internal/api/resend.go
@@ -129,7 +129,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
 			if terr != nil {
 				return terr
 			}
-			return a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider)
+			return a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, sms_provider.SMSProvider)
 		case emailChangeVerification:
 			return a.sendEmailChange(tx, config, user, mailer, params.Email, referrer, config.Mailer.OtpLength)
 		case phoneChangeVerification:
@@ -137,7 +137,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
 			if terr != nil {
 				return terr
 			}
-			return a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneChangeVerification, smsProvider)
+			return a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneChangeVerification, smsProvider, sms_provider.SMSProvider)
 		}
 		return nil
 	})
diff --git a/internal/api/signup.go b/internal/api/signup.go
index 30646524..8b57127f 100644
--- a/internal/api/signup.go
+++ b/internal/api/signup.go
@@ -25,6 +25,7 @@ type SignupParams struct {
 	Data     map[string]interface{} `json:"data"`
 	Provider string                 `json:"-"`
 	Aud      string                 `json:"-"`
+	Channel  string                 `json:"channel"`
 }
 
 // Signup is the endpoint for registering a new user
@@ -66,6 +67,15 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		params.Data = make(map[string]interface{})
 	}
 
+	// For backwards compatibility, we default to SMS if params Channel is not specified
+	if params.Phone != "" && params.Channel == "" {
+		params.Channel = sms_provider.SMSProvider
+	}
+
+	if params.Provider == "phone" && !sms_provider.IsValidMessageChannel(params.Channel, *config) {
+		return badRequestError(InvalidChannelError)
+	}
+
 	var user *models.User
 	var grantParams models.GrantParams
 	params.Aud = a.requestAud(ctx, r)
@@ -154,6 +164,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 			if config.Sms.Autoconfirm {
 				if terr = models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{
 					"provider": params.Provider,
+					"channel":  params.Channel,
 				}); terr != nil {
 					return terr
 				}
@@ -173,7 +184,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 				if terr != nil {
 					return badRequestError("Error sending confirmation sms: %v", terr)
 				}
-				if terr = a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider); terr != nil {
+				if terr = a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel); terr != nil {
 					return badRequestError("Error sending confirmation sms: %v", terr)
 				}
 			}
diff --git a/internal/api/sms_provider/messagebird.go b/internal/api/sms_provider/messagebird.go
index a8621158..9d8e7330 100644
--- a/internal/api/sms_provider/messagebird.go
+++ b/internal/api/sms_provider/messagebird.go
@@ -55,6 +55,15 @@ func NewMessagebirdProvider(config conf.MessagebirdProviderConfiguration) (SmsPr
 	}, nil
 }
 
+func (t *MessagebirdProvider) SendMessage(phone string, message string, channel string) error {
+	switch channel {
+	case SMSProvider:
+		return t.SendSms(phone, message)
+	default:
+		return fmt.Errorf("channel type %q is not supported for Messagebird", channel)
+	}
+}
+
 // Send an SMS containing the OTP with Messagebird's API
 func (t *MessagebirdProvider) SendSms(phone string, message string) error {
 	body := url.Values{
diff --git a/internal/api/sms_provider/sms_provider.go b/internal/api/sms_provider/sms_provider.go
index aac80c07..ce4f110b 100644
--- a/internal/api/sms_provider/sms_provider.go
+++ b/internal/api/sms_provider/sms_provider.go
@@ -11,6 +11,9 @@ import (
 
 var defaultTimeout time.Duration = time.Second * 10
 
+const SMSProvider = "sms"
+const WhatsappProvider = "whatsapp"
+
 func init() {
 	timeoutStr := os.Getenv("GOTRUE_INTERNAL_HTTP_TIMEOUT")
 	if timeoutStr != "" {
@@ -23,7 +26,7 @@ func init() {
 }
 
 type SmsProvider interface {
-	SendSms(phone, message string) error
+	SendMessage(phone, message, channel string) error
 }
 
 func GetSmsProvider(config conf.GlobalConfiguration) (SmsProvider, error) {
@@ -40,3 +43,14 @@ func GetSmsProvider(config conf.GlobalConfiguration) (SmsProvider, error) {
 		return nil, fmt.Errorf("sms Provider %s could not be found", name)
 	}
 }
+
+func IsValidMessageChannel(channel string, config conf.GlobalConfiguration) bool {
+	switch channel {
+	case SMSProvider:
+		return true
+	case WhatsappProvider:
+		return config.Sms.Provider == "twilio"
+	default:
+		return false
+	}
+}
diff --git a/internal/api/sms_provider/textlocal.go b/internal/api/sms_provider/textlocal.go
index cc4c3f78..2f807a5f 100644
--- a/internal/api/sms_provider/textlocal.go
+++ b/internal/api/sms_provider/textlocal.go
@@ -44,6 +44,15 @@ func NewTextlocalProvider(config conf.TextlocalProviderConfiguration) (SmsProvid
 	}, nil
 }
 
+func (t *TextlocalProvider) SendMessage(phone string, message string, channel string) error {
+	switch channel {
+	case SMSProvider:
+		return t.SendSms(phone, message)
+	default:
+		return fmt.Errorf("channel type %q is not supported for TextLocal", channel)
+	}
+}
+
 // Send an SMS containing the OTP with Textlocal's API
 func (t *TextlocalProvider) SendSms(phone string, message string) error {
 	body := url.Values{
diff --git a/internal/api/sms_provider/twilio.go b/internal/api/sms_provider/twilio.go
index c8856ca7..d8be83a5 100644
--- a/internal/api/sms_provider/twilio.go
+++ b/internal/api/sms_provider/twilio.go
@@ -54,6 +54,61 @@ func NewTwilioProvider(config conf.TwilioProviderConfiguration) (SmsProvider, er
 	}, nil
 }
 
+func (t *TwilioProvider) SendMessage(phone string, message string, channel string) error {
+	switch channel {
+	case SMSProvider:
+		return t.SendSms(phone, message)
+	case WhatsappProvider:
+		return t.SendWhatsappMessage(phone, message)
+	default:
+		return fmt.Errorf("channel type %q is not supported for Twilio", channel)
+	}
+}
+
+// Send a Whatsapp message containing the OTP with Twilio's API
+// TODO (J0) Merge with SendSms once stable
+func (t *TwilioProvider) SendWhatsappMessage(phone string, message string) error {
+	body := url.Values{
+		"To":      {"whatsapp:" + "+" + phone}, // twilio api requires "+" extension to be included
+		"Channel": {"whatsapp"},
+		"From":    {"whatsapp:" + t.Config.MessageServiceSid},
+		"Body":    {message},
+	}
+
+	client := &http.Client{Timeout: defaultTimeout}
+	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")
+	r.SetBasicAuth(t.Config.AccountSid, t.Config.AuthToken)
+	res, err := client.Do(r)
+	defer utilities.SafeClose(res.Body)
+	if err != nil {
+		return err
+	}
+	if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
+		resp := &twilioErrResponse{}
+		if err := json.NewDecoder(res.Body).Decode(resp); err != nil {
+			return err
+		}
+		return resp
+	}
+
+	// validate sms status
+	resp := &SmsStatus{}
+	derr := json.NewDecoder(res.Body).Decode(resp)
+	if derr != nil {
+		return derr
+	}
+
+	if resp.Status == "failed" || resp.Status == "undelivered" {
+		return fmt.Errorf("twilio error: %v %v", resp.ErrorMessage, resp.ErrorCode)
+	}
+
+	return nil
+}
+
 // Send an SMS containing the OTP with Twilio's API
 func (t *TwilioProvider) SendSms(phone string, message string) error {
 	body := url.Values{
diff --git a/internal/api/sms_provider/vonage.go b/internal/api/sms_provider/vonage.go
index 0713c8c2..de8987bc 100644
--- a/internal/api/sms_provider/vonage.go
+++ b/internal/api/sms_provider/vonage.go
@@ -44,6 +44,15 @@ func NewVonageProvider(config conf.VonageProviderConfiguration) (SmsProvider, er
 	}, nil
 }
 
+func (t *VonageProvider) SendMessage(phone string, message string, channel string) error {
+	switch channel {
+	case SMSProvider:
+		return t.SendSms(phone, message)
+	default:
+		return fmt.Errorf("channel type %q is not supported for Vonage", channel)
+	}
+}
+
 // Send an SMS containing the OTP with Vonage's API
 func (t *VonageProvider) SendSms(phone string, message string) error {
 	body := url.Values{
diff --git a/internal/api/user.go b/internal/api/user.go
index eaa4adeb..877d112e 100644
--- a/internal/api/user.go
+++ b/internal/api/user.go
@@ -20,6 +20,7 @@ type UserUpdateParams struct {
 	Data     map[string]interface{} `json:"data"`
 	AppData  map[string]interface{} `json:"app_metadata,omitempty"`
 	Phone    string                 `json:"phone"`
+	Channel  string                 `json:"channel"`
 }
 
 // UserGet returns a user
@@ -74,6 +75,12 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 			return unprocessableEntityError(DuplicateEmailMsg)
 		}
 	}
+	if params.Phone != "" && params.Channel == "" {
+		params.Channel = sms_provider.SMSProvider
+	}
+	if params.Phone != "" && !sms_provider.IsValidMessageChannel(params.Channel, *config) {
+		return badRequestError(InvalidChannelError)
+	}
 
 	if params.Phone != "" && params.Phone != user.GetPhone() {
 		params.Phone, err = validatePhone(params.Phone)
@@ -193,7 +200,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 				if terr != nil {
 					return badRequestError("Error sending sms: %v", terr)
 				}
-				if terr := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneChangeVerification, smsProvider); terr != nil {
+				if terr := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneChangeVerification, smsProvider, params.Channel); terr != nil {
 					return internalServerError("Error sending phone change otp").WithInternalError(terr)
 				}
 			}
diff --git a/openapi.yaml b/openapi.yaml
index 51090d91..2d413a21 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -294,6 +294,11 @@ paths:
                 value:
                   phone: "+1234567890"
                   password: password1
+              "phone+password+whatsapp":
+                value:
+                  phone: "+1234567890"
+                  password: password1
+                  channel: whatsapp
             schema:
               type: object
               properties:
@@ -303,6 +308,11 @@ paths:
                 phone:
                   type: string
                   format: phone
+                channel:
+                  type: string
+                  enum:
+                    - sms
+                    - whatsapp
                 password:
                   type: string
                 data:
@@ -477,6 +487,11 @@ paths:
                 phone:
                   type: string
                   format: phone
+                channel:
+                  type: string
+                  enum:
+                    - sms
+                    - whatsapp
                 create_user:
                   type: boolean
                 data:
@@ -543,6 +558,11 @@ paths:
                   type: object
                 app_metadata:
                   type: object
+                channel:
+                  type: string
+                  enum:
+                    - sms
+                    - whatsapp
       responses:
         200:
           description: User's updated account information.


Test patch:
diff --git a/internal/api/otp_test.go b/internal/api/otp_test.go
index efb0e0ff..9ee50efb 100644
--- a/internal/api/otp_test.go
+++ b/internal/api/otp_test.go
@@ -35,9 +35,12 @@ func TestOtp(t *testing.T) {
 
 func (ts *OtpTestSuite) SetupTest() {
 	models.TruncateAll(ts.API.db)
+
 }
 
 func (ts *OtpTestSuite) TestOtp() {
+	// Configured to allow testing of invalid channel params
+	ts.Config.External.Phone.Enabled = true
 	cases := []struct {
 		desc     string
 		params   OtpParams
@@ -81,6 +84,24 @@ func (ts *OtpTestSuite) TestOtp() {
 				},
 			},
 		},
+		{
+			desc: "Test Failure invalid channel param",
+			params: OtpParams{
+				Phone:      "123456789",
+				Channel:    "invalidchannel",
+				CreateUser: true,
+			},
+			expected: struct {
+				code     int
+				response map[string]interface{}
+			}{
+				http.StatusBadRequest,
+				map[string]interface{}{
+					"code": float64(http.StatusBadRequest),
+					"msg":  InvalidChannelError,
+				},
+			},
+		},
 	}
 
 	for _, c := range cases {
diff --git a/internal/api/phone_test.go b/internal/api/phone_test.go
index 651060f3..29c8e595 100644
--- a/internal/api/phone_test.go
+++ b/internal/api/phone_test.go
@@ -15,6 +15,7 @@ import (
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
+	"github.com/supabase/gotrue/internal/api/sms_provider"
 	"github.com/supabase/gotrue/internal/conf"
 	"github.com/supabase/gotrue/internal/models"
 )
@@ -29,7 +30,7 @@ type TestSmsProvider struct {
 	mock.Mock
 }
 
-func (t *TestSmsProvider) SendSms(phone string, message string) error {
+func (t *TestSmsProvider) SendMessage(phone string, message string, channel string) error {
 	return nil
 }
 
@@ -98,7 +99,7 @@ func (ts *PhoneTestSuite) TestSendPhoneConfirmation() {
 
 	for _, c := range cases {
 		ts.Run(c.desc, func() {
-			err = ts.API.sendPhoneConfirmation(ctx, ts.API.db, u, "123456789", c.otpType, &TestSmsProvider{})
+			err = ts.API.sendPhoneConfirmation(ctx, ts.API.db, u, "123456789", c.otpType, &TestSmsProvider{}, sms_provider.SMSProvider)
 			require.Equal(ts.T(), c.expected, err)
 			u, err = models.FindUserByPhoneAndAudience(ts.API.db, "123456789", ts.Config.JWT.Aud)
 			require.NoError(ts.T(), err)