From 99d942d15368ebdf1d8ef3b05f6a9b26e52192fa Mon Sep 17 00:00:00 2001 From: cxbitz Date: Fri, 7 Aug 2026 11:04:20 +0200 Subject: [PATCH] fix: send through Bird's API when the Messagebird key is a Bird key MessageBird is now Bird, and accounts created on the new platform are issued bk_{region}_ keys for an API the provider does not speak: it posts form-encoded to rest.messagebird.com with AccessKey auth, which those keys are not valid for, so phone auth never sends for them (#1830). The access key now decides the API. A bk_{region}_ key is routed to https://{region}.platform.bird.com/v1/sms/messages with bearer auth and a JSON body; the region comes from the key, so no new configuration is needed. Anything else keeps the legacy path byte for byte, so existing accounts are untouched. Bird requires a sender the workspace owns or has registered, which a new workspace has neither of, so the originator becomes optional for a Bird key: unset, the send uses Bird's stocked one-time-passcode template, which selects a sender for the destination; set, it sends the configured SMS_TEMPLATE text from that sender. Auth generates and verifies the code in both cases, so this stays a plain sender and the OTP lifecycle is unchanged. --- README.md | 15 +- internal/api/sms_provider/messagebird.go | 134 +++++++++++++++++- .../api/sms_provider/sms_provider_test.go | 84 ++++++++++- internal/conf/configuration.go | 5 +- 4 files changed, 231 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7b103730b0..79388e7f43 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/api/sms_provider/messagebird.go b/internal/api/sms_provider/messagebird.go index 05f793903c..cce2340c7e 100644 --- a/internal/api/sms_provider/messagebird.go +++ b/internal/api/sms_provider/messagebird.go @@ -1,10 +1,12 @@ package sms_provider import ( + "bytes" "encoding/json" "fmt" "net/http" "net/url" + "regexp" "strings" "github.com/supabase/auth/internal/conf" @@ -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 @@ -43,6 +54,51 @@ 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 { @@ -50,23 +106,97 @@ func NewMessagebirdProvider(config conf.MessagebirdProviderConfiguration) (SmsPr } 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}, diff --git a/internal/api/sms_provider/sms_provider_test.go b/internal/api/sms_provider/sms_provider_test.go index e5b5216a35..eff675f2f4 100644 --- a/internal/api/sms_provider/sms_provider_test.go +++ b/internal/api/sms_provider/sms_provider_test.go @@ -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) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 00e0367029..2f7abd5420 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -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