Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -865,10 +865,19 @@ Then you can use your [twilio credentials](https://www.twilio.com/docs/usage/req
- `SMS_TWILIO_AUTH_TOKEN`
- `SMS_TWILIO_MESSAGE_SERVICE_SID` - can be set to your twilio sender mobile number

Or Messagebird credentials, which can be obtained in the [Dashboard](https://dashboard.messagebird.com/en/developers/access):
Or Messagebird credentials:

- `SMS_MESSAGEBIRD_ACCESS_KEY` - your Messagebird access key
- `SMS_MESSAGEBIRD_ORIGINATOR` - SMS sender (your Messagebird phone number with + or company name)
- `SMS_MESSAGEBIRD_ACCESS_KEY` - your access key
- `SMS_MESSAGEBIRD_ORIGINATOR` - SMS sender (a phone number with + or a company name)

MessageBird is now Bird, and the two platforms issue different keys. The access
key you hold decides which API Auth calls, so no other setting changes:

- A **Bird** key (`bk_{region}_...`, from the [Bird dashboard](https://bird.com/dashboard/w/api-keys), needs the `sms` scope) calls Bird's API. The region comes from the key, so there is nothing else to configure. `SMS_MESSAGEBIRD_ORIGINATOR` becomes optional here: leave it unset and Bird sends its own one-time-passcode template, choosing a sender for the destination, which is what lets a workspace that owns no number send at all. Set it and Auth sends its own `SMS_TEMPLATE` text from that sender instead. Either way Auth still generates and verifies the code.
- A **legacy MessageBird** access key calls the legacy API exactly as before, and still requires an originator.

Before sending on Bird, enable your destination countries and fund the
workspace wallet; a send is rejected until both are done.

### CAPTCHA

Expand Down
134 changes: 132 additions & 2 deletions internal/api/sms_provider/messagebird.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package sms_provider

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"

"github.com/supabase/auth/internal/conf"
Expand All @@ -13,8 +15,17 @@ import (

const (
defaultMessagebirdApiBase = "https://rest.messagebird.com"
// The OTP template Bird stocks in every workspace. A template send needs no
// sender: Bird picks one for the destination, which is what lets a workspace
// that owns no number send at all.
birdOtpTemplate = "bird_otp_verification"
)

// Bird's platform keys carry their region: bk_{region}_{token}. Matching the
// shape rather than a fixed list of regions means a new region needs no change
// here, and a legacy MessageBird access key never matches.
var birdKeyPattern = regexp.MustCompile(`^bk_([a-z]{2}[0-9]+)_.+$`)

type MessagebirdProvider struct {
Config *conf.MessagebirdProviderConfiguration
APIPath string
Expand Down Expand Up @@ -43,30 +54,149 @@ func (t MessagebirdErrResponse) Error() string {
return t.Errors[0].Description
}

// Bird's platform nests every error under an "error" key, unlike the legacy
// API's "errors" array.
type birdErrEnvelope struct {
Error BirdErrResponse `json:"error"`
}

type BirdErrResponse struct {
Code string `json:"code"`
Message string `json:"message"`
// Bird resolves the next step for a given error code server-side, so the
// remediation travels with the response instead of being mapped here.
Remediation string `json:"remediation"`
RequestID string `json:"request_id"`
StatusCode int `json:"-"`
}

func (e BirdErrResponse) Error() string {
msg := "bird error: " + e.Message
if e.Remediation != "" {
msg += " " + e.Remediation
}
details := fmt.Sprintf("code %s, HTTP %d", e.Code, e.StatusCode)
if e.RequestID != "" {
details += ", request id " + e.RequestID
}
return msg + " (" + details + ")"
}

type birdSmsTemplate struct {
Name string `json:"name"`
Parameters map[string]string `json:"parameters"`
}

type birdSmsRequest struct {
To string `json:"to"`
From string `json:"from,omitempty"`
Text string `json:"text,omitempty"`
Category string `json:"category,omitempty"`
Template *birdSmsTemplate `json:"template,omitempty"`
}

type birdSmsResponse struct {
ID string `json:"id"`
}

// Creates a SmsProvider with the Messagebird Config
func NewMessagebirdProvider(config conf.MessagebirdProviderConfiguration) (SmsProvider, error) {
if err := config.Validate(); err != nil {
return nil, err
}

apiPath := defaultMessagebirdApiBase + "/messages"
if region := birdRegionFromAccessKey(config.AccessKey); region != "" {
apiPath = "https://" + region + ".platform.bird.com/v1/sms/messages"
}
return &MessagebirdProvider{
Config: &config,
APIPath: apiPath,
}, nil
}

// birdRegionFromAccessKey returns the region of a bk_{region}_{token} key, or ""
// for a legacy MessageBird access key.
func birdRegionFromAccessKey(key string) string {
m := birdKeyPattern.FindStringSubmatch(key)
if m == nil {
return ""
}
return m[1]
}

func (t *MessagebirdProvider) isBirdPlatform() bool {
return birdRegionFromAccessKey(t.Config.AccessKey) != ""
}

func (t *MessagebirdProvider) SendMessage(phone, message, channel, otp string) (string, error) {
switch channel {
case SMSProvider:
return t.SendSms(phone, message)
return t.SendSms(phone, message, otp)
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) (string, error) {
func (t *MessagebirdProvider) SendSms(phone, message, otp string) (string, error) {
if t.isBirdPlatform() {
return t.sendBird(phone, message, otp)
}
return t.sendLegacy(phone, message)
}

// Bird's platform requires a sender the workspace owns or has registered, which
// a workspace that has just signed up has neither of. A send from Bird's stocked
// OTP template needs no sender, so an unset originator selects it: GoTrue still
// generates and checks the code, and only the message wording comes from Bird.
func (t *MessagebirdProvider) sendBird(phone, message, otp string) (string, error) {
payload := birdSmsRequest{To: "+" + strings.TrimPrefix(phone, "+")}
if t.Config.Originator != "" {
payload.From = t.Config.Originator
payload.Text = message
payload.Category = "authentication"
} else {
payload.Template = &birdSmsTemplate{
Name: birdOtpTemplate,
Parameters: map[string]string{"code": otp},
}
}

body, err := json.Marshal(payload)
if err != nil {
return "", err
}
client := &http.Client{Timeout: defaultTimeout}
r, err := http.NewRequest("POST", t.APIPath, bytes.NewReader(body))
if err != nil {
return "", err
}
r.Header.Add("Content-Type", "application/json")
r.Header.Add("Authorization", "Bearer "+t.Config.AccessKey)
res, err := client.Do(r)
if err != nil {
return "", err
}
defer utilities.SafeClose(res.Body)

if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusAccepted {
envelope := &birdErrEnvelope{}
if err := json.NewDecoder(res.Body).Decode(envelope); err != nil {
return "", fmt.Errorf("bird error: HTTP %d", res.StatusCode)
}
envelope.Error.StatusCode = res.StatusCode
return "", envelope.Error
}

resp := &birdSmsResponse{}
if err := json.NewDecoder(res.Body).Decode(resp); err != nil {
return "", err
}
return resp.ID, nil
}

func (t *MessagebirdProvider) sendLegacy(phone string, message string) (string, error) {
body := url.Values{
"originator": {t.Config.Originator},
"body": {message},
Expand Down
84 changes: 83 additions & 1 deletion internal/api/sms_provider/sms_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,92 @@ func (ts *SmsProviderTestSuite) TestMessagebirdSendSms() {
},
})

_, err = messagebirdProvider.SendSms(phone, message)
_, err = messagebirdProvider.SendSms(phone, message, "123456")
require.NoError(ts.T(), err)
}

func (ts *SmsProviderTestSuite) TestMessagebirdSendSmsOnBirdPlatform() {
defer gock.Off()

ts.Run("A bk_ key routes to the platform and sends the template when no originator is set", func() {
provider, err := NewMessagebirdProvider(conf.MessagebirdProviderConfiguration{
AccessKey: "bk_eu1_test_api_key",
})
require.NoError(ts.T(), err)
birdProvider := provider.(*MessagebirdProvider)

// The region comes from the key, so no extra configuration is needed.
require.Equal(ts.T(), "https://eu1.platform.bird.com/v1/sms/messages", birdProvider.APIPath)

gock.New(birdProvider.APIPath).Post("").
MatchHeader("Authorization", "Bearer bk_eu1_test_api_key").
JSON(birdSmsRequest{
To: "+123456789",
Template: &birdSmsTemplate{Name: "bird_otp_verification", Parameters: map[string]string{"code": "123456"}},
}).
Reply(202).JSON(birdSmsResponse{ID: "sms_abcdef"})

id, err := birdProvider.SendSms("123456789", "unused", "123456")
require.NoError(ts.T(), err)
require.Equal(ts.T(), "sms_abcdef", id)
})

ts.Run("An originator sends free text from that sender instead", func() {
provider, err := NewMessagebirdProvider(conf.MessagebirdProviderConfiguration{
AccessKey: "bk_eu1_test_api_key",
Originator: "Acme",
})
require.NoError(ts.T(), err)
birdProvider := provider.(*MessagebirdProvider)

gock.New(birdProvider.APIPath).Post("").
JSON(birdSmsRequest{
To: "+123456789",
From: "Acme",
Text: "This is the sms code: 123456",
Category: "authentication",
}).
Reply(202).JSON(birdSmsResponse{ID: "sms_abcdef"})

_, err = birdProvider.SendSms("123456789", "This is the sms code: 123456", "123456")
require.NoError(ts.T(), err)
})

ts.Run("Platform errors are nested under error and carry a remediation", func() {
provider, err := NewMessagebirdProvider(conf.MessagebirdProviderConfiguration{
AccessKey: "bk_eu1_test_api_key",
})
require.NoError(ts.T(), err)
birdProvider := provider.(*MessagebirdProvider)

gock.New(birdProvider.APIPath).Post("").
Reply(422).JSON(birdErrEnvelope{Error: BirdErrResponse{
Code: "E12020",
Message: "This destination country is not enabled for your workspace.",
Remediation: "Enable this destination country in your workspace's SMS destination settings, then send again.",
RequestID: "req_01ky7q3hckecgv6d7jpq865532",
}})

_, err = birdProvider.SendSms("123456789", "unused", "123456")
require.Error(ts.T(), err)
require.Contains(ts.T(), err.Error(), "Enable this destination country")
require.Contains(ts.T(), err.Error(), "E12020")
require.Contains(ts.T(), err.Error(), "req_01ky7q3hckecgv6d7jpq865532")
})

ts.Run("A legacy access key keeps the legacy host and still requires an originator", func() {
provider, err := NewMessagebirdProvider(conf.MessagebirdProviderConfiguration{
AccessKey: "legacy_access_key",
Originator: "Acme",
})
require.NoError(ts.T(), err)
require.Equal(ts.T(), "https://rest.messagebird.com/messages", provider.(*MessagebirdProvider).APIPath)

_, err = NewMessagebirdProvider(conf.MessagebirdProviderConfiguration{AccessKey: "legacy_access_key"})
require.EqualError(ts.T(), err, "missing Messagebird originator")
})
}

func (ts *SmsProviderTestSuite) TestVonageSendSms() {
defer gock.Off()
provider, err := NewVonageProvider(ts.Config.Sms.Vonage)
Expand Down
5 changes: 4 additions & 1 deletion internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -1380,7 +1380,10 @@ func (t *MessagebirdProviderConfiguration) Validate() error {
if t.AccessKey == "" {
return errors.New("missing Messagebird access key")
}
if t.Originator == "" {
// A Bird platform key can send from Bird's stocked OTP template, which
// selects its own sender, so an originator is optional there. The legacy
// API has no such fallback and still requires one.
if t.Originator == "" && !strings.HasPrefix(t.AccessKey, "bk_") {
return errors.New("missing Messagebird originator")
}
return nil
Expand Down