package api import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" mail "github.com/supabase/auth/internal/mailer" "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/crypto" "github.com/supabase/auth/internal/models" ) type VerifyTestSuite struct { suite.Suite API *API Config *conf.GlobalConfiguration } func TestVerify(t *testing.T) { api, config, err := setupAPIForTest() require.NoError(t, err) ts := &VerifyTestSuite{ API: api, Config: config, } defer api.db.Close() suite.Run(t, ts) } func (ts *VerifyTestSuite) SetupTest() { models.TruncateAll(ts.API.db) // Create user u, err := models.NewUser("12345678", "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") // Create identity i, err := models.NewIdentity(u, "email", map[string]interface{}{ "sub": u.ID.String(), "email": "test@example.com", "email_verified": false, }) require.NoError(ts.T(), err, "Error creating test identity model") require.NoError(ts.T(), ts.API.db.Create(i), "Error saving new test identity") } func (ts *VerifyTestSuite) TestVerifyPasswordRecovery() { // modify config so we don't hit rate limit from requesting recovery twice in 60s ts.Config.SMTP.MaxFrequency = 60 u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.RecoverySentAt = &time.Time{} require.NoError(ts.T(), ts.API.db.Update(u)) testEmail := "test@example.com" cases := []struct { desc string body map[string]interface{} isPKCE bool }{ { desc: "Implict Flow Recovery", body: map[string]interface{}{ "email": testEmail, }, isPKCE: false, }, { desc: "PKCE Flow", body: map[string]interface{}{ "email": testEmail, // Code Challenge needs to be at least 43 characters long "code_challenge": "6b151854-cc15-4e29-8db7-3d3a9f15b3066b151854-cc15-4e29-8db7-3d3a9f15b306", "code_challenge_method": models.SHA256.String(), }, isPKCE: true, }, } for _, c := range cases { ts.Run(c.desc, func() { // Reset user u.EmailConfirmedAt = nil require.NoError(ts.T(), ts.API.db.Update(u)) require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) // Request body var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) // Setup request req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer) req.Header.Set("Content-Type", "application/json") // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusOK, w.Code) u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second) assert.False(ts.T(), u.IsConfirmed()) recoveryToken := u.RecoveryToken reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.RecoveryVerification, recoveryToken) req = httptest.NewRequest(http.MethodGet, reqURL, nil) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusSeeOther, w.Code) u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.True(ts.T(), u.IsConfirmed()) if c.isPKCE { rURL, _ := w.Result().Location() f, err := url.ParseQuery(rURL.RawQuery) require.NoError(ts.T(), err) assert.NotEmpty(ts.T(), f.Get("code")) } }) } } func (ts *VerifyTestSuite) TestVerifySecureEmailChange() { currentEmail := "test@example.com" newEmail := "new@example.com" // Change from new email to current email and back to new email cases := []struct { desc string body map[string]interface{} isPKCE bool currentEmail string newEmail string }{ { desc: "Implict Flow Email Change", body: map[string]interface{}{ "email": newEmail, }, isPKCE: false, currentEmail: currentEmail, newEmail: newEmail, }, { desc: "PKCE Email Change", body: map[string]interface{}{ "email": currentEmail, // Code Challenge needs to be at least 43 characters long "code_challenge": "6b151854-cc15-4e29-8db7-3d3a9f15b3066b151854-cc15-4e29-8db7-3d3a9f15b306", "code_challenge_method": models.SHA256.String(), }, isPKCE: true, currentEmail: newEmail, newEmail: currentEmail, }, } for _, c := range cases { ts.Run(c.desc, func() { u, err := models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) require.NoError(ts.T(), err) // reset user u.EmailChangeSentAt = nil u.EmailChangeTokenCurrent = "" u.EmailChangeTokenNew = "" require.NoError(ts.T(), ts.API.db.Update(u)) require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) // Request body var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) // Setup request req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) req.Header.Set("Content-Type", "application/json") // Generate access token for request and a mock session var token string session, err := models.NewSession(u.ID, nil) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(session)) token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.MagicLink) require.NoError(ts.T(), err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusOK, w.Code) u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) require.NoError(ts.T(), err) currentTokenHash := u.EmailChangeTokenCurrent newTokenHash := u.EmailChangeTokenNew u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.WithinDuration(ts.T(), time.Now(), *u.EmailChangeSentAt, 1*time.Second) assert.False(ts.T(), u.IsConfirmed()) // Verify new email reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, newTokenHash) req = httptest.NewRequest(http.MethodGet, reqURL, nil) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), http.StatusSeeOther, w.Code) urlVal, err := url.Parse(w.Result().Header.Get("Location")) ts.Require().NoError(err, "redirect url parse failed") var v url.Values if !c.isPKCE { v, err = url.ParseQuery(urlVal.Fragment) ts.Require().NoError(err) ts.Require().NotEmpty(v.Get("message")) } else if c.isPKCE { v, err = url.ParseQuery(urlVal.RawQuery) ts.Require().NoError(err) ts.Require().NotEmpty(v.Get("message")) v, err = url.ParseQuery(urlVal.Fragment) ts.Require().NoError(err) ts.Require().NotEmpty(v.Get("message")) } u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus) // Verify old email reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, currentTokenHash) req = httptest.NewRequest(http.MethodGet, reqURL, nil) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), http.StatusSeeOther, w.Code) urlVal, err = url.Parse(w.Header().Get("Location")) ts.Require().NoError(err, "redirect url parse failed") if !c.isPKCE { v, err = url.ParseQuery(urlVal.Fragment) ts.Require().NoError(err) ts.Require().NotEmpty(v.Get("access_token")) ts.Require().NotEmpty(v.Get("expires_in")) ts.Require().NotEmpty(v.Get("refresh_token")) } else if c.isPKCE { v, err = url.ParseQuery(urlVal.RawQuery) ts.Require().NoError(err) ts.Require().NotEmpty(v.Get("code")) } // user's email should've been updated to newEmail u, err = models.FindUserByEmailAndAudience(ts.API.db, c.newEmail, ts.Config.JWT.Aud) require.NoError(ts.T(), err) require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus) // Reset confirmation status after each test u.EmailConfirmedAt = nil require.NoError(ts.T(), ts.API.db.Update(u)) }) } } func (ts *VerifyTestSuite) TestExpiredConfirmationToken() { // verify variant testing not necessary in this test as it's testing // the ConfirmationSentAt behavior, not the ConfirmationToken behavior u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.ConfirmationToken = "asdf3" sentTime := time.Now().Add(-48 * time.Hour) u.ConfirmationSentAt = &sentTime require.NoError(ts.T(), ts.API.db.Update(u)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) // Setup request reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.SignupVerification, u.ConfirmationToken) req := httptest.NewRequest(http.MethodGet, reqURL, nil) // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusSeeOther, w.Code) rurl, err := url.Parse(w.Header().Get("Location")) require.NoError(ts.T(), err, "redirect url parse failed") f, err := url.ParseQuery(rurl.Fragment) require.NoError(ts.T(), err) assert.Equal(ts.T(), ErrorCodeOTPExpired, f.Get("error_code")) assert.Equal(ts.T(), "Email link is invalid or has expired", f.Get("error_description")) assert.Equal(ts.T(), "access_denied", f.Get("error")) } func (ts *VerifyTestSuite) TestInvalidOtp() { u, err := models.FindUserByPhoneAndAudience(ts.API.db, "12345678", ts.Config.JWT.Aud) require.NoError(ts.T(), err) sentTime := time.Now().Add(-48 * time.Hour) u.ConfirmationToken = "123456" u.ConfirmationSentAt = &sentTime u.PhoneChange = "22222222" u.PhoneChangeToken = "123456" u.PhoneChangeSentAt = &sentTime u.EmailChange = "test@gmail.com" u.EmailChangeTokenNew = "123456" u.EmailChangeTokenCurrent = "123456" require.NoError(ts.T(), ts.API.db.Update(u)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.PhoneChange, u.PhoneChangeToken, models.PhoneChangeToken)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.EmailChange, u.EmailChangeTokenNew, models.EmailChangeTokenNew)) type ResponseBody struct { Code int `json:"code"` Msg string `json:"msg"` } expectedResponse := ResponseBody{ Code: http.StatusForbidden, Msg: "Token has expired or is invalid", } cases := []struct { desc string sentTime time.Time body map[string]interface{} expected ResponseBody }{ { desc: "Expired SMS OTP", sentTime: time.Now().Add(-48 * time.Hour), body: map[string]interface{}{ "type": smsVerification, "token": u.ConfirmationToken, "phone": u.GetPhone(), }, expected: expectedResponse, }, { desc: "Invalid SMS OTP", sentTime: time.Now(), body: map[string]interface{}{ "type": smsVerification, "token": "invalid_otp", "phone": u.GetPhone(), }, expected: expectedResponse, }, { desc: "Invalid Phone Change OTP", sentTime: time.Now(), body: map[string]interface{}{ "type": phoneChangeVerification, "token": "invalid_otp", "phone": u.PhoneChange, }, expected: expectedResponse, }, { desc: "Invalid Email OTP", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.SignupVerification, "token": "invalid_otp", "email": u.GetEmail(), }, expected: expectedResponse, }, { desc: "Invalid Email Change", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.EmailChangeVerification, "token": "invalid_otp", "email": u.GetEmail(), }, expected: expectedResponse, }, } for _, caseItem := range cases { c := caseItem ts.Run(c.desc, func() { // update token sent time sentTime = time.Now() u.ConfirmationSentAt = &c.sentTime require.NoError(ts.T(), ts.API.db.Update(u)) var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) // Setup request req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) req.Header.Set("Content-Type", "application/json") // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) b, err := io.ReadAll(w.Body) require.NoError(ts.T(), err) var resp ResponseBody err = json.Unmarshal(b, &resp) require.NoError(ts.T(), err) assert.Equal(ts.T(), c.expected.Code, resp.Code) assert.Equal(ts.T(), c.expected.Msg, resp.Msg) }) } } func (ts *VerifyTestSuite) TestExpiredRecoveryToken() { // verify variant testing not necessary in this test as it's testing // the RecoverySentAt behavior, not the RecoveryToken behavior u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.RecoveryToken = "asdf3" sentTime := time.Now().Add(-48 * time.Hour) u.RecoverySentAt = &sentTime require.NoError(ts.T(), ts.API.db.Update(u)) // Setup request reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", "signup", u.RecoveryToken) req := httptest.NewRequest(http.MethodGet, reqURL, nil) // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusSeeOther, w.Code, w.Body.String()) } func (ts *VerifyTestSuite) TestVerifyPermitedCustomUri() { // verify variant testing not necessary in this test as it's testing // the redirect URL behavior, not the RecoveryToken behavior u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.RecoverySentAt = &time.Time{} require.NoError(ts.T(), ts.API.db.Update(u)) // Request body var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "email": "test@example.com", })) // Setup request req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer) req.Header.Set("Content-Type", "application/json") // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusOK, w.Code) u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second) assert.False(ts.T(), u.IsConfirmed()) redirectURL, _ := url.Parse(ts.Config.URIAllowList[0]) reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s&redirect_to=%s", "recovery", u.RecoveryToken, redirectURL.String()) req = httptest.NewRequest(http.MethodGet, reqURL, nil) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusSeeOther, w.Code) rURL, _ := w.Result().Location() assert.Equal(ts.T(), redirectURL.Hostname(), rURL.Hostname()) u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.True(ts.T(), u.IsConfirmed()) } func (ts *VerifyTestSuite) TestVerifyNotPermitedCustomUri() { // verify variant testing not necessary in this test as it's testing // the redirect URL behavior, not the RecoveryToken behavior u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.RecoverySentAt = &time.Time{} require.NoError(ts.T(), ts.API.db.Update(u)) // Request body var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ "email": "test@example.com", })) // Setup request req := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer) req.Header.Set("Content-Type", "application/json") // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusOK, w.Code) u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second) assert.False(ts.T(), u.IsConfirmed()) fakeredirectURL, _ := url.Parse("http://custom-url.com") siteURL, _ := url.Parse(ts.Config.SiteURL) reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s&redirect_to=%s", "recovery", u.RecoveryToken, fakeredirectURL.String()) req = httptest.NewRequest(http.MethodGet, reqURL, nil) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusSeeOther, w.Code) rURL, _ := w.Result().Location() assert.Equal(ts.T(), siteURL.Hostname(), rURL.Hostname()) u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.True(ts.T(), u.IsConfirmed()) } func (ts *VerifyTestSuite) TestVerifySignupWithRedirectURLContainedPath() { // verify variant testing not necessary in this test as it's testing // the redirect URL behavior, not the RecoveryToken behavior testCases := []struct { desc string siteURL string uriAllowList []string requestredirectURL string expectedredirectURL string }{ { desc: "same site url and redirect url with path", siteURL: "http://localhost:3000/#/", uriAllowList: []string{"http://localhost:3000"}, requestredirectURL: "http://localhost:3000/#/", expectedredirectURL: "http://localhost:3000/#/", }, { desc: "different site url and redirect url in allow list", siteURL: "https://someapp-something.codemagic.app/#/", uriAllowList: []string{"http://localhost:3000"}, requestredirectURL: "http://localhost:3000", expectedredirectURL: "http://localhost:3000", }, { desc: "different site url and redirect url not in allow list", siteURL: "https://someapp-something.codemagic.app/#/", uriAllowList: []string{"http://localhost:3000"}, requestredirectURL: "http://localhost:3000/docs", expectedredirectURL: "https://someapp-something.codemagic.app/#/", }, { desc: "same wildcard site url and redirect url in allow list", siteURL: "http://sub.test.dev:3000/#/", uriAllowList: []string{"http://*.test.dev:3000"}, requestredirectURL: "http://sub.test.dev:3000/#/", expectedredirectURL: "http://sub.test.dev:3000/#/", }, { desc: "different wildcard site url and redirect url in allow list", siteURL: "http://sub.test.dev/#/", uriAllowList: []string{"http://*.other.dev:3000"}, requestredirectURL: "http://sub.other.dev:3000", expectedredirectURL: "http://sub.other.dev:3000", }, { desc: "different wildcard site url and redirect url not in allow list", siteURL: "http://test.dev:3000/#/", uriAllowList: []string{"http://*.allowed.dev:3000"}, requestredirectURL: "http://sub.test.dev:3000/#/", expectedredirectURL: "http://test.dev:3000/#/", }, { desc: "exact mobile deep link redirect url in allow list", siteURL: "http://test.dev:3000/#/", uriAllowList: []string{"twitter://timeline"}, requestredirectURL: "twitter://timeline", expectedredirectURL: "twitter://timeline", }, // previously the below example was not allowed and with good // reason, however users do want flexibility in the redirect // URL after the scheme, which is why the example is now corrected { desc: "wildcard mobile deep link redirect url in allow list", siteURL: "http://test.dev:3000/#/", uriAllowList: []string{"com.example.app://**"}, requestredirectURL: "com.example.app://sign-in/v2", expectedredirectURL: "com.example.app://sign-in/v2", }, { desc: "redirect respects . separator", siteURL: "http://localhost:3000", uriAllowList: []string{"http://*.*.dev:3000"}, requestredirectURL: "http://foo.bar.dev:3000", expectedredirectURL: "http://foo.bar.dev:3000", }, { desc: "redirect does not respect . separator", siteURL: "http://localhost:3000", uriAllowList: []string{"http://*.dev:3000"}, requestredirectURL: "http://foo.bar.dev:3000", expectedredirectURL: "http://localhost:3000", }, { desc: "redirect respects / separator in url subdirectory", siteURL: "http://localhost:3000", uriAllowList: []string{"http://test.dev:3000/*/*"}, requestredirectURL: "http://test.dev:3000/bar/foo", expectedredirectURL: "http://test.dev:3000/bar/foo", }, { desc: "redirect does not respect / separator in url subdirectory", siteURL: "http://localhost:3000", uriAllowList: []string{"http://test.dev:3000/*"}, requestredirectURL: "http://test.dev:3000/bar/foo", expectedredirectURL: "http://localhost:3000", }, } for _, tC := range testCases { ts.Run(tC.desc, func() { // prepare test data ts.Config.SiteURL = tC.siteURL redirectURL := tC.requestredirectURL ts.Config.URIAllowList = tC.uriAllowList ts.Config.ApplyDefaults() // set verify token to user as it actual do in magic link method u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.ConfirmationToken = "someToken" sendTime := time.Now().Add(time.Hour) u.ConfirmationSentAt = &sendTime require.NoError(ts.T(), ts.API.db.Update(u)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s&redirect_to=%s", "signup", u.ConfirmationToken, redirectURL) req := httptest.NewRequest(http.MethodGet, reqURL, nil) w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusSeeOther, w.Code) rURL, _ := w.Result().Location() assert.Contains(ts.T(), rURL.String(), tC.expectedredirectURL) // redirected url starts with per test value u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.True(ts.T(), u.IsConfirmed()) assert.True(ts.T(), u.UserMetaData["email_verified"].(bool)) assert.True(ts.T(), u.Identities[0].IdentityData["email_verified"].(bool)) }) } } func (ts *VerifyTestSuite) TestVerifyPKCEOTP() { u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) t := time.Now() u.ConfirmationSentAt = &t u.RecoverySentAt = &t u.EmailChangeSentAt = &t require.NoError(ts.T(), ts.API.db.Update(u)) cases := []struct { desc string payload *VerifyParams authenticationMethod models.AuthenticationMethod }{ { desc: "Verify user on signup", payload: &VerifyParams{ Type: "signup", Token: "pkce_confirmation_token", }, authenticationMethod: models.EmailSignup, }, { desc: "Verify magiclink", payload: &VerifyParams{ Type: "magiclink", Token: "pkce_recovery_token", }, authenticationMethod: models.MagicLink, }, } for _, c := range cases { ts.Run(c.desc, func() { var buffer bytes.Buffer // since the test user is the same, the tokens are being cleared after each successful verification attempt // so we create them on each run if c.payload.Type == "signup" { require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), c.payload.Token, models.ConfirmationToken)) } else if c.payload.Type == "magiclink" { require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), c.payload.Token, models.RecoveryToken)) } require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload)) codeChallenge := "codechallengecodechallengcodechallengcodechallengcodechallenge" flowState := models.NewFlowState(c.authenticationMethod.String(), codeChallenge, models.SHA256, c.authenticationMethod, &u.ID) require.NoError(ts.T(), ts.API.db.Create(flowState)) requestUrl := fmt.Sprintf("http://localhost/verify?type=%v&token=%v", c.payload.Type, c.payload.Token) req := httptest.NewRequest(http.MethodGet, requestUrl, &buffer) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusSeeOther, w.Code) rURL, _ := w.Result().Location() u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.True(ts.T(), u.IsConfirmed()) f, err := url.ParseQuery(rURL.RawQuery) require.NoError(ts.T(), err) assert.NotEmpty(ts.T(), f.Get("code")) }) } } func (ts *VerifyTestSuite) TestVerifyBannedUser() { u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.ConfirmationToken = "confirmation_token" u.RecoveryToken = "recovery_token" u.EmailChangeTokenCurrent = "current_email_change_token" u.EmailChangeTokenNew = "new_email_change_token" t := time.Now() u.ConfirmationSentAt = &t u.RecoverySentAt = &t u.EmailChangeSentAt = &t t = time.Now().Add(24 * time.Hour) u.BannedUntil = &t require.NoError(ts.T(), ts.API.db.Update(u)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.ConfirmationToken, models.ConfirmationToken)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.RecoveryToken, models.RecoveryToken)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenCurrent, models.EmailChangeTokenCurrent)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, u.GetEmail(), u.EmailChangeTokenNew, models.EmailChangeTokenNew)) cases := []struct { desc string payload *VerifyParams }{ { desc: "Verify banned user on signup", payload: &VerifyParams{ Type: "signup", Token: u.ConfirmationToken, }, }, { desc: "Verify banned user on invite", payload: &VerifyParams{ Type: "invite", Token: u.ConfirmationToken, }, }, { desc: "Verify banned user on recover", payload: &VerifyParams{ Type: "recovery", Token: u.RecoveryToken, }, }, { desc: "Verify banned user on magiclink", payload: &VerifyParams{ Type: "magiclink", Token: u.RecoveryToken, }, }, { desc: "Verify banned user on email change", payload: &VerifyParams{ Type: "email_change", Token: u.EmailChangeTokenCurrent, }, }, } for _, c := range cases { ts.Run(c.desc, func() { var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload)) requestUrl := fmt.Sprintf("http://localhost/verify?type=%v&token=%v", c.payload.Type, c.payload.Token) req := httptest.NewRequest(http.MethodGet, requestUrl, &buffer) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusSeeOther, w.Code) rurl, err := url.Parse(w.Header().Get("Location")) require.NoError(ts.T(), err, "redirect url parse failed") f, err := url.ParseQuery(rurl.Fragment) require.NoError(ts.T(), err) assert.Equal(ts.T(), ErrorCodeUserBanned, f.Get("error_code")) }) } } func (ts *VerifyTestSuite) TestVerifyValidOtp() { ts.Config.Mailer.SecureEmailChangeEnabled = true u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.EmailChange = "new@example.com" u.Phone = "12345678" u.PhoneChange = "1234567890" require.NoError(ts.T(), ts.API.db.Update(u)) type expected struct { code int tokenHash string } cases := []struct { desc string sentTime time.Time body map[string]interface{} expected }{ { desc: "Valid SMS OTP", sentTime: time.Now(), body: map[string]interface{}{ "type": smsVerification, "token": "123456", "phone": u.GetPhone(), }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.GetPhone(), "123456"), }, }, { desc: "Valid Confirmation OTP", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.SignupVerification, "token": "123456", "email": u.GetEmail(), }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"), }, }, { desc: "Valid Signup Token Hash", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.SignupVerification, "token_hash": crypto.GenerateTokenHash(u.GetEmail(), "123456"), }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"), }, }, { desc: "Valid Recovery OTP", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.RecoveryVerification, "token": "123456", "email": u.GetEmail(), }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"), }, }, { desc: "Valid Email OTP", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.EmailOTPVerification, "token": "123456", "email": u.GetEmail(), }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"), }, }, { desc: "Valid Email OTP (email casing shouldn't matter)", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.EmailOTPVerification, "token": "123456", "email": strings.ToUpper(u.GetEmail()), }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"), }, }, { desc: "Valid Email Change OTP", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.EmailChangeVerification, "token": "123456", "email": u.EmailChange, }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.EmailChange, "123456"), }, }, { desc: "Valid Phone Change OTP", sentTime: time.Now(), body: map[string]interface{}{ "type": phoneChangeVerification, "token": "123456", "phone": u.PhoneChange, }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.PhoneChange, "123456"), }, }, { desc: "Valid Email Change Token Hash", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.EmailChangeVerification, "token_hash": crypto.GenerateTokenHash(u.EmailChange, "123456"), }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.EmailChange, "123456"), }, }, { desc: "Valid Email Verification Type", sentTime: time.Now(), body: map[string]interface{}{ "type": mail.EmailOTPVerification, "token_hash": crypto.GenerateTokenHash(u.GetEmail(), "123456"), }, expected: expected{ code: http.StatusOK, tokenHash: crypto.GenerateTokenHash(u.GetEmail(), "123456"), }, }, } for _, caseItem := range cases { c := caseItem ts.Run(c.desc, func() { // create user require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) u.ConfirmationSentAt = &c.sentTime u.RecoverySentAt = &c.sentTime u.EmailChangeSentAt = &c.sentTime u.PhoneChangeSentAt = &c.sentTime u.ConfirmationToken = c.expected.tokenHash u.RecoveryToken = c.expected.tokenHash u.EmailChangeTokenNew = c.expected.tokenHash u.PhoneChangeToken = c.expected.tokenHash require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.ConfirmationToken, models.ConfirmationToken)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.RecoveryToken, models.RecoveryToken)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.EmailChangeTokenNew, models.EmailChangeTokenNew)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", u.PhoneChangeToken, models.PhoneChangeToken)) require.NoError(ts.T(), ts.API.db.Update(u)) var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) // Setup request req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) req.Header.Set("Content-Type", "application/json") // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), c.expected.code, w.Code) }) } } func (ts *VerifyTestSuite) TestSecureEmailChangeWithTokenHash() { ts.Config.Mailer.SecureEmailChangeEnabled = true u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) u.EmailChange = "new@example.com" require.NoError(ts.T(), ts.API.db.Update(u)) currentEmailChangeToken := crypto.GenerateTokenHash(string(u.Email), "123456") newEmailChangeToken := crypto.GenerateTokenHash(u.EmailChange, "123456") cases := []struct { desc string firstVerificationBody map[string]interface{} secondVerificationBody map[string]interface{} expectedStatus int }{ { desc: "Secure Email Change with Token Hash (Success)", firstVerificationBody: map[string]interface{}{ "type": mail.EmailChangeVerification, "token_hash": currentEmailChangeToken, }, secondVerificationBody: map[string]interface{}{ "type": mail.EmailChangeVerification, "token_hash": newEmailChangeToken, }, expectedStatus: http.StatusOK, }, { desc: "Secure Email Change with Token Hash. Reusing a token hash twice should fail", firstVerificationBody: map[string]interface{}{ "type": mail.EmailChangeVerification, "token_hash": currentEmailChangeToken, }, secondVerificationBody: map[string]interface{}{ "type": mail.EmailChangeVerification, "token_hash": currentEmailChangeToken, }, expectedStatus: http.StatusForbidden, }, } for _, c := range cases { ts.Run(c.desc, func() { // Set the corresponding email change tokens u.EmailChangeTokenCurrent = currentEmailChangeToken u.EmailChangeTokenNew = newEmailChangeToken require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", currentEmailChangeToken, models.EmailChangeTokenCurrent)) require.NoError(ts.T(), models.CreateOneTimeToken(ts.API.db, u.ID, "relates_to not used", newEmailChangeToken, models.EmailChangeTokenNew)) currentTime := time.Now() u.EmailChangeSentAt = ¤tTime require.NoError(ts.T(), ts.API.db.Update(u)) var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.firstVerificationBody)) // Setup request req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) req.Header.Set("Content-Type", "application/json") // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.secondVerificationBody)) // Setup second request req = httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) req.Header.Set("Content-Type", "application/json") // Setup second response recorder w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), c.expectedStatus, w.Code) }) } } func (ts *VerifyTestSuite) TestPrepRedirectURL() { escapedMessage := url.QueryEscape(singleConfirmationAccepted) cases := []struct { desc string message string rurl string flowType models.FlowType expected string }{ { desc: "(PKCE): Redirect URL with additional query params", message: singleConfirmationAccepted, rurl: "https://example.com/?first=another&second=other", flowType: models.PKCEFlow, expected: fmt.Sprintf("https://example.com/?first=another&message=%s&second=other#message=%s", escapedMessage, escapedMessage), }, { desc: "(PKCE): Query params in redirect url are overriden", message: singleConfirmationAccepted, rurl: "https://example.com/?message=Valid+redirect+URL", flowType: models.PKCEFlow, expected: fmt.Sprintf("https://example.com/?message=%s#message=%s", escapedMessage, escapedMessage), }, { desc: "(Implicit): plain redirect url", message: singleConfirmationAccepted, rurl: "https://example.com/", flowType: models.ImplicitFlow, expected: fmt.Sprintf("https://example.com/#message=%s", escapedMessage), }, { desc: "(Implicit): query params retained", message: singleConfirmationAccepted, rurl: "https://example.com/?first=another", flowType: models.ImplicitFlow, expected: fmt.Sprintf("https://example.com/?first=another#message=%s", escapedMessage), }, } for _, c := range cases { ts.Run(c.desc, func() { rurl, err := ts.API.prepRedirectURL(c.message, c.rurl, c.flowType) require.NoError(ts.T(), err) require.Equal(ts.T(), c.expected, rurl) }) } } func (ts *VerifyTestSuite) TestPrepErrorRedirectURL() { const DefaultError = "Invalid redirect URL" redirectError := fmt.Sprintf("error=invalid_request&error_code=validation_failed&error_description=%s", url.QueryEscape(DefaultError)) cases := []struct { desc string message string rurl string flowType models.FlowType expected string }{ { desc: "(PKCE): Error in both query params and hash fragment", message: "Valid redirect URL", rurl: "https://example.com/", flowType: models.PKCEFlow, expected: fmt.Sprintf("https://example.com/?%s#%s", redirectError, redirectError), }, { desc: "(PKCE): Error with conflicting query params in redirect url", message: DefaultError, rurl: "https://example.com/?error=Error+to+be+overriden", flowType: models.PKCEFlow, expected: fmt.Sprintf("https://example.com/?%s#%s", redirectError, redirectError), }, { desc: "(Implicit): plain redirect url", message: DefaultError, rurl: "https://example.com/", flowType: models.ImplicitFlow, expected: fmt.Sprintf("https://example.com/#%s", redirectError), }, { desc: "(Implicit): query params preserved", message: DefaultError, rurl: "https://example.com/?test=param", flowType: models.ImplicitFlow, expected: fmt.Sprintf("https://example.com/?test=param#%s", redirectError), }, } for _, c := range cases { ts.Run(c.desc, func() { req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) rurl, err := ts.API.prepErrorRedirectURL(badRequestError(ErrorCodeValidationFailed, DefaultError), req, c.rurl, c.flowType) require.NoError(ts.T(), err) require.Equal(ts.T(), c.expected, rurl) }) } } func (ts *VerifyTestSuite) TestVerifyValidateParams() { cases := []struct { desc string params *VerifyParams method string expected error }{ { desc: "Successful GET Verify", params: &VerifyParams{ Type: "signup", Token: "some-token-hash", }, method: http.MethodGet, expected: nil, }, { desc: "Successful POST Verify (TokenHash)", params: &VerifyParams{ Type: "signup", TokenHash: "some-token-hash", }, method: http.MethodPost, expected: nil, }, { desc: "Successful POST Verify (Token)", params: &VerifyParams{ Type: "signup", Token: "some-token", Email: "email@example.com", }, method: http.MethodPost, expected: nil, }, // unsuccessful validations { desc: "Need to send email or phone number with token", params: &VerifyParams{ Type: "signup", Token: "some-token", }, method: http.MethodPost, expected: badRequestError(ErrorCodeValidationFailed, "Only an email address or phone number should be provided on verify"), }, { desc: "Cannot send both TokenHash and Token", params: &VerifyParams{ Type: "signup", Token: "some-token", TokenHash: "some-token-hash", }, method: http.MethodPost, expected: badRequestError(ErrorCodeValidationFailed, "Verify requires either a token or a token hash"), }, { desc: "No verification type specified", params: &VerifyParams{ Token: "some-token", Email: "email@example.com", }, method: http.MethodPost, expected: badRequestError(ErrorCodeValidationFailed, "Verify requires a verification type"), }, } for _, c := range cases { ts.Run(c.desc, func() { req := httptest.NewRequest(c.method, "http://localhost", nil) err := c.params.Validate(req, ts.API) require.Equal(ts.T(), c.expected, err) }) } }