package api import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/gofrs/uuid" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/models" ) type UserTestSuite struct { suite.Suite API *API Config *conf.GlobalConfiguration } func TestUser(t *testing.T) { api, config, err := setupAPIForTest() require.NoError(t, err) ts := &UserTestSuite{ API: api, Config: config, } defer api.db.Close() suite.Run(t, ts) } func (ts *UserTestSuite) SetupTest() { models.TruncateAll(ts.API.db) // Create user u, err := models.NewUser("123456789", "test@example.com", "password", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user") } func (ts *UserTestSuite) generateToken(user *models.User, sessionId *uuid.UUID) string { req := httptest.NewRequest(http.MethodPost, "/token?grant_type=password", nil) token, _, err := ts.API.generateAccessToken(req, ts.API.db, user, sessionId, models.PasswordGrant) require.NoError(ts.T(), err, "Error generating access token") return token } func (ts *UserTestSuite) generateAccessTokenAndSession(user *models.User) string { session, err := models.NewSession(user.ID, nil) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(session)) req := httptest.NewRequest(http.MethodPost, "/token?grant_type=password", nil) token, _, err := ts.API.generateAccessToken(req, ts.API.db, user, &session.ID, models.PasswordGrant) require.NoError(ts.T(), err, "Error generating access token") return token } func (ts *UserTestSuite) TestUserGet() { u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err, "Error finding user") token := ts.generateAccessTokenAndSession(u) require.NoError(ts.T(), err, "Error generating access token") req := httptest.NewRequest(http.MethodGet, "http://localhost/user", nil) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), http.StatusOK, w.Code) } func (ts *UserTestSuite) TestUserUpdateEmail() { cases := []struct { desc string userData map[string]interface{} isSecureEmailChangeEnabled bool isMailerAutoconfirmEnabled bool expectedCode int }{ { desc: "User doesn't have an existing email", userData: map[string]interface{}{ "email": "", "phone": "", }, isSecureEmailChangeEnabled: false, isMailerAutoconfirmEnabled: false, expectedCode: http.StatusOK, }, { desc: "User doesn't have an existing email and double email confirmation required", userData: map[string]interface{}{ "email": "", "phone": "234567890", }, isSecureEmailChangeEnabled: true, isMailerAutoconfirmEnabled: false, expectedCode: http.StatusOK, }, { desc: "User has an existing email", userData: map[string]interface{}{ "email": "foo@example.com", "phone": "", }, isSecureEmailChangeEnabled: false, isMailerAutoconfirmEnabled: false, expectedCode: http.StatusOK, }, { desc: "User has an existing email and double email confirmation required", userData: map[string]interface{}{ "email": "bar@example.com", "phone": "", }, isSecureEmailChangeEnabled: true, isMailerAutoconfirmEnabled: false, expectedCode: http.StatusOK, }, { desc: "Update email with mailer autoconfirm enabled", userData: map[string]interface{}{ "email": "bar@example.com", "phone": "", }, isSecureEmailChangeEnabled: true, isMailerAutoconfirmEnabled: true, expectedCode: http.StatusOK, }, { desc: "Update email with mailer autoconfirm enabled and anonymous user", userData: map[string]interface{}{ "email": "bar@example.com", "phone": "", "is_anonymous": true, }, isSecureEmailChangeEnabled: true, isMailerAutoconfirmEnabled: true, expectedCode: http.StatusOK, }, } for _, c := range cases { ts.Run(c.desc, func() { u, err := models.NewUser("", "", "", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") require.NoError(ts.T(), u.SetEmail(ts.API.db, c.userData["email"].(string)), "Error setting user email") require.NoError(ts.T(), u.SetPhone(ts.API.db, c.userData["phone"].(string)), "Error setting user phone") if isAnonymous, ok := c.userData["is_anonymous"]; ok { u.IsAnonymous = isAnonymous.(bool) } require.NoError(ts.T(), ts.API.db.Create(u), "Error saving test user") token := ts.generateAccessTokenAndSession(u) require.NoError(ts.T(), err, "Error generating access token") var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "email": "new@example.com", })) req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) w := httptest.NewRecorder() ts.Config.Mailer.SecureEmailChangeEnabled = c.isSecureEmailChangeEnabled ts.Config.Mailer.Autoconfirm = c.isMailerAutoconfirmEnabled ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), c.expectedCode, w.Code) var data models.User require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) if c.isMailerAutoconfirmEnabled && u.IsAnonymous { require.Empty(ts.T(), data.EmailChange) require.Equal(ts.T(), "new@example.com", data.GetEmail()) require.Len(ts.T(), data.Identities, 1) } else { require.Equal(ts.T(), "new@example.com", data.EmailChange) require.Len(ts.T(), data.Identities, 0) } // remove user after each case require.NoError(ts.T(), ts.API.db.Destroy(u)) }) } } func (ts *UserTestSuite) TestUserUpdatePhoneAutoconfirmEnabled() { u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) existingUser, err := models.NewUser("22222222", "", "", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(existingUser)) cases := []struct { desc string userData map[string]string expectedCode int }{ { desc: "New phone number is the same as current phone number", userData: map[string]string{ "phone": "123456789", }, expectedCode: http.StatusOK, }, { desc: "New phone number exists already", userData: map[string]string{ "phone": "22222222", }, expectedCode: http.StatusUnprocessableEntity, }, { desc: "New phone number is different from current phone number", userData: map[string]string{ "phone": "234567890", }, expectedCode: http.StatusOK, }, } ts.Config.Sms.Autoconfirm = true for _, c := range cases { ts.Run(c.desc, func() { token := ts.generateAccessTokenAndSession(u) require.NoError(ts.T(), err, "Error generating access token") var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "phone": c.userData["phone"], })) req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), c.expectedCode, w.Code) if c.expectedCode == http.StatusOK { // check that the user response returned contains the updated phone field data := &models.User{} require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) require.Equal(ts.T(), data.GetPhone(), c.userData["phone"]) } }) } } func (ts *UserTestSuite) TestUserUpdatePassword() { u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) r, err := models.GrantAuthenticatedUser(ts.API.db, u, models.GrantParams{}) require.NoError(ts.T(), err) r2, err := models.GrantAuthenticatedUser(ts.API.db, u, models.GrantParams{}) require.NoError(ts.T(), err) // create a session and modify it's created_at time to simulate a session that is not recently logged in notRecentlyLoggedIn, err := models.FindSessionByID(ts.API.db, *r2.SessionId, true) require.NoError(ts.T(), err) // cannot use Update here because Update doesn't removes the created_at field require.NoError(ts.T(), ts.API.db.RawQuery( "update "+notRecentlyLoggedIn.TableName()+" set created_at = ? where id = ?", time.Now().Add(-24*time.Hour), notRecentlyLoggedIn.ID).Exec(), ) type expected struct { code int isAuthenticated bool } var cases = []struct { desc string newPassword string nonce string requireReauthentication bool sessionId *uuid.UUID expected expected }{ { desc: "Need reauthentication because outside of recently logged in window", newPassword: "newpassword123", nonce: "", requireReauthentication: true, sessionId: ¬RecentlyLoggedIn.ID, expected: expected{code: http.StatusBadRequest, isAuthenticated: false}, }, { desc: "No nonce provided", newPassword: "newpassword123", nonce: "", sessionId: ¬RecentlyLoggedIn.ID, requireReauthentication: true, expected: expected{code: http.StatusBadRequest, isAuthenticated: false}, }, { desc: "Invalid nonce", newPassword: "newpassword1234", nonce: "123456", sessionId: ¬RecentlyLoggedIn.ID, requireReauthentication: true, expected: expected{code: http.StatusUnprocessableEntity, isAuthenticated: false}, }, { desc: "No need reauthentication because recently logged in", newPassword: "newpassword123", nonce: "", requireReauthentication: true, sessionId: r.SessionId, expected: expected{code: http.StatusOK, isAuthenticated: true}, }, } for _, c := range cases { ts.Run(c.desc, func() { ts.Config.Security.UpdatePasswordRequireReauthentication = c.requireReauthentication var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"password": c.newPassword, "nonce": c.nonce})) req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) req.Header.Set("Content-Type", "application/json") token := ts.generateToken(u, c.sessionId) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), c.expected.code, w.Code) // Request body u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, c.newPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) require.NoError(ts.T(), err) require.Equal(ts.T(), c.expected.isAuthenticated, isAuthenticated) }) } } func (ts *UserTestSuite) TestUserUpdatePasswordNoReauthenticationRequired() { u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) type expected struct { code int isAuthenticated bool } var cases = []struct { desc string newPassword string nonce string requireReauthentication bool expected expected }{ { desc: "Invalid password length", newPassword: "", nonce: "", requireReauthentication: false, expected: expected{code: http.StatusUnprocessableEntity, isAuthenticated: false}, }, { desc: "Valid password length", newPassword: "newpassword", nonce: "", requireReauthentication: false, expected: expected{code: http.StatusOK, isAuthenticated: true}, }, } for _, c := range cases { ts.Run(c.desc, func() { ts.Config.Security.UpdatePasswordRequireReauthentication = c.requireReauthentication var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"password": c.newPassword, "nonce": c.nonce})) req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) req.Header.Set("Content-Type", "application/json") token := ts.generateAccessTokenAndSession(u) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), c.expected.code, w.Code) // Request body u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, c.newPassword, ts.API.config.Security.DBEncryption.DecryptionKeys, ts.API.config.Security.DBEncryption.Encrypt, ts.API.config.Security.DBEncryption.EncryptionKeyID) require.NoError(ts.T(), err) require.Equal(ts.T(), c.expected.isAuthenticated, isAuthenticated) }) } } func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() { ts.Config.Security.UpdatePasswordRequireReauthentication = true u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) // Confirm the test user now := time.Now() u.EmailConfirmedAt = &now require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user") token := ts.generateAccessTokenAndSession(u) // request for reauthentication nonce req := httptest.NewRequest(http.MethodGet, "http://localhost/reauthenticate", nil) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), w.Code, http.StatusOK) u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) require.NotEmpty(ts.T(), u.ReauthenticationToken) require.NotEmpty(ts.T(), u.ReauthenticationSentAt) // update reauthentication token to a known token u.ReauthenticationToken = crypto.GenerateTokenHash(u.GetEmail(), "123456") require.NoError(ts.T(), ts.API.db.Update(u)) // update password with reauthentication token var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "password": "newpass", "nonce": "123456", })) req = httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), w.Code, http.StatusOK) // Request body u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, "newpass", ts.Config.Security.DBEncryption.DecryptionKeys, ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID) require.NoError(ts.T(), err) require.True(ts.T(), isAuthenticated) require.Empty(ts.T(), u.ReauthenticationToken) require.Nil(ts.T(), u.ReauthenticationSentAt) } func (ts *UserTestSuite) TestUserUpdatePasswordLogoutOtherSessions() { ts.Config.Security.UpdatePasswordRequireReauthentication = false u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) // Confirm the test user now := time.Now() u.EmailConfirmedAt = &now require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user") // Login the test user to get first session var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "email": u.GetEmail(), "password": "password", })) req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &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) session1 := AccessTokenResponse{} require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&session1)) // Login test user to get second session require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "email": u.GetEmail(), "password": "password", })) req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer) req.Header.Set("Content-Type", "application/json") ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), http.StatusOK, w.Code) session2 := AccessTokenResponse{} require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&session2)) // Update user's password using first session require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "password": "newpass", })) req = httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session1.Token)) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), http.StatusOK, w.Code) // Attempt to refresh session1 should pass require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "refresh_token": session1.RefreshToken, })) req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &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) // Attempt to refresh session2 should fail require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "refresh_token": session2.RefreshToken, })) req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer) req.Header.Set("Content-Type", "application/json") w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.NotEqual(ts.T(), http.StatusOK, w.Code) }