chatdesk-ui/auth_v2.169.0/internal/api/verify_test.go

1281 lines
41 KiB
Go

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 = &currentTime
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)
})
}
}