package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/models" ) type OtpTestSuite struct { suite.Suite API *API Config *conf.GlobalConfiguration } func TestOtp(t *testing.T) { api, config, err := setupAPIForTest() require.NoError(t, err) ts := &OtpTestSuite{ API: api, Config: config, } defer api.db.Close() suite.Run(t, ts) } func (ts *OtpTestSuite) SetupTest() { models.TruncateAll(ts.API.db) } func (ts *OtpTestSuite) TestOtpPKCE() { ts.Config.External.Phone.Enabled = true testCodeChallenge := "testtesttesttesttesttesttestteststeststesttesttesttest" var buffer bytes.Buffer cases := []struct { desc string params OtpParams expected struct { code int response map[string]interface{} } }{ { desc: "Test (PKCE) Success Magiclink Otp", params: OtpParams{ Email: "test@example.com", CreateUser: true, CodeChallengeMethod: "s256", CodeChallenge: testCodeChallenge, }, expected: struct { code int response map[string]interface{} }{ http.StatusOK, make(map[string]interface{}), }, }, { desc: "Test (PKCE) Failure, no code challenge", params: OtpParams{ Email: "test@example.com", CreateUser: true, CodeChallengeMethod: "s256", }, expected: struct { code int response map[string]interface{} }{ http.StatusBadRequest, map[string]interface{}{ "code": float64(http.StatusBadRequest), "error_code": ErrorCodeValidationFailed, "msg": "PKCE flow requires code_challenge_method and code_challenge", }, }, }, { desc: "Test (PKCE) Failure, no code challenge method", params: OtpParams{ Email: "test@example.com", CreateUser: true, CodeChallenge: testCodeChallenge, }, expected: struct { code int response map[string]interface{} }{ http.StatusBadRequest, map[string]interface{}{ "code": float64(http.StatusBadRequest), "error_code": ErrorCodeValidationFailed, "msg": "PKCE flow requires code_challenge_method and code_challenge", }, }, }, { desc: "Test (PKCE) Success, phone with valid params", params: OtpParams{ Phone: "123456789", CreateUser: true, CodeChallengeMethod: "s256", CodeChallenge: testCodeChallenge, }, expected: struct { code int response map[string]interface{} }{ http.StatusInternalServerError, map[string]interface{}{ "code": float64(http.StatusInternalServerError), "msg": "Unable to get SMS provider", }, }, }, } for _, c := range cases { ts.Run(c.desc, func() { require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params)) req := httptest.NewRequest(http.MethodPost, "/otp", &buffer) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), c.expected.code, w.Code) data := make(map[string]interface{}) require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) }) } } func (ts *OtpTestSuite) TestOtp() { // Configured to allow testing of invalid channel params ts.Config.External.Phone.Enabled = true cases := []struct { desc string params OtpParams expected struct { code int response map[string]interface{} } }{ { desc: "Test Success Magiclink Otp", params: OtpParams{ Email: "test@example.com", CreateUser: true, Data: map[string]interface{}{ "somedata": "metadata", }, }, expected: struct { code int response map[string]interface{} }{ http.StatusOK, make(map[string]interface{}), }, }, { desc: "Test Failure Pass Both Email & Phone", params: OtpParams{ Email: "test@example.com", Phone: "123456789", CreateUser: true, }, expected: struct { code int response map[string]interface{} }{ http.StatusBadRequest, map[string]interface{}{ "code": float64(http.StatusBadRequest), "error_code": ErrorCodeValidationFailed, "msg": "Only an email address or phone number should be provided", }, }, }, { 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), "error_code": ErrorCodeValidationFailed, "msg": InvalidChannelError, }, }, }, } for _, c := range cases { ts.Run(c.desc, func() { var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params)) req := httptest.NewRequest(http.MethodPost, "/otp", &buffer) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), c.expected.code, w.Code) data := make(map[string]interface{}) require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) // response should be empty assert.Equal(ts.T(), data, c.expected.response) }) } } func (ts *OtpTestSuite) TestNoSignupsForOtp() { var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "email": "newuser@example.com", "create_user": false, })) req := httptest.NewRequest(http.MethodPost, "/otp", &buffer) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code) data := make(map[string]interface{}) require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) // response should be empty assert.Equal(ts.T(), data, map[string]interface{}{ "code": float64(http.StatusUnprocessableEntity), "error_code": ErrorCodeOTPDisabled, "msg": "Signups not allowed for otp", }) } func (ts *OtpTestSuite) TestSubsequentOtp() { ts.Config.SMTP.MaxFrequency = 0 userEmail := "foo@example.com" var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "email": userEmail, })) req := httptest.NewRequest(http.MethodPost, "/otp", &buffer) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), http.StatusOK, w.Code) newUser, err := models.FindUserByEmailAndAudience(ts.API.db, userEmail, ts.Config.JWT.Aud) require.NoError(ts.T(), err) require.NotEmpty(ts.T(), newUser.ConfirmationToken) require.NotEmpty(ts.T(), newUser.ConfirmationSentAt) require.Empty(ts.T(), newUser.RecoveryToken) require.Empty(ts.T(), newUser.RecoverySentAt) require.Empty(ts.T(), newUser.EmailConfirmedAt) // since the signup process hasn't been completed, // subsequent requests for another magiclink should not create a recovery token require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "email": userEmail, })) req = httptest.NewRequest(http.MethodPost, "/otp", &buffer) req.Header.Set("Content-Type", "application/json") w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), http.StatusOK, w.Code) user, err := models.FindUserByEmailAndAudience(ts.API.db, userEmail, ts.Config.JWT.Aud) require.NoError(ts.T(), err) require.NotEmpty(ts.T(), user.ConfirmationToken) require.NotEmpty(ts.T(), user.ConfirmationSentAt) require.Empty(ts.T(), user.RecoveryToken) require.Empty(ts.T(), user.RecoverySentAt) require.Empty(ts.T(), user.EmailConfirmedAt) }