diff --git a/example.env b/example.env index 92bb7d917e..ead2b108ab 100644 --- a/example.env +++ b/example.env @@ -223,6 +223,11 @@ GOTRUE_SMS_TEXTLOCAL_SENDER="" GOTRUE_SMS_VONAGE_API_KEY="" GOTRUE_SMS_VONAGE_API_SECRET="" GOTRUE_SMS_VONAGE_FROM="" +GOTRUE_SMS_AT_API_KEY="" +GOTRUE_SMS_AT_USERNAME="" +GOTRUE_SMS_AT_SENDER_ID="" # optional; leave empty to use AT shared shortcode + + # Captcha config GOTRUE_SECURITY_CAPTCHA_ENABLED="false" diff --git a/internal/api/sms_provider/africas_talking.go b/internal/api/sms_provider/africas_talking.go new file mode 100644 index 0000000000..70b7c3c4e5 --- /dev/null +++ b/internal/api/sms_provider/africas_talking.go @@ -0,0 +1,136 @@ +package sms_provider + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/utilities" +) + +// Africa's Talking API endpoints +const ( + ATProductionEndpoint = "https://api.africastalking.com/version1/messaging" + ATSandboxEndpoint = "https://api.sandbox.africastalking.com/version1/messaging" +) + +// AfricasTalkingProvider implements the SmsProvider interface for Africa's Talking +type AfricasTalkingProvider struct { + Config *conf.AfricasTalkingProviderConfiguration + APIEndpoint string +} + +// ATRecipient represents a single recipient in the AT API response +type ATRecipient struct { + StatusCode int `json:"statusCode"` + Number string `json:"number"` + Status string `json:"status"` + Cost string `json:"cost"` + MessageID string `json:"messageId"` +} + +// ATSMSMessageData holds the message data from the AT API response +type ATSMSMessageData struct { + Message string `json:"Message"` + Recipients []ATRecipient `json:"Recipients"` +} + +// ATResponse is the top-level response from Africa's Talking SMS API +type ATResponse struct { + SMSMessageData ATSMSMessageData `json:"SMSMessageData"` +} + +// NewAfricasTalkingProvider creates and returns a new AfricasTalkingProvider +func NewAfricasTalkingProvider(config conf.AfricasTalkingProviderConfiguration) (SmsProvider, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + endpoint := ATProductionEndpoint + if config.Username == "sandbox" { + endpoint = ATSandboxEndpoint + } + + return &AfricasTalkingProvider{ + Config: &config, + APIEndpoint: endpoint, + }, nil +} + +// SendMessage sends an OTP SMS via Africa's Talking API and returns the message ID +func (a *AfricasTalkingProvider) SendMessage(phone, message, channel, otp string) (string, error) { + switch channel { + case SMSProvider: + return a.SendSms(phone, message) + default: + return "", fmt.Errorf("africas_talking: channel type %q is not supported, only the SMS channel is available", channel) + } +} + +// SendSms sends an SMS containing the OTP via the Africa's Talking API +func (a *AfricasTalkingProvider) SendSms(phone, message string) (string, error) { + params := url.Values{} + params.Set("username", a.Config.Username) + params.Set("to", phone) + params.Set("message", message) + + // Sender ID is optional — if blank, AT uses a shared shortcode + if a.Config.SenderID != "" { + params.Set("from", a.Config.SenderID) + } + + client := &http.Client{Timeout: defaultTimeout} + req, err := http.NewRequest(http.MethodPost, a.APIEndpoint, strings.NewReader(params.Encode())) + if err != nil { + return "", fmt.Errorf("africas_talking: failed to create request: %w", err) + } + + // Use direct map assignment to preserve exact header casing. + // Africa's Talking requires "apiKey" (lowercase k); Header.Set would + // canonicalise it to "Apikey", causing a 401. + req.Header["apiKey"] = []string{a.Config.APIKey} + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("africas_talking: request failed: %w", err) + } + defer utilities.SafeClose(resp.Body) + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("africas_talking: unexpected HTTP status: %d", resp.StatusCode) + } + + var atResp ATResponse + if err := json.NewDecoder(resp.Body).Decode(&atResp); err != nil { + return "", fmt.Errorf("africas_talking: failed to decode response: %w", err) + } + + recipients := atResp.SMSMessageData.Recipients + if len(recipients) == 0 { + return "", fmt.Errorf("africas_talking: no recipients in response: %s", atResp.SMSMessageData.Message) + } + + recipient := recipients[0] + + // statusCode 101 = success on Africa's Talking + if recipient.StatusCode != 101 { + return recipient.MessageID, fmt.Errorf( + "africas_talking: delivery failed for %s — status: %s (code: %d)", + recipient.Number, + recipient.Status, + recipient.StatusCode, + ) + } + + return recipient.MessageID, nil +} + +// VerifyOTP is not supported by Africa's Talking; OTPs are verified internally. +func (a *AfricasTalkingProvider) VerifyOTP(phone, code string) error { + return fmt.Errorf("VerifyOTP is not supported for Africa's Talking") +} diff --git a/internal/api/sms_provider/africas_talking_test.go b/internal/api/sms_provider/africas_talking_test.go new file mode 100644 index 0000000000..84cc12cf96 --- /dev/null +++ b/internal/api/sms_provider/africas_talking_test.go @@ -0,0 +1,154 @@ +package sms_provider + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +func TestAfricasTalkingProvider_Validate(t *testing.T) { + t.Run("missing api key", func(t *testing.T) { + config := conf.AfricasTalkingProviderConfiguration{ + Username: "sandbox", + } + _, err := NewAfricasTalkingProvider(config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "API key") + }) + + t.Run("missing username", func(t *testing.T) { + config := conf.AfricasTalkingProviderConfiguration{ + APIKey: "test_key", + } + _, err := NewAfricasTalkingProvider(config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "username") + }) + + t.Run("valid sandbox config", func(t *testing.T) { + config := conf.AfricasTalkingProviderConfiguration{ + APIKey: "test_key", + Username: "sandbox", + } + provider, err := NewAfricasTalkingProvider(config) + require.NoError(t, err) + assert.NotNil(t, provider) + + atProvider := provider.(*AfricasTalkingProvider) + assert.Equal(t, ATSandboxEndpoint, atProvider.APIEndpoint) + }) + + t.Run("valid production config", func(t *testing.T) { + config := conf.AfricasTalkingProviderConfiguration{ + APIKey: "live_key", + Username: "myapp", + } + provider, err := NewAfricasTalkingProvider(config) + require.NoError(t, err) + + atProvider := provider.(*AfricasTalkingProvider) + assert.Equal(t, ATProductionEndpoint, atProvider.APIEndpoint) + }) +} + +func TestAfricasTalkingProvider_SendMessage(t *testing.T) { + // Mock Africa's Talking API server + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify required headers + assert.Equal(t, "test_api_key", r.Header.Get("apiKey")) + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + + // Parse form body + require.NoError(t, r.ParseForm()) + assert.Equal(t, "sandbox", r.FormValue("username")) + assert.Equal(t, "+254712345678", r.FormValue("to")) + assert.NotEmpty(t, r.FormValue("message")) + + // Return a successful AT response + resp := ATResponse{ + SMSMessageData: ATSMSMessageData{ + Message: "Sent to 1/1 Total Cost: KES 0.8000", + Recipients: []ATRecipient{ + { + StatusCode: 101, + Number: "+254712345678", + Status: "Success", + Cost: "KES 0.8000", + MessageID: "ATXid_abc123", + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + defer mockServer.Close() + + config := conf.AfricasTalkingProviderConfiguration{ + APIKey: "test_api_key", + Username: "sandbox", + } + + provider, err := NewAfricasTalkingProvider(config) + require.NoError(t, err) + + // Override the endpoint to point to mock server + provider.(*AfricasTalkingProvider).APIEndpoint = mockServer.URL + + t.Run("successful SMS send", func(t *testing.T) { + messageID, err := provider.SendMessage("+254712345678", "Your OTP is: 123456", SMSProvider, "123456") + assert.NoError(t, err) + assert.Equal(t, "ATXid_abc123", messageID) + }) + + t.Run("rejects whatsapp channel", func(t *testing.T) { + _, err := provider.SendMessage("+254712345678", "Your OTP is: 123456", WhatsappProvider, "123456") + assert.Error(t, err) + assert.Contains(t, err.Error(), "SMS channel") + }) +} + +func TestAfricasTalkingProvider_SendMessage_DeliveryFailure(t *testing.T) { + // Mock server that returns a failed delivery + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := ATResponse{ + SMSMessageData: ATSMSMessageData{ + Message: "Sent to 0/1 Total Cost: KES 0", + Recipients: []ATRecipient{ + { + StatusCode: 403, + Number: "+254000000000", + Status: "InvalidPhoneNumber", + Cost: "KES 0", + MessageID: "", + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + defer mockServer.Close() + + config := conf.AfricasTalkingProviderConfiguration{ + APIKey: "test_api_key", + Username: "sandbox", + } + + provider, err := NewAfricasTalkingProvider(config) + require.NoError(t, err) + provider.(*AfricasTalkingProvider).APIEndpoint = mockServer.URL + + _, err = provider.SendMessage("+254000000000", "Your OTP is: 123456", SMSProvider, "123456") + assert.Error(t, err) + assert.Contains(t, err.Error(), "delivery failed") +} diff --git a/internal/api/sms_provider/sms_provider.go b/internal/api/sms_provider/sms_provider.go index 8e054bb729..f39af51c52 100644 --- a/internal/api/sms_provider/sms_provider.go +++ b/internal/api/sms_provider/sms_provider.go @@ -49,6 +49,8 @@ func GetSmsProvider(config conf.GlobalConfiguration) (SmsProvider, error) { return NewVonageProvider(config.Sms.Vonage) case "twilio_verify": return NewTwilioVerifyProvider(config.Sms.TwilioVerify) + case "africas_talking": + return NewAfricasTalkingProvider(config.Sms.AfricasTalking) default: return nil, fmt.Errorf("sms Provider %s could not be found", name) } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 1b8023a14e..a6a5c30cc5 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -718,11 +718,12 @@ type SmsProviderConfiguration struct { TestOTPValidUntil Time `json:"test_otp_valid_until" split_words:"true"` SMSTemplate *template.Template `json:"-"` - Twilio TwilioProviderConfiguration `json:"twilio"` - TwilioVerify TwilioVerifyProviderConfiguration `json:"twilio_verify" split_words:"true"` - Messagebird MessagebirdProviderConfiguration `json:"messagebird"` - Textlocal TextlocalProviderConfiguration `json:"textlocal"` - Vonage VonageProviderConfiguration `json:"vonage"` + Twilio TwilioProviderConfiguration `json:"twilio"` + TwilioVerify TwilioVerifyProviderConfiguration `json:"twilio_verify" split_words:"true"` + Messagebird MessagebirdProviderConfiguration `json:"messagebird"` + Textlocal TextlocalProviderConfiguration `json:"textlocal"` + Vonage VonageProviderConfiguration `json:"vonage"` + AfricasTalking AfricasTalkingProviderConfiguration `json:"africas_talking" split_words:"true" envconfig:"AT"` } func (c *SmsProviderConfiguration) GetTestOTP(phone string, now time.Time) (string, bool) { @@ -763,6 +764,12 @@ type VonageProviderConfiguration struct { From string `json:"from" split_words:"true"` } +type AfricasTalkingProviderConfiguration struct { + APIKey string `json:"api_key" split_words:"true"` + Username string `json:"username"` + SenderID string `json:"sender_id" split_words:"true"` +} + type CaptchaConfiguration struct { Enabled bool `json:"enabled" default:"false"` Provider string `json:"provider" default:"hcaptcha"` @@ -1380,6 +1387,16 @@ func (t *VonageProviderConfiguration) Validate() error { return nil } +func (t *AfricasTalkingProviderConfiguration) Validate() error { + if t.APIKey == "" { + return errors.New("africas_talking: missing API key") + } + if t.Username == "" { + return errors.New("africas_talking: missing username") + } + return nil +} + func (t *SmsProviderConfiguration) IsTwilioVerifyProvider() bool { return t.Provider == "twilio_verify" }