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
5 changes: 5 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
136 changes: 136 additions & 0 deletions internal/api/sms_provider/africas_talking.go
Original file line number Diff line number Diff line change
@@ -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")
}
154 changes: 154 additions & 0 deletions internal/api/sms_provider/africas_talking_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
2 changes: 2 additions & 0 deletions internal/api/sms_provider/sms_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
27 changes: 22 additions & 5 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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"
}
Expand Down