Repository: supabase/gotrue
Pull request #1317 (2023-11-22 05:58:13)

feat: add manual linking APIs
## What kind of change does this PR introduce?
* Adds a new endpoint `GET /user/identities/authorize` which is an endpoint to initiate the manual linking process and can only be invoked if the user is authenticated
* `GET /user/identities/authorize` functions similarly to `GET /authorize` where the user needs to login to the new oauth identity in order to link the identity 
* Example
```curl
// sign in with one of the supported auth methods to get the user's access token JWT first

// start the identity linking process
$ curl -X GET "http://localhost:9999/user/identities/authorize?provider=google" -H "Authorization: Bearer ACCESS_TOKEN_JWT"

{"url":"https://oauth_provider_url.com/path/to/sign-in"}

// visit the url returned and login to the oauth provider 
// request will be redirected to the /callback endpoint

// if the identity is successfully linked, the request will be redirected to `http://localhost:3000/#access_token=xxx&....`

// if the identity already exists, the request will be redirect to:
// http://localhost:3000/?error=invalid_request&error_code=400&error_description=Identity+is+already+linked+to+another+user#error=invalid_request&error_code=400&error_description=Identity+is+already+linked+to+another+user
```

## Details
* The callback endpoint used will be the same callback as the oauth sign-in flow so that the developer doesn't have to add any additional callback URLs to the oauth provider in order to enable manual linking
* A special field `LinkingTargetId` is introduced in the oauth state to store the linking target user ID. This ID will be used in the callback to determine the target user to link the candidate identity used
* If the identity is already linked to the current user or another user, an error will be returned
* If the identity doesn't exist, then it will be successfully linked to the existing user and a new access & refresh token will be issued.


Patch:
diff --git a/README.md b/README.md
index 413e7f91..92e9594d 100644
--- a/README.md
+++ b/README.md
@@ -217,6 +217,8 @@ Rate limit the number of emails sent per hr on the following endpoints: `/signup
 
 Minimum password length, defaults to 6.
 
+`GOTRUE_PASSWORD_REQUIRED_CHARACTERS` - a string of character sets separated by `:`. A password must contain at least one character of each set to be accepted. To use the `:` character escape it with `\`.
+
 `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` - `bool`
 
 If refresh token rotation is enabled, gotrue will automatically detect malicious attempts to reuse a revoked refresh token. When a malicious attempt is detected, gotrue immediately revokes all tokens that descended from the offending token.
diff --git a/internal/api/admin.go b/internal/api/admin.go
index 024d6ecb..fa8132e3 100644
--- a/internal/api/admin.go
+++ b/internal/api/admin.go
@@ -3,7 +3,6 @@ package api
 import (
 	"context"
 	"encoding/json"
-	"errors"
 	"net/http"
 	"strings"
 	"time"
@@ -144,7 +143,6 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 	user := getUser(ctx)
 	adminUser := getAdminUser(ctx)
 	params, err := a.getAdminParams(r)
-	config := a.config
 	if err != nil {
 		return err
 	}
@@ -176,6 +174,18 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 		}
 	}
 
+	if params.Password != nil {
+		password := *params.Password
+
+		if err := a.checkPasswordStrength(ctx, password); err != nil {
+			return err
+		}
+
+		if err := user.SetPassword(ctx, password); err != nil {
+			return err
+		}
+	}
+
 	err = db.Transaction(func(tx *storage.Connection) error {
 		if params.Role != "" {
 			if terr := user.SetRole(tx, params.Role); terr != nil {
@@ -196,11 +206,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 		}
 
 		if params.Password != nil {
-			if len(*params.Password) < config.PasswordMinLength {
-				return invalidPasswordLengthError(config.PasswordMinLength)
-			}
-
-			if terr := user.UpdatePassword(tx, *params.Password, nil); terr != nil {
+			if terr := user.UpdatePassword(tx, nil); terr != nil {
 				return terr
 			}
 		}
@@ -284,9 +290,6 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 	})
 
 	if err != nil {
-		if errors.Is(err, invalidPasswordLengthError(config.PasswordMinLength)) {
-			return err
-		}
 		return internalServerError("Error updating user").WithInternalError(err)
 	}
 
diff --git a/internal/api/api.go b/internal/api/api.go
index 6e04b38e..ea963e19 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -153,7 +153,11 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 			r.Get("/", api.UserGet)
 			r.With(sharedLimiter).Put("/", api.UserUpdate)
 
-			r.Delete("/identities/{identity_id}", api.DeleteIdentity)
+			r.Route("/identities", func(r *router) {
+				r.Use(api.requireManualLinkingEnabled)
+				r.Get("/authorize", api.LinkIdentity)
+				r.Delete("/{identity_id}", api.DeleteIdentity)
+			})
 		})
 
 		r.With(api.requireAuthentication).Route("/factors", func(r *router) {
diff --git a/internal/api/context.go b/internal/api/context.go
index 107377b5..2cd73309 100644
--- a/internal/api/context.go
+++ b/internal/api/context.go
@@ -21,6 +21,7 @@ const (
 	signatureKey            = contextKey("signature")
 	externalProviderTypeKey = contextKey("external_provider_type")
 	userKey                 = contextKey("user")
+	targetUserKey           = contextKey("target_user")
 	factorKey               = contextKey("factor")
 	sessionKey              = contextKey("session")
 	externalReferrerKey     = contextKey("external_referrer")
@@ -76,6 +77,11 @@ func withUser(ctx context.Context, u *models.User) context.Context {
 	return context.WithValue(ctx, userKey, u)
 }
 
+// withTargetUser adds the target user for linking to the context.
+func withTargetUser(ctx context.Context, u *models.User) context.Context {
+	return context.WithValue(ctx, targetUserKey, u)
+}
+
 // with Factor adds the factor id to the context.
 func withFactor(ctx context.Context, f *models.Factor) context.Context {
 	return context.WithValue(ctx, factorKey, f)
@@ -93,6 +99,18 @@ func getUser(ctx context.Context) *models.User {
 	return obj.(*models.User)
 }
 
+// getTargetUser reads the user from the context.
+func getTargetUser(ctx context.Context) *models.User {
+	if ctx == nil {
+		return nil
+	}
+	obj := ctx.Value(targetUserKey)
+	if obj == nil {
+		return nil
+	}
+	return obj.(*models.User)
+}
+
 // getFactor reads the factor id from the context
 func getFactor(ctx context.Context) *models.Factor {
 	obj := ctx.Value(factorKey)
diff --git a/internal/api/errors.go b/internal/api/errors.go
index 8efdbe81..bb7ba487 100644
--- a/internal/api/errors.go
+++ b/internal/api/errors.go
@@ -65,10 +65,6 @@ func (e *OAuthError) Cause() error {
 	return e
 }
 
-func invalidPasswordLengthError(passwordMinLength int) *HTTPError {
-	return unprocessableEntityError(fmt.Sprintf("Password should be at least %d characters", passwordMinLength))
-}
-
 func invalidSignupError(config *conf.GlobalConfiguration) *HTTPError {
 	var msg string
 	if config.External.Email.Enabled && config.External.Phone.Enabled {
@@ -245,6 +241,22 @@ func handleError(err error, w http.ResponseWriter, r *http.Request) {
 	log := observability.GetLogEntry(r)
 	errorID := getRequestID(r.Context())
 	switch e := err.(type) {
+	case *WeakPasswordError:
+		var output struct {
+			HTTPError
+			Payload struct {
+				Reasons []string `json:"reasons,omitempty"`
+			} `json:"weak_password,omitempty"`
+		}
+
+		output.Code = http.StatusUnprocessableEntity
+		output.Message = e.Message
+		output.Payload.Reasons = e.Reasons
+
+		if jsonErr := sendJSON(w, output.Code, output); jsonErr != nil {
+			handleError(jsonErr, w, r)
+		}
+
 	case *HTTPError:
 		if e.Code >= http.StatusInternalServerError {
 			e.ErrorID = errorID
diff --git a/internal/api/external.go b/internal/api/external.go
index 30ef2b46..9f8da6a5 100644
--- a/internal/api/external.go
+++ b/internal/api/external.go
@@ -25,14 +25,25 @@ import (
 // ExternalProviderClaims are the JWT claims sent as the state in the external oauth provider signup flow
 type ExternalProviderClaims struct {
 	AuthMicroserviceClaims
-	Provider    string `json:"provider"`
-	InviteToken string `json:"invite_token,omitempty"`
-	Referrer    string `json:"referrer,omitempty"`
-	FlowStateID string `json:"flow_state_id"`
+	Provider        string `json:"provider"`
+	InviteToken     string `json:"invite_token,omitempty"`
+	Referrer        string `json:"referrer,omitempty"`
+	FlowStateID     string `json:"flow_state_id"`
+	LinkingTargetID string `json:"linking_target_id,omitempty"`
 }
 
-// ExternalProviderRedirect redirects the request to the corresponding oauth provider
+// ExternalProviderRedirect redirects the request to the oauth provider
 func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) error {
+	rurl, err := a.GetExternalProviderRedirectURL(w, r, nil)
+	if err != nil {
+		return err
+	}
+	http.Redirect(w, r, rurl, http.StatusFound)
+	return nil
+}
+
+// GetExternalProviderRedirectURL returns the URL to start the oauth flow with the corresponding oauth provider
+func (a *API) GetExternalProviderRedirectURL(w http.ResponseWriter, r *http.Request, linkingTargetUser *models.User) (string, error) {
 	ctx := r.Context()
 	db := a.db.WithContext(ctx)
 	config := a.config
@@ -45,7 +56,7 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 
 	p, err := a.Provider(ctx, providerType, scopes)
 	if err != nil {
-		return badRequestError("Unsupported provider: %+v", err).WithInternalError(err)
+		return "", badRequestError("Unsupported provider: %+v", err).WithInternalError(err)
 	}
 
 	inviteToken := query.Get("invite_token")
@@ -53,9 +64,9 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 		_, userErr := models.FindUserByConfirmationToken(db, inviteToken)
 		if userErr != nil {
 			if models.IsNotFoundError(userErr) {
-				return notFoundError(userErr.Error())
+				return "", notFoundError(userErr.Error())
 			}
-			return internalServerError("Database error finding user").WithInternalError(userErr)
+			return "", internalServerError("Database error finding user").WithInternalError(userErr)
 		}
 	}
 
@@ -63,7 +74,7 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 	log := observability.GetLogEntry(r)
 	log.WithField("provider", providerType).Info("Redirecting to external provider")
 	if err := validatePKCEParams(codeChallengeMethod, codeChallenge); err != nil {
-		return err
+		return "", err
 	}
 	flowType := getFlowFromChallenge(codeChallenge)
 
@@ -71,19 +82,19 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 	if flowType == models.PKCEFlow {
 		codeChallengeMethodType, err := models.ParseCodeChallengeMethod(codeChallengeMethod)
 		if err != nil {
-			return err
+			return "", err
 		}
 		flowState, err := models.NewFlowState(providerType, codeChallenge, codeChallengeMethodType, models.OAuth)
 		if err != nil {
-			return err
+			return "", err
 		}
 		if err := a.db.Create(flowState); err != nil {
-			return err
+			return "", err
 		}
 		flowStateID = flowState.ID.String()
 	}
 
-	token := jwt.NewWithClaims(jwt.SigningMethodHS256, ExternalProviderClaims{
+	claims := ExternalProviderClaims{
 		AuthMicroserviceClaims: AuthMicroserviceClaims{
 			StandardClaims: jwt.StandardClaims{
 				ExpiresAt: time.Now().Add(5 * time.Minute).Unix(),
@@ -95,10 +106,17 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 		InviteToken: inviteToken,
 		Referrer:    redirectURL,
 		FlowStateID: flowStateID,
-	})
+	}
+
+	if linkingTargetUser != nil {
+		// this means that the user is performing manual linking
+		claims.LinkingTargetID = linkingTargetUser.ID.String()
+	}
+
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
 	tokenString, err := token.SignedString([]byte(config.JWT.Secret))
 	if err != nil {
-		return internalServerError("Error creating state").WithInternalError(err)
+		return "", internalServerError("Error creating state").WithInternalError(err)
 	}
 
 	authUrlParams := make([]oauth2.AuthCodeOption, 0)
@@ -115,20 +133,15 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 		}
 	}
 
-	var authURL string
+	authURL := p.AuthCodeURL(tokenString, authUrlParams...)
 	switch externalProvider := p.(type) {
 	case *provider.TwitterProvider:
-		authURL = externalProvider.AuthCodeURL(tokenString, authUrlParams...)
-		err := storage.StoreInSession(providerType, externalProvider.Marshal(), r, w)
-		if err != nil {
-			return internalServerError("Error storing request token in session").WithInternalError(err)
+		if err := storage.StoreInSession(providerType, externalProvider.Marshal(), r, w); err != nil {
+			return "", internalServerError("Error storing request token in session").WithInternalError(err)
 		}
-	default:
-		authURL = p.AuthCodeURL(tokenString, authUrlParams...)
 	}
 
-	http.Redirect(w, r, authURL, http.StatusFound)
-	return nil
+	return authURL, nil
 }
 
 // ExternalProviderCallback handles the callback endpoint in the external oauth provider flow
@@ -142,37 +155,41 @@ func (a *API) ExternalProviderCallback(w http.ResponseWriter, r *http.Request) e
 	return nil
 }
 
+func (a *API) handleOAuthCallback(w http.ResponseWriter, r *http.Request) (*OAuthProviderData, error) {
+	ctx := r.Context()
+	providerType := getExternalProviderType(ctx)
+
+	var oAuthResponseData *OAuthProviderData
+	var err error
+	switch providerType {
+	case "twitter":
+		// future OAuth1.0 providers will use this method
+		oAuthResponseData, err = a.oAuth1Callback(ctx, r, providerType)
+	default:
+		oAuthResponseData, err = a.oAuthCallback(ctx, r, providerType)
+	}
+	if err != nil {
+		return nil, err
+	}
+	return oAuthResponseData, nil
+}
+
 func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
 	db := a.db.WithContext(ctx)
 	config := a.config
 
-	providerType := getExternalProviderType(ctx)
-	var userData *provider.UserProvidedData
-	var providerAccessToken string
-	var providerRefreshToken string
 	var grantParams models.GrantParams
-	var err error
-
 	grantParams.FillGrantParams(r)
 
-	if providerType == "twitter" {
-		// future OAuth1.0 providers will use this method
-		oAuthResponseData, err := a.oAuth1Callback(ctx, r, providerType)
-		if err != nil {
-			return err
-		}
-		userData = oAuthResponseData.userData
-		providerAccessToken = oAuthResponseData.token
-	} else {
-		oAuthResponseData, err := a.oAuthCallback(ctx, r, providerType)
-		if err != nil {
-			return err
-		}
-		userData = oAuthResponseData.userData
-		providerAccessToken = oAuthResponseData.token
-		providerRefreshToken = oAuthResponseData.refreshToken
+	providerType := getExternalProviderType(ctx)
+	data, err := a.handleOAuthCallback(w, r)
+	if err != nil {
+		return err
 	}
+	userData := data.userData
+	providerAccessToken := data.token
+	providerRefreshToken := data.refreshToken
 
 	var flowState *models.FlowState
 	// if there's a non-empty FlowStateID we perform PKCE Flow
@@ -187,8 +204,11 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 	var token *AccessTokenResponse
 	err = db.Transaction(func(tx *storage.Connection) error {
 		var terr error
-		inviteToken := getInviteToken(ctx)
-		if inviteToken != "" {
+		if targetUser := getTargetUser(ctx); targetUser != nil {
+			if user, terr = a.linkIdentityToUser(ctx, tx, userData, providerType); terr != nil {
+				return terr
+			}
+		} else if inviteToken := getInviteToken(ctx); inviteToken != "" {
 			if user, terr = a.processInvite(r, ctx, tx, userData, inviteToken, providerType); terr != nil {
 				return terr
 			}
@@ -479,6 +499,20 @@ func (a *API) loadExternalState(ctx context.Context, state string) (context.Cont
 	if claims.FlowStateID != "" {
 		ctx = withFlowStateID(ctx, claims.FlowStateID)
 	}
+	if claims.LinkingTargetID != "" {
+		linkingTargetUserID, err := uuid.FromString(claims.LinkingTargetID)
+		if err != nil {
+			return nil, badRequestError("invalid target user id")
+		}
+		u, err := models.FindUserByID(a.db, linkingTargetUserID)
+		if err != nil {
+			if models.IsNotFoundError(err) {
+				return nil, notFoundError("Linking target user not found")
+			}
+			return nil, internalServerError("Database error loading user").WithInternalError(err)
+		}
+		ctx = withTargetUser(ctx, u)
+	}
 	ctx = withExternalProviderType(ctx, claims.Provider)
 	return withSignature(ctx, state), nil
 }
diff --git a/internal/api/identity.go b/internal/api/identity.go
index 769dec75..b3b69fae 100644
--- a/internal/api/identity.go
+++ b/internal/api/identity.go
@@ -1,10 +1,13 @@
 package api
 
 import (
+	"context"
 	"net/http"
 
+	"github.com/fatih/structs"
 	"github.com/go-chi/chi"
 	"github.com/gofrs/uuid"
+	"github.com/supabase/gotrue/internal/api/provider"
 	"github.com/supabase/gotrue/internal/models"
 	"github.com/supabase/gotrue/internal/storage"
 )
@@ -29,7 +32,7 @@ func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
 
 	user := getUser(ctx)
 	if len(user.Identities) <= 1 {
-		return badRequestError("Cannot unlink identity from user. User must have at least 1 identity after unlinking")
+		return badRequestError("User must have at least 1 identity after unlinking")
 	}
 	var identityToBeDeleted *models.Identity
 	for i := range user.Identities {
@@ -65,3 +68,43 @@ func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
 
 	return sendJSON(w, http.StatusOK, map[string]interface{}{})
 }
+
+func (a *API) LinkIdentity(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	user := getUser(ctx)
+	rurl, err := a.GetExternalProviderRedirectURL(w, r, user)
+	if err != nil {
+		return err
+	}
+	skipHTTPRedirect := r.URL.Query().Get("skip_http_redirect") == "true"
+	if skipHTTPRedirect {
+		return sendJSON(w, http.StatusOK, map[string]interface{}{
+			"url": rurl,
+		})
+	}
+	http.Redirect(w, r, rurl, http.StatusFound)
+	return nil
+}
+
+func (a *API) linkIdentityToUser(ctx context.Context, tx *storage.Connection, userData *provider.UserProvidedData, providerType string) (*models.User, error) {
+	targetUser := getTargetUser(ctx)
+	identity, terr := models.FindIdentityByIdAndProvider(tx, userData.Metadata.Subject, providerType)
+	if terr != nil {
+		if !models.IsNotFoundError(terr) {
+			return nil, internalServerError("Database error finding identity for linking").WithInternalError(terr)
+		}
+	}
+	if identity != nil {
+		if identity.UserID == targetUser.ID {
+			return nil, badRequestError("Identity is already linked")
+		}
+		return nil, badRequestError("Identity is already linked to another user")
+	}
+	if _, terr := a.createNewIdentity(tx, targetUser, providerType, structs.Map(userData.Metadata)); terr != nil {
+		return nil, terr
+	}
+	if terr := targetUser.UpdateAppMetaDataProviders(tx); terr != nil {
+		return nil, terr
+	}
+	return targetUser, nil
+}
diff --git a/internal/api/mail.go b/internal/api/mail.go
index d2de1068..786496d5 100644
--- a/internal/api/mail.go
+++ b/internal/api/mail.go
@@ -97,12 +97,6 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error {
 
 	var signupUser *models.User
 	if params.Type == signupVerification && user == nil {
-		if params.Password == "" {
-			return unprocessableEntityError("Signup requires a valid password")
-		}
-		if len(params.Password) < config.PasswordMinLength {
-			return invalidPasswordLengthError(config.PasswordMinLength)
-		}
 		signupParams := &SignupParams{
 			Email:    params.Email,
 			Password: params.Password,
@@ -111,6 +105,10 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error {
 			Aud:      aud,
 		}
 
+		if err := a.validateSignupParams(ctx, signupParams); err != nil {
+			return err
+		}
+
 		signupUser, err = signupParams.ToUserModel(false /* <- isSSOUser */)
 		if err != nil {
 			return err
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
index 0ccb51a5..d529b89e 100644
--- a/internal/api/middleware.go
+++ b/internal/api/middleware.go
@@ -233,6 +233,14 @@ func (a *API) requireSAMLEnabled(w http.ResponseWriter, req *http.Request) (cont
 	return ctx, nil
 }
 
+func (a *API) requireManualLinkingEnabled(w http.ResponseWriter, req *http.Request) (context.Context, error) {
+	ctx := req.Context()
+	if !a.config.Security.ManualLinkingEnabled {
+		return nil, notFoundError("Manual linking is disabled")
+	}
+	return ctx, nil
+}
+
 func (a *API) databaseCleanup(cleanup *models.Cleanup) func(http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/api/password.go b/internal/api/password.go
new file mode 100644
index 00000000..a614d67b
--- /dev/null
+++ b/internal/api/password.go
@@ -0,0 +1,50 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"strings"
+)
+
+// WeakPasswordError encodes an error that a password does not meet strength
+// requirements. It is handled specially in errors.go as it gets transformed to
+// a HTTPError with a special weak_password field that encodes the Reasons
+// slice.
+type WeakPasswordError struct {
+	Message string
+	Reasons []string
+}
+
+func (e *WeakPasswordError) Error() string {
+	return e.Message
+}
+
+func (a *API) checkPasswordStrength(ctx context.Context, password string) error {
+	config := a.config
+
+	var messages, reasons []string
+
+	if len(password) < config.Password.MinLength {
+		reasons = append(reasons, "length")
+		messages = append(messages, fmt.Sprintf("Password should be at least %d characters.", config.Password.MinLength))
+	}
+
+	for _, characterSet := range config.Password.RequiredCharacters {
+		if characterSet != "" && !strings.ContainsAny(password, characterSet) {
+			reasons = append(reasons, "characters")
+
+			messages = append(messages, fmt.Sprintf("Password should contain at least one character of each: %s.", strings.Join(config.Password.RequiredCharacters, ", ")))
+
+			break
+		}
+	}
+
+	if len(reasons) > 0 {
+		return &WeakPasswordError{
+			Message: strings.Join(messages, " "),
+			Reasons: reasons,
+		}
+	}
+
+	return nil
+}
diff --git a/internal/api/signup.go b/internal/api/signup.go
index f392f600..ff403010 100644
--- a/internal/api/signup.go
+++ b/internal/api/signup.go
@@ -31,17 +31,20 @@ type SignupParams struct {
 	CodeChallenge       string                 `json:"code_challenge"`
 }
 
-func (p *SignupParams) Validate(passwordMinLength int, smsProvider string) error {
+func (a *API) validateSignupParams(ctx context.Context, p *SignupParams) error {
+	config := a.config
+
 	if p.Password == "" {
 		return unprocessableEntityError("Signup requires a valid password")
 	}
-	if len(p.Password) < passwordMinLength {
-		return invalidPasswordLengthError(passwordMinLength)
+
+	if err := a.checkPasswordStrength(ctx, p.Password); err != nil {
+		return err
 	}
 	if p.Email != "" && p.Phone != "" {
 		return unprocessableEntityError("Only an email address or phone number should be provided on signup.")
 	}
-	if p.Provider == "phone" && !sms_provider.IsValidMessageChannel(p.Channel, smsProvider) {
+	if p.Provider == "phone" && !sms_provider.IsValidMessageChannel(p.Channel, config.Sms.Provider) {
 		return badRequestError(InvalidChannelError)
 	}
 	// PKCE not needed as phone signups already return access token in body
@@ -123,8 +126,10 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read Signup params: %v", err)
 	}
+
 	params.ConfigureDefaults()
-	if err := params.Validate(config.PasswordMinLength, config.Sms.Provider); err != nil {
+
+	if err := a.validateSignupParams(ctx, params); err != nil {
 		return err
 	}
 
diff --git a/internal/api/token.go b/internal/api/token.go
index 30f82c37..f4d5584d 100644
--- a/internal/api/token.go
+++ b/internal/api/token.go
@@ -140,7 +140,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri
 		return internalServerError("Database error querying schema").WithInternalError(err)
 	}
 
-	if user.IsBanned() || !user.Authenticate(params.Password) {
+	if user.IsBanned() || !user.Authenticate(ctx, params.Password) {
 		return oauthError("invalid_grant", InvalidLoginMessage)
 	}
 
diff --git a/internal/api/user.go b/internal/api/user.go
index 78c7684c..70577396 100644
--- a/internal/api/user.go
+++ b/internal/api/user.go
@@ -1,6 +1,7 @@
 package api
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"net/http"
@@ -10,9 +11,7 @@ import (
 	"github.com/gofrs/uuid"
 	"github.com/supabase/gotrue/internal/api/provider"
 	"github.com/supabase/gotrue/internal/api/sms_provider"
-	"github.com/supabase/gotrue/internal/conf"
 	"github.com/supabase/gotrue/internal/models"
-	"github.com/supabase/gotrue/internal/observability"
 	"github.com/supabase/gotrue/internal/storage"
 	"github.com/supabase/gotrue/internal/utilities"
 )
@@ -30,58 +29,35 @@ type UserUpdateParams struct {
 	CodeChallengeMethod string                 `json:"code_challenge_method"`
 }
 
-func (p *UserUpdateParams) Validate(tx *storage.Connection, user *models.User, aud string, config *conf.GlobalConfiguration) error {
+func (a *API) validateUserUpdateParams(ctx context.Context, p *UserUpdateParams) error {
+	config := a.config
+
 	var err error
-	if p.Email != "" && p.Email != user.GetEmail() {
+	if p.Email != "" {
 		p.Email, err = validateEmail(p.Email)
 		if err != nil {
 			return err
 		}
-		if duplicateUser, err := models.IsDuplicatedEmail(tx, p.Email, aud, user); err != nil {
-			return internalServerError("Database error checking email").WithInternalError(err)
-		} else if duplicateUser != nil {
-			return unprocessableEntityError(DuplicateEmailMsg)
-		}
 	}
+
 	if p.Phone != "" {
+		if p.Phone, err = validatePhone(p.Phone); err != nil {
+			return err
+		}
 		if p.Channel == "" {
 			p.Channel = sms_provider.SMSProvider
 		}
 		if !sms_provider.IsValidMessageChannel(p.Channel, config.Sms.Provider) {
 			return badRequestError(InvalidChannelError)
 		}
-		if p.Phone != user.GetPhone() {
-			if p.Phone, err = validatePhone(p.Phone); err != nil {
-				return err
-			}
-			if exists, err := models.IsDuplicatedPhone(tx, p.Phone, aud); err != nil {
-				return internalServerError("Database error checking phone").WithInternalError(err)
-			} else if exists {
-				return unprocessableEntityError(DuplicatePhoneMsg)
-			}
-		}
-	}
-	if user.IsSSOUser {
-		if (p.Password != nil && *p.Password != "") || p.Email != "" || p.Phone != "" || p.Nonce != "" {
-			return unprocessableEntityError("Updating email, phone, password of a SSO account only possible via SSO")
-		}
 	}
-	if p.Password != nil {
-		password := *p.Password
-
-		if len(password) < config.PasswordMinLength {
-			return invalidPasswordLengthError(config.PasswordMinLength)
-		}
 
-		if user.EncryptedPassword != "" && user.Authenticate(password) {
-			return unprocessableEntityError("New password should be different from the old password.")
-		}
-	}
-	if p.AppData != nil {
-		if !isAdmin(user, config) {
-			return unauthorizedError("Updating app_metadata requires admin privileges")
+	if p.Password != nil {
+		if err := a.checkPasswordStrength(ctx, *p.Password); err != nil {
+			return err
 		}
 	}
+
 	return nil
 }
 
@@ -122,37 +98,84 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 
 	user := getUser(ctx)
 	session := getSession(ctx)
-	log := observability.GetLogEntry(r)
-	log.Debugf("Checking params for token %v", params)
 
-	if err := params.Validate(a.db, user, aud, config); err != nil {
+	if err := a.validateUserUpdateParams(ctx, params); err != nil {
 		return err
 	}
 
-	err = db.Transaction(func(tx *storage.Connection) error {
-		var terr error
-		if params.Password != nil {
-			if config.Security.UpdatePasswordRequireReauthentication {
-				now := time.Now()
-				// we require reauthentication if the user hasn't signed in recently in the current session
-				if session == nil || now.After(session.CreatedAt.Add(24*time.Hour)) {
-					if len(params.Nonce) == 0 {
-						return badRequestError("Password update requires reauthentication")
-					}
-					if terr = a.verifyReauthentication(params.Nonce, tx, config, user); terr != nil {
-						return terr
-					}
+	if params.AppData != nil && !isAdmin(user, config) {
+		if !isAdmin(user, config) {
+			return unauthorizedError("Updating app_metadata requires admin privileges")
+		}
+	}
+
+	if user.IsSSOUser {
+		updatingForbiddenFields := false
+
+		updatingForbiddenFields = updatingForbiddenFields || (params.Password != nil && *params.Password != "")
+		updatingForbiddenFields = updatingForbiddenFields || (params.Email != "" && params.Email != user.GetEmail())
+		updatingForbiddenFields = updatingForbiddenFields || (params.Phone != "" && params.Phone != user.GetPhone())
+		updatingForbiddenFields = updatingForbiddenFields || (params.Nonce != "")
+
+		if updatingForbiddenFields {
+			return unprocessableEntityError("Updating email, phone, password of a SSO account only possible via SSO")
+		}
+	}
+
+	if params.Email != "" && user.GetEmail() != params.Email {
+		if duplicateUser, err := models.IsDuplicatedEmail(db, params.Email, aud, user); err != nil {
+			return internalServerError("Database error checking email").WithInternalError(err)
+		} else if duplicateUser != nil {
+			return unprocessableEntityError(DuplicateEmailMsg)
+		}
+	}
+
+	if params.Phone != "" && user.GetPhone() != params.Phone {
+		if exists, err := models.IsDuplicatedPhone(db, params.Phone, aud); err != nil {
+			return internalServerError("Database error checking phone").WithInternalError(err)
+		} else if exists {
+			return unprocessableEntityError(DuplicatePhoneMsg)
+		}
+	}
+
+	if params.Password != nil {
+		if config.Security.UpdatePasswordRequireReauthentication {
+			now := time.Now()
+			// we require reauthentication if the user hasn't signed in recently in the current session
+			if session == nil || now.After(session.CreatedAt.Add(24*time.Hour)) {
+				if len(params.Nonce) == 0 {
+					return badRequestError("Password update requires reauthentication")
 				}
+				if err := a.verifyReauthentication(params.Nonce, db, config, user); err != nil {
+					return err
+				}
+			}
+		}
+
+		password := *params.Password
+		if password != "" {
+			if user.EncryptedPassword != "" && user.Authenticate(ctx, password) {
+				return unprocessableEntityError("New password should be different from the old password.")
 			}
+		}
 
+		if err := user.SetPassword(ctx, password); err != nil {
+			return err
+		}
+	}
+
+	err = db.Transaction(func(tx *storage.Connection) error {
+		var terr error
+		if params.Password != nil {
 			var sessionID *uuid.UUID
 			if session != nil {
 				sessionID = &session.ID
 			}
 
-			if terr = user.UpdatePassword(tx, *params.Password, sessionID); terr != nil {
+			if terr = user.UpdatePassword(tx, sessionID); terr != nil {
 				return internalServerError("Error during password storage").WithInternalError(terr)
 			}
+
 			if terr := models.NewAuditLogEntry(r, tx, user, models.UserUpdatePasswordAction, "", nil); terr != nil {
 				return terr
 			}
diff --git a/internal/api/verify.go b/internal/api/verify.go
index 8afe6293..ae0b26ae 100644
--- a/internal/api/verify.go
+++ b/internal/api/verify.go
@@ -291,19 +291,24 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP
 func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) {
 	config := a.config
 
+	if user.EncryptedPassword == "" && user.InvitedAt != nil {
+		// sign them up with temporary password, and require application
+		// to present the user with a password set form
+		password, err := password.Generate(64, 10, 0, false, true)
+		if err != nil {
+			return nil, err
+		}
+
+		if err := user.SetPassword(ctx, password); err != nil {
+			return nil, err
+		}
+	}
+
 	err := conn.Transaction(func(tx *storage.Connection) error {
 		var terr error
-		if user.EncryptedPassword == "" {
-			if user.InvitedAt != nil {
-				// sign them up with temporary password, and require application
-				// to present the user with a password set form
-				password, err := password.Generate(64, 10, 0, false, true)
-				if err != nil {
-					internalServerError("error creating user").WithInternalError(err)
-				}
-				if terr = user.UpdatePassword(tx, password, nil); terr != nil {
-					return internalServerError("Error storing password").WithInternalError(terr)
-				}
+		if user.EncryptedPassword == "" && user.InvitedAt != nil {
+			if terr = user.UpdatePassword(tx, nil); terr != nil {
+				return internalServerError("Error storing password").WithInternalError(terr)
 			}
 		}
 
diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go
index a3778a5c..0e76455f 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -131,6 +131,41 @@ func (c *SessionsConfiguration) Validate() error {
 	return nil
 }
 
+type PasswordRequiredCharacters []string
+
+func (v *PasswordRequiredCharacters) Decode(value string) error {
+	parts := strings.Split(value, ":")
+
+	for i := 0; i < len(parts)-1; i += 1 {
+		part := parts[i]
+
+		if part == "" {
+			continue
+		}
+
+		// part ended in escape character, so it should be joined with the next one
+		if part[len(part)-1] == '\\' {
+			parts[i] = part[0:len(part)-1] + ":" + parts[i+1]
+			parts[i+1] = ""
+			continue
+		}
+	}
+
+	for _, part := range parts {
+		if part != "" {
+			*v = append(*v, part)
+		}
+	}
+
+	return nil
+}
+
+type PasswordConfiguration struct {
+	MinLength int `json:"min_length" split_words:"true"`
+
+	RequiredCharacters PasswordRequiredCharacters `json:"required_characters" split_words:"true"`
+}
+
 // GlobalConfiguration holds all the configuration that applies to all instances.
 type GlobalConfiguration struct {
 	API                   APIConfiguration
@@ -149,19 +184,19 @@ type GlobalConfiguration struct {
 	RateLimitTokenRefresh float64 `split_words:"true" default:"150"`
 	RateLimitSso          float64 `split_words:"true" default:"30"`
 
-	SiteURL           string   `json:"site_url" split_words:"true" required:"true"`
-	URIAllowList      []string `json:"uri_allow_list" split_words:"true"`
-	URIAllowListMap   map[string]glob.Glob
-	PasswordMinLength int                      `json:"password_min_length" split_words:"true"`
-	JWT               JWTConfiguration         `json:"jwt"`
-	Mailer            MailerConfiguration      `json:"mailer"`
-	Sms               SmsProviderConfiguration `json:"sms"`
-	DisableSignup     bool                     `json:"disable_signup" split_words:"true"`
-	Webhook           WebhookConfig            `json:"webhook" split_words:"true"`
-	Security          SecurityConfiguration    `json:"security"`
-	Sessions          SessionsConfiguration    `json:"sessions"`
-	MFA               MFAConfiguration         `json:"MFA"`
-	Cookie            struct {
+	SiteURL         string   `json:"site_url" split_words:"true" required:"true"`
+	URIAllowList    []string `json:"uri_allow_list" split_words:"true"`
+	URIAllowListMap map[string]glob.Glob
+	Password        PasswordConfiguration    `json:"password"`
+	JWT             JWTConfiguration         `json:"jwt"`
+	Mailer          MailerConfiguration      `json:"mailer"`
+	Sms             SmsProviderConfiguration `json:"sms"`
+	DisableSignup   bool                     `json:"disable_signup" split_words:"true"`
+	Webhook         WebhookConfig            `json:"webhook" split_words:"true"`
+	Security        SecurityConfiguration    `json:"security"`
+	Sessions        SessionsConfiguration    `json:"sessions"`
+	MFA             MFAConfiguration         `json:"MFA"`
+	Cookie          struct {
 		Key      string `json:"key"`
 		Domain   string `json:"domain"`
 		Duration int    `json:"duration"`
@@ -351,6 +386,7 @@ type SecurityConfiguration struct {
 	RefreshTokenRotationEnabled           bool                 `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
 	RefreshTokenReuseInterval             int                  `json:"refresh_token_reuse_interval" split_words:"true"`
 	UpdatePasswordRequireReauthentication bool                 `json:"update_password_require_reauthentication" split_words:"true"`
+	ManualLinkingEnabled                  bool                 `json:"manual_linking_enabled" split_words:"true" default:"false"`
 }
 
 func (c *SecurityConfiguration) Validate() error {
@@ -516,8 +552,8 @@ func (config *GlobalConfiguration) ApplyDefaults() error {
 		}
 	}
 
-	if config.PasswordMinLength < defaultMinPasswordLength {
-		config.PasswordMinLength = defaultMinPasswordLength
+	if config.Password.MinLength < defaultMinPasswordLength {
+		config.Password.MinLength = defaultMinPasswordLength
 	}
 	if config.MFA.ChallengeExpiryDuration < defaultChallengeExpiryDuration {
 		config.MFA.ChallengeExpiryDuration = defaultChallengeExpiryDuration
diff --git a/internal/models/user.go b/internal/models/user.go
index 1b59ed95..d52a7047 100644
--- a/internal/models/user.go
+++ b/internal/models/user.go
@@ -239,13 +239,24 @@ func (u *User) SetPhone(tx *storage.Connection, phone string) error {
 	return tx.UpdateOnly(u, "phone")
 }
 
-// UpdatePassword updates the user's password
-func (u *User) UpdatePassword(tx *storage.Connection, password string, sessionID *uuid.UUID) error {
-	pw, err := crypto.GenerateFromPassword(context.Background(), password)
+func (u *User) SetPassword(ctx context.Context, password string) error {
+	if password == "" {
+		u.EncryptedPassword = ""
+		return nil
+	}
+
+	pw, err := crypto.GenerateFromPassword(ctx, password)
 	if err != nil {
 		return err
 	}
+
 	u.EncryptedPassword = pw
+
+	return nil
+}
+
+// UpdatePassword updates the user's password. Use SetPassword outside of a transaction first!
+func (u *User) UpdatePassword(tx *storage.Connection, sessionID *uuid.UUID) error {
 	if err := tx.UpdateOnly(u, "encrypted_password"); err != nil {
 		return err
 	}
@@ -266,8 +277,8 @@ func (u *User) UpdatePhone(tx *storage.Connection, phone string) error {
 }
 
 // Authenticate a user from a password
-func (u *User) Authenticate(password string) bool {
-	err := crypto.CompareHashAndPassword(context.Background(), u.EncryptedPassword, password)
+func (u *User) Authenticate(ctx context.Context, password string) bool {
+	err := crypto.CompareHashAndPassword(ctx, u.EncryptedPassword, password)
 	return err == nil
 }
 


Test patch:
diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go
index fb7f67a3..f886ca88 100644
--- a/internal/api/admin_test.go
+++ b/internal/api/admin_test.go
@@ -350,7 +350,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
 				expectedPassword = fmt.Sprintf("%v", c.params["password"])
 			}
 
-			assert.Equal(ts.T(), c.expected["isAuthenticated"], u.Authenticate(expectedPassword))
+			assert.Equal(ts.T(), c.expected["isAuthenticated"], u.Authenticate(context.Background(), expectedPassword))
 
 			// remove created user after each case
 			require.NoError(ts.T(), ts.API.db.Destroy(u))
@@ -456,7 +456,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdatePasswordFailed() {
 	require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
 
 	var updateEndpoint = fmt.Sprintf("/admin/users/%s", u.ID)
-	ts.Config.PasswordMinLength = 6
+	ts.Config.Password.MinLength = 6
 	ts.Run("Password doesn't meet minimum length", func() {
 		var buffer bytes.Buffer
 		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
@@ -479,7 +479,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdateBannedUntilFailed() {
 	require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
 
 	var updateEndpoint = fmt.Sprintf("/admin/users/%s", u.ID)
-	ts.Config.PasswordMinLength = 6
+	ts.Config.Password.MinLength = 6
 	ts.Run("Incorrect format for ban_duration", func() {
 		var buffer bytes.Buffer
 		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
diff --git a/internal/api/identity_test.go b/internal/api/identity_test.go
new file mode 100644
index 00000000..a14b770a
--- /dev/null
+++ b/internal/api/identity_test.go
@@ -0,0 +1,77 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+	"github.com/supabase/gotrue/internal/api/provider"
+	"github.com/supabase/gotrue/internal/conf"
+	"github.com/supabase/gotrue/internal/models"
+)
+
+type IdentityTestSuite struct {
+	suite.Suite
+	API    *API
+	Config *conf.GlobalConfiguration
+}
+
+func TestIdentity(t *testing.T) {
+	api, config, err := setupAPIForTest()
+	require.NoError(t, err)
+	ts := &IdentityTestSuite{
+		API:    api,
+		Config: config,
+	}
+	defer api.db.Close()
+	suite.Run(t, ts)
+}
+
+func (ts *IdentityTestSuite) SetupTest() {
+	models.TruncateAll(ts.API.db)
+
+	// Create user
+	u, err := models.NewUser("", "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": u.GetEmail(),
+	})
+	require.NoError(ts.T(), err)
+	require.NoError(ts.T(), ts.API.db.Create(i))
+}
+
+func (ts *IdentityTestSuite) TestLinkIdentityToUser() {
+	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	ctx := withTargetUser(context.Background(), u)
+
+	// link a valid identity
+	testValidUserData := &provider.UserProvidedData{
+		Metadata: &provider.Claims{
+			Subject: "test_subject",
+		},
+	}
+	u, err = ts.API.linkIdentityToUser(ctx, ts.API.db, testValidUserData, "test")
+	require.NoError(ts.T(), err)
+
+	// load associated identities for the user
+	ts.API.db.Load(u, "Identities")
+	require.Len(ts.T(), u.Identities, 2)
+	require.Equal(ts.T(), u.AppMetaData["provider"], "email")
+	require.Equal(ts.T(), u.AppMetaData["providers"], []string{"email", "test"})
+
+	// link an already existing identity
+	testExistingUserData := &provider.UserProvidedData{
+		Metadata: &provider.Claims{
+			Subject: u.ID.String(),
+		},
+	}
+	u, err = ts.API.linkIdentityToUser(ctx, ts.API.db, testExistingUserData, "email")
+	require.ErrorIs(ts.T(), err, badRequestError("Identity is already linked"))
+	require.Nil(ts.T(), u)
+}
diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go
index e3686c9a..8d4d6ef7 100644
--- a/internal/api/mfa_test.go
+++ b/internal/api/mfa_test.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"github.com/gofrs/uuid"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -25,11 +26,14 @@ import (
 
 type MFATestSuite struct {
 	suite.Suite
-	API        *API
-	Config     *conf.GlobalConfiguration
-	TestDomain string
-	TestEmail  string
-	TestOTPKey *otp.Key
+	API          *API
+	Config       *conf.GlobalConfiguration
+	TestDomain   string
+	TestEmail    string
+	TestOTPKey   *otp.Key
+	TestPassword string
+	TestUser     *models.User
+	TestSession  *models.Session
 }
 
 func TestMFA(t *testing.T) {
@@ -53,13 +57,16 @@ func (ts *MFATestSuite) SetupTest() {
 	f, err := models.NewFactor(u, "test_factor", models.TOTP, models.FactorStateUnverified, "secretkey")
 	require.NoError(ts.T(), err, "Error creating test factor model")
 	require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
-	// Create corresponding sessoin
+	// Create corresponding session
 	s, err := models.NewSession()
 	require.NoError(ts.T(), err, "Error creating test session")
 	s.UserID = u.ID
 	s.FactorID = &f.ID
 	require.NoError(ts.T(), ts.API.db.Create(s), "Error saving test session")
 
+	ts.TestUser = u
+	ts.TestSession = s
+
 	// Generate TOTP related settings
 	emailValue, err := u.Email.Value()
 	require.NoError(ts.T(), err)
@@ -67,6 +74,7 @@ func (ts *MFATestSuite) SetupTest() {
 	testDomain := strings.Split(testEmail, "@")[1]
 	ts.TestDomain = testDomain
 	ts.TestEmail = testEmail
+	ts.TestPassword = "password"
 
 	key, err := totp.Generate(totp.GenerateOpts{
 		Issuer:      ts.TestDomain,
@@ -80,6 +88,12 @@ func (ts *MFATestSuite) SetupTest() {
 func (ts *MFATestSuite) TestEnrollFactor() {
 	testFriendlyName := "bob"
 	alternativeFriendlyName := "john"
+	user, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
+	ts.Require().NoError(err)
+
+	token, _, err := generateAccessToken(ts.API.db, user, nil, &ts.Config.JWT)
+
+	require.NoError(ts.T(), err)
 	var cases = []struct {
 		desc         string
 		friendlyName string
@@ -119,20 +133,8 @@ func (ts *MFATestSuite) TestEnrollFactor() {
 	}
 	for _, c := range cases {
 		ts.Run(c.desc, func() {
-			var buffer bytes.Buffer
-			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"friendly_name": c.friendlyName, "factor_type": c.factorType, "issuer": c.issuer}))
-			user, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-			ts.Require().NoError(err)
 
-			token, _, err := generateAccessToken(ts.API.db, user, nil, &ts.Config.JWT)
-			require.NoError(ts.T(), err)
-
-			w := httptest.NewRecorder()
-			req := httptest.NewRequest(http.MethodPost, "/factors", &buffer)
-			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
-			req.Header.Set("Content-Type", "application/json")
-			ts.API.handler.ServeHTTP(w, req)
-			require.Equal(ts.T(), c.expectedCode, w.Code)
+			w := performEnrollFlow(ts, token, c.friendlyName, c.factorType, c.issuer, c.expectedCode)
 
 			factors, err := models.FindFactorsByUser(ts.API.db, user)
 			ts.Require().NoError(err)
@@ -165,17 +167,13 @@ func (ts *MFATestSuite) TestChallengeFactor() {
 	token, _, err := generateAccessToken(ts.API.db, u, nil, &ts.Config.JWT)
 	require.NoError(ts.T(), err, "Error generating access token")
 
-	var buffer bytes.Buffer
-	req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", f.ID), &buffer)
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
-
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
+	w := performChallengeFlow(ts, f.ID, token)
 	require.Equal(ts.T(), http.StatusOK, w.Code)
 }
 
 func (ts *MFATestSuite) TestMFAVerifyFactor() {
+	user, err := models.FindUserByEmailAndAudience(ts.API.db, ts.TestEmail, ts.Config.JWT.Aud)
+	ts.Require().NoError(err)
 	cases := []struct {
 		desc             string
 		validChallenge   bool
@@ -204,8 +202,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() {
 	for _, v := range cases {
 		ts.Run(v.desc, func() {
 			// Authenticate users and set secret
-			user, err := models.FindUserByEmailAndAudience(ts.API.db, ts.TestEmail, ts.Config.JWT.Aud)
-			ts.Require().NoError(err)
+
 			var buffer bytes.Buffer
 			r, err := models.GrantAuthenticatedUser(ts.API.db, user, models.GrantParams{})
 			require.NoError(ts.T(), err)
@@ -272,6 +269,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() {
 }
 
 func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
+
 	cases := []struct {
 		desc             string
 		isAAL2           bool
@@ -289,25 +287,20 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 		},
 	}
 	for _, v := range cases {
-
 		ts.Run(v.desc, func() {
 			// Create User
-			u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-			require.NoError(ts.T(), err)
-			s, err := models.FindSessionByUserID(ts.API.db, u.ID)
-			require.NoError(ts.T(), err)
 			if v.isAAL2 {
-				s.UpdateAssociatedAAL(ts.API.db, models.AAL2.String())
+				ts.TestSession.UpdateAssociatedAAL(ts.API.db, models.AAL2.String())
 			}
 			var secondarySession *models.Session
 
 			// Create Session to test behaviour which downgrades other sessions
-			factors, err := models.FindFactorsByUser(ts.API.db, u)
+			factors, err := models.FindFactorsByUser(ts.API.db, ts.TestUser)
 			require.NoError(ts.T(), err, "error finding factors")
 			f := factors[0]
 			secondarySession, err = models.NewSession()
 			require.NoError(ts.T(), err, "Error creating test session")
-			secondarySession.UserID = u.ID
+			secondarySession.UserID = ts.TestUser.ID
 			secondarySession.FactorID = &f.ID
 			require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")
 
@@ -319,7 +312,7 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 
 			var buffer bytes.Buffer
 
-			token, _, err := generateAccessToken(ts.API.db, u, &s.ID, &ts.Config.JWT)
+			token, _, err := generateAccessToken(ts.API.db, ts.TestUser, &ts.TestSession.ID, &ts.Config.JWT)
 			require.NoError(ts.T(), err)
 
 			w := httptest.NewRecorder()
@@ -342,17 +335,13 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 }
 
 func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
-	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-	require.NoError(ts.T(), err)
-	s, err := models.FindSessionByUserID(ts.API.db, u.ID)
-	require.NoError(ts.T(), err)
 	var secondarySession *models.Session
-	factors, err := models.FindFactorsByUser(ts.API.db, u)
+	factors, err := models.FindFactorsByUser(ts.API.db, ts.TestUser)
 	require.NoError(ts.T(), err, "error finding factors")
 	f := factors[0]
 	secondarySession, err = models.NewSession()
 	require.NoError(ts.T(), err, "Error creating test session")
-	secondarySession.UserID = u.ID
+	secondarySession.UserID = ts.TestUser.ID
 	secondarySession.FactorID = &f.ID
 	require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")
 
@@ -361,7 +350,7 @@ func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
 
 	var buffer bytes.Buffer
 
-	token, _, err := generateAccessToken(ts.API.db, u, &s.ID, &ts.Config.JWT)
+	token, _, err := generateAccessToken(ts.API.db, ts.TestUser, &ts.TestSession.ID, &ts.Config.JWT)
 	require.NoError(ts.T(), err)
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
 		"factor_id": f.ID,
@@ -382,14 +371,16 @@ func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
 
 // Integration Tests
 func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
-	email := "test1@example.com"
-	password := "test123"
-	token := signUpAndVerify(ts, email, password)
+	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword)
+	accessTokenResp := &AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
+
 	ts.Config.Security.RefreshTokenRotationEnabled = true
 	var buffer bytes.Buffer
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"refresh_token": token.RefreshToken,
+		"refresh_token": accessTokenResp.RefreshToken,
 	}))
+
 	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer)
 	req.Header.Set("Content-Type", "application/json")
 	w := httptest.NewRecorder()
@@ -408,14 +399,15 @@ func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
 
 // Performing MFA Verification followed by a sign in should return an AAL1 session and an AAL2 session
 func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
-	email := "test1@example.com"
-	password := "test123"
-	token := signUpAndVerify(ts, email, password)
+	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword)
+	accessTokenResp := &AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
+
 	ts.Config.Security.RefreshTokenRotationEnabled = true
 	var buffer bytes.Buffer
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":    email,
-		"password": password,
+		"email":    ts.TestEmail,
+		"password": ts.TestPassword,
 	}))
 	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer)
 	req.Header.Set("Content-Type", "application/json")
@@ -430,7 +422,7 @@ func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
 	ctx, err = ts.API.maybeLoadUserOrSession(ctx)
 	require.NoError(ts.T(), err)
 	require.Equal(ts.T(), models.AAL1.String(), getSession(ctx).GetAAL())
-	session, err := models.FindSessionByUserID(ts.API.db, token.User.ID)
+	session, err := models.FindSessionByUserID(ts.API.db, accessTokenResp.User.ID)
 	require.NoError(ts.T(), err)
 	require.True(ts.T(), session.IsAAL2())
 }
@@ -455,43 +447,30 @@ func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenRes
 	return data
 }
 
-func signUpAndVerify(ts *MFATestSuite, email, password string) (verifyResp *AccessTokenResponse) {
+func performTestSignupAndVerify(ts *MFATestSuite, email, password string) *httptest.ResponseRecorder {
 
 	signUpResp := signUp(ts, email, password)
-	verifyResp = enrollAndVerify(ts, signUpResp.User, signUpResp.Token)
+	resp := performEnrollAndVerify(ts, signUpResp.User, signUpResp.Token)
 
-	return verifyResp
+	return resp
 
 }
 
-func enrollAndVerify(ts *MFATestSuite, user *models.User, token string) (verifyResp *AccessTokenResponse) {
+func performEnrollFlow(ts *MFATestSuite, token, friendlyName, factorType, issuer string, expectedCode int) *httptest.ResponseRecorder {
 	var buffer bytes.Buffer
 	w := httptest.NewRecorder()
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"friendly_name": "john", "factor_type": models.TOTP, "issuer": ts.TestDomain}))
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"friendly_name": friendlyName, "factor_type": factorType, "issuer": issuer}))
 
 	req := httptest.NewRequest(http.MethodPost, "http://localhost/factors/", &buffer)
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 	req.Header.Set("Content-Type", "application/json")
 
 	ts.API.handler.ServeHTTP(w, req)
-	require.Equal(ts.T(), http.StatusOK, w.Code)
-	enrollResp := EnrollFactorResponse{}
-	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
-	factorID := enrollResp.ID
-
-	// Challenge
-	var challengeBuffer bytes.Buffer
-	x := httptest.NewRecorder()
-	req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), &challengeBuffer)
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
-	req.Header.Set("Content-Type", "application/json")
-	ts.API.handler.ServeHTTP(x, req)
-	require.Equal(ts.T(), http.StatusOK, x.Code)
-	challengeResp := EnrollFactorResponse{}
-	require.NoError(ts.T(), json.NewDecoder(x.Body).Decode(&challengeResp))
-	challengeID := challengeResp.ID
+	require.Equal(ts.T(), expectedCode, w.Code)
+	return w
 
-	// Verify
+}
+func performVerifyFlow(ts *MFATestSuite, challengeID, factorID uuid.UUID, token string, expectedCode int) *httptest.ResponseRecorder {
 	var verifyBuffer bytes.Buffer
 	y := httptest.NewRecorder()
 
@@ -511,13 +490,42 @@ func enrollAndVerify(ts *MFATestSuite, user *models.User, token string) (verifyR
 		"challenge_id": challengeID,
 		"code":         code,
 	}))
-	req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", factorID), &verifyBuffer)
+	req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", factorID), &verifyBuffer)
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 	req.Header.Set("Content-Type", "application/json")
 
 	ts.API.handler.ServeHTTP(y, req)
 	require.Equal(ts.T(), http.StatusOK, y.Code)
-	verifyResp = &AccessTokenResponse{}
-	require.NoError(ts.T(), json.NewDecoder(y.Body).Decode(&verifyResp))
-	return verifyResp
+	return y
+}
+
+func performChallengeFlow(ts *MFATestSuite, factorID uuid.UUID, token string) *httptest.ResponseRecorder {
+	var challengeBuffer bytes.Buffer
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), &challengeBuffer)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+	req.Header.Set("Content-Type", "application/json")
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+	return w
+
+}
+
+func performEnrollAndVerify(ts *MFATestSuite, user *models.User, token string) *httptest.ResponseRecorder {
+	w := performEnrollFlow(ts, token, "", models.TOTP, ts.TestDomain, http.StatusOK)
+	enrollResp := EnrollFactorResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
+	factorID := enrollResp.ID
+
+	// Challenge
+	w = performChallengeFlow(ts, factorID, token)
+
+	challengeResp := EnrollFactorResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp))
+	challengeID := challengeResp.ID
+
+	// Verify
+	y := performVerifyFlow(ts, challengeID, factorID, token, http.StatusOK)
+
+	return y
 }
diff --git a/internal/api/password_test.go b/internal/api/password_test.go
new file mode 100644
index 00000000..a35fe2d0
--- /dev/null
+++ b/internal/api/password_test.go
@@ -0,0 +1,107 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"github.com/supabase/gotrue/internal/conf"
+)
+
+func TestPasswordStrengthChecks(t *testing.T) {
+	examples := []struct {
+		MinLength          int
+		RequiredCharacters []string
+
+		Password string
+		Reasons  []string
+	}{
+		{
+			MinLength: 6,
+			Password:  "12345",
+			Reasons: []string{
+				"length",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "123",
+			Reasons: []string{
+				"length",
+				"characters",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "a123",
+			Reasons: []string{
+				"length",
+				"characters",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "ab123",
+			Reasons: []string{
+				"length",
+				"characters",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "c123",
+			Reasons: []string{
+				"length",
+				"characters",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "abc123",
+			Reasons:  nil,
+		},
+	}
+
+	for i, example := range examples {
+		api := &API{
+			config: &conf.GlobalConfiguration{
+				Password: conf.PasswordConfiguration{
+					MinLength:          example.MinLength,
+					RequiredCharacters: conf.PasswordRequiredCharacters(example.RequiredCharacters),
+				},
+			},
+		}
+
+		err := api.checkPasswordStrength(context.Background(), example.Password)
+		if example.Reasons == nil {
+			require.NoError(t, err, "Example %d failed with error", i)
+		} else {
+			require.Equal(t, err.(*WeakPasswordError).Reasons, example.Reasons, "Example %d failed with wrong reasons", i)
+		}
+	}
+}
diff --git a/internal/api/user_test.go b/internal/api/user_test.go
index 2297a128..43046523 100644
--- a/internal/api/user_test.go
+++ b/internal/api/user_test.go
@@ -2,6 +2,7 @@ package api
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -306,7 +307,7 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
 			u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
 			require.NoError(ts.T(), err)
 
-			require.Equal(ts.T(), c.expected.isAuthenticated, u.Authenticate(c.newPassword))
+			require.Equal(ts.T(), c.expected.isAuthenticated, u.Authenticate(context.Background(), c.newPassword))
 		})
 	}
 }
@@ -363,7 +364,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() {
 	u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
 
-	require.True(ts.T(), u.Authenticate("newpass"))
+	require.True(ts.T(), u.Authenticate(context.Background(), "newpass"))
 	require.Empty(ts.T(), u.ReauthenticationToken)
 	require.NotEmpty(ts.T(), u.ReauthenticationSentAt)
 }
diff --git a/internal/conf/configuration_test.go b/internal/conf/configuration_test.go
index 69317537..a9178085 100644
--- a/internal/conf/configuration_test.go
+++ b/internal/conf/configuration_test.go
@@ -26,3 +26,72 @@ func TestGlobal(t *testing.T) {
 	require.NotNil(t, gc)
 	assert.Equal(t, "X-Request-ID", gc.API.RequestIDHeader)
 }
+
+func TestPasswordRequiredCharactersDecode(t *testing.T) {
+	examples := []struct {
+		Value  string
+		Result []string
+	}{
+		{
+			Value: "a:b:c",
+			Result: []string{
+				"a",
+				"b",
+				"c",
+			},
+		},
+		{
+			Value: "a\\:b:c",
+			Result: []string{
+				"a:b",
+				"c",
+			},
+		},
+		{
+			Value: "a:b\\:c",
+			Result: []string{
+				"a",
+				"b:c",
+			},
+		},
+		{
+			Value: "\\:a:b:c",
+			Result: []string{
+				":a",
+				"b",
+				"c",
+			},
+		},
+		{
+			Value: "a:b:c\\:",
+			Result: []string{
+				"a",
+				"b",
+				"c:",
+			},
+		},
+		{
+			Value: "::\\::",
+			Result: []string{
+				":",
+			},
+		},
+		{
+			Value:  "",
+			Result: nil,
+		},
+		{
+			Value: " ",
+			Result: []string{
+				" ",
+			},
+		},
+	}
+
+	for i, example := range examples {
+		var into PasswordRequiredCharacters
+		require.NoError(t, into.Decode(example.Value), "Example %d failed with error", i)
+
+		require.Equal(t, []string(into), example.Result, "Example %d got unexpected result", i)
+	}
+}
diff --git a/internal/models/linking_test.go b/internal/models/linking_test.go
index d2e46944..5c68ac43 100644
--- a/internal/models/linking_test.go
+++ b/internal/models/linking_test.go
@@ -81,7 +81,7 @@ func (ts *AccountLinkingTestSuite) TestCreateAccountDecisionWithAccounts() {
 
 	// when the email doesn't exist in the system -- conventional provider
 	decision, err := DetermineAccountLinking(ts.db, ts.config, []provider.Email{
-		provider.Email{
+		{
 			Email:    "other@example.com",
 			Verified: true,
 			Primary:  true,
@@ -93,7 +93,7 @@ func (ts *AccountLinkingTestSuite) TestCreateAccountDecisionWithAccounts() {
 
 	// when looking for an email that doesn't exist in the SSO linking domain
 	decision, err = DetermineAccountLinking(ts.db, ts.config, []provider.Email{
-		provider.Email{
+		{
 			Email:    "other@samltest.id",
 			Verified: true,
 			Primary:  true,
@@ -116,7 +116,7 @@ func (ts *AccountLinkingTestSuite) TestAccountExists() {
 	require.NoError(ts.T(), ts.db.Create(identityA))
 
 	decision, err := DetermineAccountLinking(ts.db, ts.config, []provider.Email{
-		provider.Email{
+		{
 			Email:    "test@example.com",
 			Verified: true,
 			Primary:  true,
@@ -299,7 +299,7 @@ func (ts *AccountLinkingTestSuite) TestMultipleAccounts() {
 	// identities in the same "default" linking domain with the same email
 	// address pointing to two different user accounts
 	decision, err := DetermineAccountLinking(ts.db, ts.config, []provider.Email{
-		provider.Email{
+		{
 			Email:    "test@example.com",
 			Verified: true,
 			Primary:  true,