Repository: supabase/gotrue
Pull request #1224 (2023-08-18 09:48:30)

fix: pass through redirect query parameters
## What kind of change does this PR introduce?

Aims to address #1150  so that we can pass redirect query parameters alongside errors. Note that if any of the existing query parameters are named `error` or `error_description` they will be overwritten. In such cases, the error added by Supabase Auth will take precedence


## What is the current behavior?

When an error occurs the redirect query params only return error and error_description without other query parameters.


## How this was tested
- Case with Error was induced by artifically returning an error in `internalExternalProviderCallback`
- Happy path was tested as per normal


J0: For Testing

Patch:
diff --git a/internal/api/external.go b/internal/api/external.go
index af523f99..7ad21f85 100644
--- a/internal/api/external.go
+++ b/internal/api/external.go
@@ -132,7 +132,12 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 
 // ExternalProviderCallback handles the callback endpoint in the external oauth provider flow
 func (a *API) ExternalProviderCallback(w http.ResponseWriter, r *http.Request) error {
-	a.redirectErrors(a.internalExternalProviderCallback, w, r)
+	rurl := a.getExternalRedirectURL(r)
+	u, err := url.Parse(rurl)
+	if err != nil {
+		return err
+	}
+	a.redirectErrors(a.internalExternalProviderCallback, w, r, u)
 	return nil
 }
 
@@ -554,19 +559,19 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
 	}
 }
 
-func (a *API) redirectErrors(handler apiHandler, w http.ResponseWriter, r *http.Request) {
+func (a *API) redirectErrors(handler apiHandler, w http.ResponseWriter, r *http.Request, u *url.URL) {
 	ctx := r.Context()
 	log := observability.GetLogEntry(r)
 	errorID := getRequestID(ctx)
 	err := handler(w, r)
 	if err != nil {
-		q := getErrorQueryString(err, errorID, log)
-		http.Redirect(w, r, a.getExternalRedirectURL(r)+"?"+q.Encode(), http.StatusFound)
+		q := getErrorQueryString(err, errorID, log, u.Query())
+		u.RawQuery = q.Encode()
+		http.Redirect(w, r, u.String(), http.StatusFound)
 	}
 }
 
-func getErrorQueryString(err error, errorID string, log logrus.FieldLogger) *url.Values {
-	q := url.Values{}
+func getErrorQueryString(err error, errorID string, log logrus.FieldLogger, q url.Values) *url.Values {
 	switch e := err.(type) {
 	case *HTTPError:
 		if str, ok := oauthErrorMap[e.Code]; ok {
@@ -587,7 +592,7 @@ func getErrorQueryString(err error, errorID string, log logrus.FieldLogger) *url
 		q.Set("error_description", e.Description)
 		log.WithError(e.Cause()).Info(e.Error())
 	case ErrorCause:
-		return getErrorQueryString(e.Cause(), errorID, log)
+		return getErrorQueryString(e.Cause(), errorID, log, q)
 	default:
 		error_type, error_description := "server_error", err.Error()
 
diff --git a/internal/api/samlacs.go b/internal/api/samlacs.go
index 979e8dc1..f4433390 100644
--- a/internal/api/samlacs.go
+++ b/internal/api/samlacs.go
@@ -60,6 +60,8 @@ func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
 	redirectTo := ""
 	var requestIds []string
 
+	var flowState *models.FlowState
+	flowState = nil
 	if relayStateUUID != uuid.Nil {
 		// relay state is a valid UUID, therefore this is likely a SP initiated flow
 
@@ -98,6 +100,9 @@ func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
 		entityId = ssoProvider.SAMLProvider.EntityID
 		redirectTo = relayState.RedirectTo
 		requestIds = append(requestIds, relayState.RequestID)
+		if relayState.FlowState != nil {
+			flowState = relayState.FlowState
+		}
 
 		if err := a.samlDestroyRelayState(ctx, relayState); err != nil {
 			return err
@@ -276,6 +281,13 @@ func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
 		if user, terr = a.createAccountFromExternalIdentity(tx, r, &userProvidedData, "sso:"+ssoProvider.ID.String()); terr != nil {
 			return terr
 		}
+		if flowState != nil {
+			// This means that the callback is using PKCE
+			flowState.UserID = &(user.ID)
+			if terr := tx.Update(flowState); terr != nil {
+				return terr
+			}
+		}
 
 		token, terr = a.issueRefreshToken(ctx, tx, user, models.SSOSAML, grantParams)
 
@@ -295,7 +307,17 @@ func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
 	if !utilities.IsRedirectURLValid(config, redirectTo) {
 		redirectTo = config.SiteURL
 	}
+	if flowState != nil {
+		// This means that the callback is using PKCE
+		// Set the flowState.AuthCode to the query param here
+		redirectTo, err = a.prepPKCERedirectURL(redirectTo, flowState.AuthCode)
+		if err != nil {
+			return err
+		}
+		http.Redirect(w, r, redirectTo, http.StatusFound)
+		return nil
 
+	}
 	http.Redirect(w, r, token.AsRedirectURL(redirectTo, url.Values{}), http.StatusFound)
 
 	return nil
diff --git a/internal/api/sso.go b/internal/api/sso.go
index b9a77ec0..a5a69a26 100644
--- a/internal/api/sso.go
+++ b/internal/api/sso.go
@@ -12,10 +12,12 @@ import (
 )
 
 type SingleSignOnParams struct {
-	ProviderID       uuid.UUID `json:"provider_id"`
-	Domain           string    `json:"domain"`
-	RedirectTo       string    `json:"redirect_to"`
-	SkipHTTPRedirect *bool     `json:"skip_http_redirect"`
+	ProviderID          uuid.UUID `json:"provider_id"`
+	Domain              string    `json:"domain"`
+	RedirectTo          string    `json:"redirect_to"`
+	SkipHTTPRedirect    *bool     `json:"skip_http_redirect"`
+	CodeChallenge       string    `json:"code_challenge"`
+	CodeChallengeMethod string    `json:"code_challenge_method"`
 }
 
 type SingleSignOnResponse struct {
@@ -56,6 +58,29 @@ func (a *API) SingleSignOn(w http.ResponseWriter, r *http.Request) error {
 	if hasProviderID, err = params.validate(); err != nil {
 		return err
 	}
+	codeChallengeMethod := params.CodeChallengeMethod
+	codeChallenge := params.CodeChallenge
+
+	if err := validatePKCEParams(codeChallengeMethod, codeChallenge); err != nil {
+		return err
+	}
+	flowType := getFlowFromChallenge(params.CodeChallenge)
+	var flowStateID *uuid.UUID
+	flowStateID = nil
+	if flowType == models.PKCEFlow {
+		codeChallengeMethodType, err := models.ParseCodeChallengeMethod(codeChallengeMethod)
+		if err != nil {
+			return err
+		}
+		flowState, err := models.NewFlowState(models.SSOSAML.String(), codeChallenge, codeChallengeMethodType, models.SSOSAML)
+		if err != nil {
+			return err
+		}
+		if err := a.db.Create(flowState); err != nil {
+			return err
+		}
+		flowStateID = &flowState.ID
+	}
 
 	var ssoProvider *models.SSOProvider
 
@@ -98,6 +123,7 @@ func (a *API) SingleSignOn(w http.ResponseWriter, r *http.Request) error {
 		RequestID:     authnRequest.ID,
 		FromIPAddress: utilities.GetIPAddress(r),
 		RedirectTo:    params.RedirectTo,
+		FlowStateID:   flowStateID,
 	}
 
 	if err := db.Transaction(func(tx *storage.Connection) error {
diff --git a/internal/api/verify.go b/internal/api/verify.go
index 1589225f..ce3a32b1 100644
--- a/internal/api/verify.go
+++ b/internal/api/verify.go
@@ -231,6 +231,7 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP
 		grantParams models.GrantParams
 		token       *AccessTokenResponse
 	)
+	var isSingleConfirmationResponse = false
 
 	err := db.Transaction(func(tx *storage.Connection) error {
 		var terr error
@@ -253,10 +254,8 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP
 		case emailChangeVerification:
 			user, terr = a.emailChangeVerify(r, ctx, tx, params, user)
 			if user == nil && terr == nil {
-				return sendJSON(w, http.StatusOK, map[string]string{
-					"msg":  singleConfirmationAccepted,
-					"code": strconv.Itoa(http.StatusOK),
-				})
+				isSingleConfirmationResponse = true
+				return nil
 			}
 		case smsVerification, phoneChangeVerification:
 			user, terr = a.smsVerify(r, ctx, tx, user, params.Type)
@@ -280,6 +279,12 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP
 	if err != nil {
 		return err
 	}
+	if isSingleConfirmationResponse {
+		return sendJSON(w, http.StatusOK, map[string]string{
+			"msg":  singleConfirmationAccepted,
+			"code": strconv.Itoa(http.StatusOK),
+		})
+	}
 	return sendJSON(w, http.StatusOK, token)
 }
 
@@ -448,9 +453,9 @@ func (a *API) emailChangeVerify(r *http.Request, ctx context.Context, conn *stor
 	if config.Mailer.SecureEmailChangeEnabled && user.EmailChangeConfirmStatus == zeroConfirmation && user.GetEmail() != "" {
 		err := conn.Transaction(func(tx *storage.Connection) error {
 			user.EmailChangeConfirmStatus = singleConfirmation
-			if params.Token == user.EmailChangeTokenCurrent {
+			if params.Token == user.EmailChangeTokenCurrent || params.TokenHash == user.EmailChangeTokenCurrent {
 				user.EmailChangeTokenCurrent = ""
-			} else if params.Token == user.EmailChangeTokenNew {
+			} else if params.Token == user.EmailChangeTokenNew || params.TokenHash == user.EmailChangeTokenNew {
 				user.EmailChangeTokenNew = ""
 			}
 			if terr := tx.UpdateOnly(user, "email_change_confirm_status", "email_change_token_current", "email_change_token_new"); terr != nil {
diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go
index 5475adb7..588b53cc 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -1,6 +1,7 @@
 package conf
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
 	"net/url"
@@ -18,6 +19,26 @@ const defaultMinPasswordLength int = 6
 const defaultChallengeExpiryDuration float64 = 300
 const defaultFlowStateExpiryDuration time.Duration = 300 * time.Second
 
+// Time is used to represent timestamps in the configuration, as envconfig has
+// trouble parsing empty strings, due to time.Time.UnmarshalText().
+type Time struct {
+	time.Time
+}
+
+func (t *Time) UnmarshalText(text []byte) error {
+	trimed := bytes.TrimSpace(text)
+
+	if len(trimed) < 1 {
+		t.Time = time.Time{}
+	} else {
+		if err := t.Time.UnmarshalText(trimed); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 // OAuthProviderConfiguration holds all config related to external account providers.
 type OAuthProviderConfiguration struct {
 	ClientID    []string `json:"client_id" split_words:"true"`
@@ -225,7 +246,7 @@ type SmsProviderConfiguration struct {
 	Provider          string             `json:"provider"`
 	Template          string             `json:"template"`
 	TestOTP           map[string]string  `json:"test_otp" split_words:"true"`
-	TestOTPValidUntil time.Time          `json:"test_otp_valid_until" split_words:"true"`
+	TestOTPValidUntil Time               `json:"test_otp_valid_until" split_words:"true"`
 	SMSTemplate       *template.Template `json:"-"`
 
 	Twilio       TwilioProviderConfiguration       `json:"twilio"`
@@ -236,7 +257,7 @@ type SmsProviderConfiguration struct {
 }
 
 func (c *SmsProviderConfiguration) GetTestOTP(phone string, now time.Time) (string, bool) {
-	if c.TestOTP != nil && (c.TestOTPValidUntil.IsZero() || now.Before(c.TestOTPValidUntil)) {
+	if c.TestOTP != nil && (c.TestOTPValidUntil.Time.IsZero() || now.Before(c.TestOTPValidUntil.Time)) {
 		testOTP, ok := c.TestOTP[phone]
 		return testOTP, ok
 	}
diff --git a/internal/mailer/template.go b/internal/mailer/template.go
index 4f117577..2ef2269b 100644
--- a/internal/mailer/template.go
+++ b/internal/mailer/template.go
@@ -202,6 +202,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent,
 				"NewEmail":        user.EmailChange,
 				"Token":           token,
 				"TokenHash":       tokenHash,
+				"SendingTo":       address,
 				"Data":            user.UserMetaData,
 			}
 			errors <- m.Mailer.Mail(
diff --git a/internal/models/sso.go b/internal/models/sso.go
index 94f73d8a..b9a880fe 100644
--- a/internal/models/sso.go
+++ b/internal/models/sso.go
@@ -154,8 +154,10 @@ type SAMLRelayState struct {
 
 	RedirectTo string `db:"redirect_to"`
 
-	CreatedAt time.Time `db:"created_at" json:"-"`
-	UpdatedAt time.Time `db:"updated_at" json:"-"`
+	CreatedAt   time.Time  `db:"created_at" json:"-"`
+	UpdatedAt   time.Time  `db:"updated_at" json:"-"`
+	FlowStateID *uuid.UUID `db:"flow_state_id" json:"flow_state_id,omitempty"`
+	FlowState   *FlowState `db:"-" json:"flow_state,omitempty" belongs_to:"flow_state"`
 }
 
 func (s SAMLRelayState) TableName() string {
diff --git a/migrations/20230818113222_add_flow_state_to_relay_state.up.sql b/migrations/20230818113222_add_flow_state_to_relay_state.up.sql
new file mode 100644
index 00000000..f940e706
--- /dev/null
+++ b/migrations/20230818113222_add_flow_state_to_relay_state.up.sql
@@ -0,0 +1,1 @@
+alter table {{ index .Options "Namespace" }}.saml_relay_states add column if not exists flow_state_id uuid references {{ index .Options "Namespace" }}.flow_state(id) on delete cascade default null;
diff --git a/openapi.yaml b/openapi.yaml
index f23a72fa..d2d09aba 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -883,6 +883,13 @@ paths:
                 skip_http_redirect:
                   type: boolean
                   description: Set to `true` if the response to this request should not be a HTTP 303 redirect -- useful for browser-based applications.
+                code_challenge:
+                  type: string
+                code_challenge_method:
+                  type: string
+                  enum:
+                    - plain
+                    - s256
                 gotrue_meta_security:
                   $ref: "#/components/schemas/GoTrueMetaSecurity"
       responses:


Test patch:
diff --git a/hack/test.env b/hack/test.env
index 1789540e..51a487ca 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -107,3 +107,4 @@ GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s"
 GOTRUE_SAML_ENABLED="true"
 GOTRUE_SAML_PRIVATE_KEY="MIIEowIBAAKCAQEAszrVveMQcSsa0Y+zN1ZFb19cRS0jn4UgIHTprW2tVBmO2PABzjY3XFCfx6vPirMAPWBYpsKmXrvm1tr0A6DZYmA8YmJd937VUQ67fa6DMyppBYTjNgGEkEhmKuszvF3MARsIKCGtZqUrmS7UG4404wYxVppnr2EYm3RGtHlkYsXu20MBqSDXP47bQP+PkJqC3BuNGk3xt5UHl2FSFpTHelkI6lBynw16B+lUT1F96SERNDaMqi/TRsZdGe5mB/29ngC/QBMpEbRBLNRir5iUevKS7Pn4aph9Qjaxx/97siktK210FJT23KjHpgcUfjoQ6BgPBTLtEeQdRyDuc/CgfwIDAQABAoIBAGYDWOEpupQPSsZ4mjMnAYJwrp4ZISuMpEqVAORbhspVeb70bLKonT4IDcmiexCg7cQBcLQKGpPVM4CbQ0RFazXZPMVq470ZDeWDEyhoCfk3bGtdxc1Zc9CDxNMs6FeQs6r1beEZug6weG5J/yRn/qYxQife3qEuDMl+lzfl2EN3HYVOSnBmdt50dxRuX26iW3nqqbMRqYn9OHuJ1LvRRfYeyVKqgC5vgt/6Tf7DAJwGe0dD7q08byHV8DBZ0pnMVU0bYpf1GTgMibgjnLjK//EVWafFHtN+RXcjzGmyJrk3+7ZyPUpzpDjO21kpzUQLrpEkkBRnmg6bwHnSrBr8avECgYEA3pq1PTCAOuLQoIm1CWR9/dhkbJQiKTJevlWV8slXQLR50P0WvI2RdFuSxlWmA4xZej8s4e7iD3MYye6SBsQHygOVGc4efvvEZV8/XTlDdyj7iLVGhnEmu2r7AFKzy8cOvXx0QcLg+zNd7vxZv/8D3Qj9Jje2LjLHKM5n/dZ3RzUCgYEAzh5Lo2anc4WN8faLGt7rPkGQF+7/18ImQE11joHWa3LzAEy7FbeOGpE/vhOv5umq5M/KlWFIRahMEQv4RusieHWI19ZLIP+JwQFxWxS+cPp3xOiGcquSAZnlyVSxZ//dlVgaZq2o2MfrxECcovRlaknl2csyf+HjFFwKlNxHm2MCgYAr//R3BdEy0oZeVRndo2lr9YvUEmu2LOihQpWDCd0fQw0ZDA2kc28eysL2RROte95r1XTvq6IvX5a0w11FzRWlDpQ4J4/LlcQ6LVt+98SoFwew+/PWuyLmxLycUbyMOOpm9eSc4wJJZNvaUzMCSkvfMtmm5jgyZYMMQ9A2Ul/9SQKBgB9mfh9mhBwVPIqgBJETZMMXOdxrjI5SBYHGSyJqpT+5Q0vIZLfqPrvNZOiQFzwWXPJ+tV4Mc/YorW3rZOdo6tdvEGnRO6DLTTEaByrY/io3/gcBZXoSqSuVRmxleqFdWWRnB56c1hwwWLqNHU+1671FhL6pNghFYVK4suP6qu4BAoGBAMk+VipXcIlD67mfGrET/xDqiWWBZtgTzTMjTpODhDY1GZck1eb4CQMP5j5V3gFJ4cSgWDJvnWg8rcz0unz/q4aeMGl1rah5WNDWj1QKWMS6vJhMHM/rqN1WHWR0ZnV83svYgtg0zDnQKlLujqW4JmGXLMU7ur6a+e6lpa1fvLsP"
 GOTRUE_MAX_VERIFIED_FACTORS=10
+GOTRUE_SMS_TEST_OTP_VALID_UNTIL=""
diff --git a/internal/api/external_test.go b/internal/api/external_test.go
index 524e6df7..1f5c1288 100644
--- a/internal/api/external_test.go
+++ b/internal/api/external_test.go
@@ -197,3 +197,48 @@ func (ts *ExternalTestSuite) TestSignupExternalUnsupported() {
 	ts.API.handler.ServeHTTP(w, req)
 	ts.Equal(w.Code, http.StatusBadRequest)
 }
+
+func (ts *ExternalTestSuite) TestRedirectErrorsShouldPreserveParams() {
+	// Request with invalid external provider
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=external", nil)
+	w := httptest.NewRecorder()
+	cases := []struct {
+		Desc         string
+		RedirectURL  string
+		QueryParams  []string
+		ErrorMessage string
+	}{
+		{
+			Desc:         "Should preserve redirect query params on error",
+			RedirectURL:  "http://example.com/path?paramforpreservation=value2",
+			QueryParams:  []string{"paramforpreservation"},
+			ErrorMessage: "invalid_request",
+		},
+		{
+			Desc:         "Error param should be overwritten",
+			RedirectURL:  "http://example.com/path?error=abc",
+			QueryParams:  []string{"error"},
+			ErrorMessage: "invalid_request",
+		},
+	}
+	for _, c := range cases {
+		parsedURL, err := url.Parse(c.RedirectURL)
+		require.Equal(ts.T(), err, nil)
+
+		ts.API.redirectErrors(ts.API.internalExternalProviderCallback, w, req, parsedURL)
+
+		parsedParams, err := url.ParseQuery(parsedURL.RawQuery)
+		require.Equal(ts.T(), err, nil)
+
+		// An error and description should be returned
+		expectedQueryParams := append(c.QueryParams, "error", "error_description")
+
+		for _, expectedQueryParam := range expectedQueryParams {
+			val, exists := parsedParams[expectedQueryParam]
+			require.True(ts.T(), exists)
+			if expectedQueryParam == "error" {
+				require.Equal(ts.T(), val[0], c.ErrorMessage)
+			}
+		}
+	}
+}
diff --git a/internal/api/sso_test.go b/internal/api/sso_test.go
index beac825e..be620cba 100644
--- a/internal/api/sso_test.go
+++ b/internal/api/sso_test.go
@@ -641,6 +641,17 @@ func (ts *SSOTestSuite) TestSingleSignOn() {
 			Code: http.StatusSeeOther,
 			URL:  "https://accounts.google.com/o/saml2?idpid=EXAMPLE-A",
 		},
+		{
+			// call /sso with provider_id (EXAMPLE-A) and SSO PKCE
+			// should be successful and redirect to the EXAMPLE-A SSO URL
+			Request: map[string]interface{}{
+				"provider_id":           providers[0].ID,
+				"code_challenge":        "vby3iMQ4XUuycKkEyNsYHXshPql1Dod7Ebey2iXTXm4",
+				"code_challenge_method": "s256",
+			},
+			Code: http.StatusSeeOther,
+			URL:  "https://accounts.google.com/o/saml2?idpid=EXAMPLE-A",
+		},
 		{
 			// call /sso with domain=example.com (provider=EXAMPLE-B)
 			// should be successful and redirect to the EXAMPLE-B SSO URL
diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go
index af1fcfd1..6c93e07c 100644
--- a/internal/api/verify_test.go
+++ b/internal/api/verify_test.go
@@ -622,7 +622,6 @@ func (ts *VerifyTestSuite) TestVerifySignupWithredirectURLContainedPath() {
 }
 
 func (ts *VerifyTestSuite) TestVerifyPKCEOTP() {
-
 	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
 	u.ConfirmationToken = "pkce_confirmation_token"
@@ -935,6 +934,83 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() {
 	}
 }
 
+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":       emailChangeVerification,
+				"token_hash": currentEmailChangeToken,
+			},
+			secondVerificationBody: map[string]interface{}{
+				"type":       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":       emailChangeVerification,
+				"token_hash": currentEmailChangeToken,
+			},
+			secondVerificationBody: map[string]interface{}{
+				"type":       emailChangeVerification,
+				"token_hash": currentEmailChangeToken,
+			},
+			expectedStatus: http.StatusUnauthorized,
+		},
+	}
+	for _, c := range cases {
+		ts.Run(c.desc, func() {
+			// Set the corresponding email change tokens
+			u.EmailChangeTokenCurrent = currentEmailChangeToken
+			u.EmailChangeTokenNew = newEmailChangeToken
+
+			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 {