Repository: supabase/gotrue
Pull request #1016 (2023-04-03 13:43:08)

feat: PKCE magic link
## What kind of change does this PR introduce?

This PR adds PKCE to  magic link and sign up verification types. The remaining flows - `email_change` and `recovery` will come soon. The `invite` type is subject to discussion.


We introduce a new column `authentication_method` to assis with tracking of  `amr` claims when we exchange an Auth Code for a session and  trigger the generation of an access token.


As an overview, the user will now receive a token `pkce_<token>` in the email when making a `/otp` request to PKCE - this will create a flow state containing the relevant details of the flow state. 

The user will then call `/verify` using  this token and GoTrue will search for the latest flow state for a user and attach a user's ID to that state. 

TODO: 
- [x] Write integration test
- [x] manually test against test app



Patch:
diff --git a/internal/api/external.go b/internal/api/external.go
index fd9b1b61..70b591a9 100644
--- a/internal/api/external.go
+++ b/internal/api/external.go
@@ -86,7 +86,7 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 		if valid, err := isValidCodeChallenge(codeChallenge); !valid {
 			return err
 		}
-		flowState, err := models.NewFlowState(providerType, codeChallenge, codeChallengeMethod)
+		flowState, err := models.NewFlowState(providerType, codeChallenge, codeChallengeMethod, models.OAuth)
 		if err != nil {
 			return err
 		}
@@ -390,7 +390,7 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.
 		if !emailData.Verified && !config.Mailer.Autoconfirm {
 			mailer := a.Mailer(ctx)
 			referrer := a.getReferrer(r)
-			if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength); terr != nil {
+			if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, models.ImplicitFlow); terr != nil {
 				if errors.Is(terr, MaxFrequencyLimitError) {
 					return nil, tooManyRequestsError("For security purposes, you can only request this once every minute")
 				}
diff --git a/internal/api/magic_link.go b/internal/api/magic_link.go
index 405e558a..ac0b57fc 100644
--- a/internal/api/magic_link.go
+++ b/internal/api/magic_link.go
@@ -15,8 +15,10 @@ import (
 
 // MagicLinkParams holds the parameters for a magic link request
 type MagicLinkParams struct {
-	Email string                 `json:"email"`
-	Data  map[string]interface{} `json:"data"`
+	Email               string                 `json:"email"`
+	Data                map[string]interface{} `json:"data"`
+	CodeChallengeMethod string                 `json:"code_challenge_method"`
+	CodeChallenge       string                 `json:"code_challenge"`
 }
 
 func (p *MagicLinkParams) Validate() error {
@@ -28,7 +30,9 @@ func (p *MagicLinkParams) Validate() error {
 	if err != nil {
 		return err
 	}
-
+	if err := validatePKCEParams(p.CodeChallengeMethod, p.CodeChallenge); err != nil {
+		return err
+	}
 	return nil
 }
 
@@ -56,6 +60,9 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error {
 	if params.Data == nil {
 		params.Data = make(map[string]interface{})
 	}
+
+	flowType := getFlowFromChallenge(params.CodeChallenge)
+
 	var isNewUser bool
 	aud := a.requestAud(ctx, r)
 	user, err := models.FindUserByEmailAndAudience(db, params.Email, aud)
@@ -78,9 +85,11 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error {
 		}
 
 		signUpParams := &SignupParams{
-			Email:    params.Email,
-			Password: password,
-			Data:     params.Data,
+			Email:               params.Email,
+			Password:            password,
+			Data:                params.Data,
+			CodeChallengeMethod: params.CodeChallengeMethod,
+			CodeChallenge:       params.CodeChallenge,
 		}
 		newBodyContent, err := json.Marshal(signUpParams)
 		if err != nil {
@@ -96,8 +105,10 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error {
 				return err
 			}
 			newBodyContent := &SignupParams{
-				Email: params.Email,
-				Data:  params.Data,
+				Email:               params.Email,
+				Data:                params.Data,
+				CodeChallengeMethod: params.CodeChallengeMethod,
+				CodeChallenge:       params.CodeChallenge,
 			}
 			metadata, err := json.Marshal(newBodyContent)
 			if err != nil {
@@ -113,15 +124,35 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error {
 
 		return sendJSON(w, http.StatusOK, make(map[string]string))
 	}
+	var (
+		flowState           *models.FlowState
+		codeChallengeMethod models.CodeChallengeMethod
+	)
+	if isPKCEFlow(models.PKCEFlow) {
+		codeChallengeMethod, err = models.ParseCodeChallengeMethod(params.CodeChallengeMethod)
+		if err != nil {
+			return err
+		}
+
+	}
 
 	err = db.Transaction(func(tx *storage.Connection) error {
 		if terr := models.NewAuditLogEntry(r, tx, user, models.UserRecoveryRequestedAction, "", nil); terr != nil {
 			return terr
 		}
 
+		if isPKCEFlow(flowType) {
+			if flowState, err = models.NewFlowStateWithUserID(models.MagicLink.String(), params.CodeChallenge, codeChallengeMethod, models.MagicLink, &user.ID); err != nil {
+				return err
+			}
+			if err := tx.Create(flowState); err != nil {
+				return err
+			}
+		}
+
 		mailer := a.Mailer(ctx)
 		referrer := a.getReferrer(r)
-		return a.sendMagicLink(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength)
+		return a.sendMagicLink(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, flowType)
 	})
 	if err != nil {
 		if errors.Is(err, MaxFrequencyLimitError) {
diff --git a/internal/api/mail.go b/internal/api/mail.go
index f8981f42..babd5be0 100644
--- a/internal/api/mail.go
+++ b/internal/api/mail.go
@@ -228,7 +228,7 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 	return sendJSON(w, http.StatusOK, resp)
 }
 
-func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int) error {
+func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int, flowType models.FlowType) error {
 	var err error
 	if u.ConfirmationSentAt != nil && !u.ConfirmationSentAt.Add(maxFrequency).Before(time.Now()) {
 		return MaxFrequencyLimitError
@@ -238,7 +238,8 @@ func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mail
 	if err != nil {
 		return err
 	}
-	u.ConfirmationToken = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp)))
+	token := fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp)))
+	u.ConfirmationToken = addFlowPrefixToToken(token, flowType)
 	now := time.Now()
 	if err := mailer.ConfirmationMail(u, otp, referrerURL); err != nil {
 		u.ConfirmationToken = oldToken
@@ -311,7 +312,7 @@ func (a *API) sendReauthenticationOtp(tx *storage.Connection, u *models.User, ma
 	return errors.Wrap(tx.UpdateOnly(u, "reauthentication_token", "reauthentication_sent_at"), "Database error updating user for reauthentication")
 }
 
-func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int) error {
+func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int, flowType models.FlowType) error {
 	var err error
 	// since Magic Link is just a recovery with a different template and behaviour
 	// around new users we will reuse the recovery db timer to prevent potential abuse
@@ -323,7 +324,9 @@ func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer maile
 	if err != nil {
 		return err
 	}
-	u.RecoveryToken = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp)))
+	token := fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp)))
+	u.RecoveryToken = addFlowPrefixToToken(token, flowType)
+
 	now := time.Now()
 	if err := mailer.MagicLinkMail(u, otp, referrerURL); err != nil {
 		u.RecoveryToken = oldToken
diff --git a/internal/api/otp.go b/internal/api/otp.go
index 38852e6d..faf20c20 100644
--- a/internal/api/otp.go
+++ b/internal/api/otp.go
@@ -14,18 +14,22 @@ import (
 
 // OtpParams contains the request body params for the otp endpoint
 type OtpParams struct {
-	Email      string                 `json:"email"`
-	Phone      string                 `json:"phone"`
-	CreateUser bool                   `json:"create_user"`
-	Data       map[string]interface{} `json:"data"`
-	Channel    string                 `json:"channel"`
+	Email               string                 `json:"email"`
+	Phone               string                 `json:"phone"`
+	CreateUser          bool                   `json:"create_user"`
+	Data                map[string]interface{} `json:"data"`
+	Channel             string                 `json:"channel"`
+	CodeChallengeMethod string                 `json:"code_challenge_method"`
+	CodeChallenge       string                 `json:"code_challenge"`
 }
 
 // SmsParams contains the request body params for sms otp
 type SmsParams struct {
-	Phone   string                 `json:"phone"`
-	Channel string                 `json:"channel"`
-	Data    map[string]interface{} `json:"data"`
+	Phone               string                 `json:"phone"`
+	Channel             string                 `json:"channel"`
+	Data                map[string]interface{} `json:"data"`
+	CodeChallengeMethod string                 `json:"code_challenge_method"`
+	CodeChallenge       string                 `json:"code_challenge"`
 }
 
 func (p *OtpParams) Validate() error {
@@ -35,6 +39,9 @@ func (p *OtpParams) Validate() error {
 	if p.Email != "" && p.Channel != "" {
 		return badRequestError("Channel should only be specified with Phone OTP")
 	}
+	if err := validatePKCEParams(p.CodeChallengeMethod, p.CodeChallenge); err != nil {
+		return err
+	}
 	return nil
 }
 
diff --git a/internal/api/pkce.go b/internal/api/pkce.go
index 47f9e8cc..91e0ad43 100644
--- a/internal/api/pkce.go
+++ b/internal/api/pkce.go
@@ -1,13 +1,20 @@
 package api
 
 import (
+	"github.com/supabase/gotrue/internal/models"
+	"github.com/supabase/gotrue/internal/storage"
 	"regexp"
+	"time"
 )
 
-const PKCE = "pkce"
-
-const MinCodeChallengeLength = 43
-const MaxCodeChallengeLength = 128
+const (
+	PKCE                          = "pkce"
+	PKCEPrefix                    = "pkce_"
+	MinCodeChallengeLength        = 43
+	MaxCodeChallengeLength        = 128
+	InvalidFlowTypeErrorMessage   = "Invalid flow type. Flow Type must be either implicit or pkce"
+	InvalidPKCEParamsErrorMessage = "PKCE flow requires code_challenge_method and code_challenge"
+)
 
 var codeChallengePattern = regexp.MustCompile("^[a-zA-Z._~0-9-]+$")
 
@@ -23,3 +30,59 @@ func isValidCodeChallenge(codeChallenge string) (bool, error) {
 		return true, nil
 	}
 }
+
+func addFlowPrefixToToken(token string, flowType models.FlowType) string {
+	if isPKCEFlow(flowType) {
+		return flowType.String() + "_" + token
+	} else if isImplicitFlow(flowType) {
+		return token
+	}
+	return token
+}
+
+func issueAuthCode(tx *storage.Connection, user *models.User, expiryDuration time.Duration) (string, error) {
+	flowState, err := models.FindFlowStateByUserID(tx, user.ID.String())
+	if models.IsNotFoundError(err) {
+		return "", badRequestError("No valid flow state found for user.")
+	} else if err != nil {
+		return "", err
+	}
+
+	if flowState.IsExpired(expiryDuration) {
+		return "", badRequestError("Flow state is expired")
+	}
+	return flowState.AuthCode, nil
+}
+
+func isPKCEFlow(flowType models.FlowType) bool {
+	return flowType == models.PKCEFlow
+}
+
+func isImplicitFlow(flowType models.FlowType) bool {
+	return flowType == models.ImplicitFlow
+}
+
+func validatePKCEParams(codeChallengeMethod, codeChallenge string) error {
+	switch true {
+	// Explicitly spell out each case
+	case codeChallenge == "" && codeChallengeMethod != "":
+		return badRequestError(InvalidPKCEParamsErrorMessage)
+	case codeChallenge != "" && codeChallengeMethod == "":
+		return badRequestError(InvalidPKCEParamsErrorMessage)
+	case codeChallenge != "" && codeChallengeMethod != "":
+		break
+	case codeChallenge == "" && codeChallengeMethod == "":
+		break
+	default:
+		return badRequestError(InvalidPKCEParamsErrorMessage)
+	}
+	return nil
+}
+
+func getFlowFromChallenge(codeChallenge string) models.FlowType {
+	if codeChallenge != "" {
+		return models.PKCEFlow
+	} else {
+		return models.ImplicitFlow
+	}
+}
diff --git a/internal/api/resend.go b/internal/api/resend.go
index 7629870a..a17ad233 100644
--- a/internal/api/resend.go
+++ b/internal/api/resend.go
@@ -120,7 +120,8 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
 			if terr := models.NewAuditLogEntry(r, tx, user, models.UserConfirmationRequestedAction, "", nil); terr != nil {
 				return terr
 			}
-			return sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength)
+			// PKCE not implemented yet
+			return sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, models.ImplicitFlow)
 		case smsVerification:
 			if terr := models.NewAuditLogEntry(r, tx, user, models.UserRecoveryRequestedAction, "", nil); terr != nil {
 				return terr
diff --git a/internal/api/signup.go b/internal/api/signup.go
index 37dc4e3d..5a2dc7a1 100644
--- a/internal/api/signup.go
+++ b/internal/api/signup.go
@@ -19,13 +19,15 @@ import (
 
 // SignupParams are the parameters the Signup endpoint accepts
 type SignupParams struct {
-	Email    string                 `json:"email"`
-	Phone    string                 `json:"phone"`
-	Password string                 `json:"password"`
-	Data     map[string]interface{} `json:"data"`
-	Provider string                 `json:"-"`
-	Aud      string                 `json:"-"`
-	Channel  string                 `json:"channel"`
+	Email               string                 `json:"email"`
+	Phone               string                 `json:"phone"`
+	Password            string                 `json:"password"`
+	Data                map[string]interface{} `json:"data"`
+	Provider            string                 `json:"-"`
+	Aud                 string                 `json:"-"`
+	Channel             string                 `json:"channel"`
+	CodeChallengeMethod string                 `json:"code_challenge_method"`
+	CodeChallenge       string                 `json:"code_challenge"`
 }
 
 func (p *SignupParams) Validate(passwordMinLength int, smsProvider string) error {
@@ -41,6 +43,14 @@ func (p *SignupParams) Validate(passwordMinLength int, smsProvider string) error
 	if p.Provider == "phone" && !sms_provider.IsValidMessageChannel(p.Channel, smsProvider) {
 		return badRequestError(InvalidChannelError)
 	}
+	// PKCE not needed as phone signups already return access token in body
+	if p.Phone != "" && p.CodeChallenge != "" {
+		return badRequestError("PKCE not supported for phone signups")
+	}
+	if err := validatePKCEParams(p.CodeChallengeMethod, p.CodeChallenge); err != nil {
+		return err
+	}
+
 	return nil
 }
 
@@ -58,6 +68,7 @@ func (p *SignupParams) ConfigureDefaults() {
 	if p.Phone != "" && p.Channel == "" {
 		p.Channel = sms_provider.SMSProvider
 	}
+
 }
 
 // Signup is the endpoint for registering a new user
@@ -85,6 +96,19 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		return err
 	}
 
+	var codeChallengeMethod models.CodeChallengeMethod
+	flowType := getFlowFromChallenge(params.CodeChallenge)
+
+	if isPKCEFlow(flowType) {
+		if codeChallengeMethod, err = models.ParseCodeChallengeMethod(params.CodeChallengeMethod); err != nil {
+			return err
+		}
+		// PKCE not needed as autoconfirm returns access token in body
+		if config.Mailer.Autoconfirm {
+			return badRequestError("PKCE flow is not supported on signups with autoconfirm enabled")
+		}
+	}
+
 	var user *models.User
 	var grantParams models.GrantParams
 	params.Aud = a.requestAud(ctx, r)
@@ -160,7 +184,14 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 				}); terr != nil {
 					return terr
 				}
-				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength); terr != nil {
+				flowState, err := models.NewFlowStateWithUserID(params.Provider, params.CodeChallenge, codeChallengeMethod, models.MagicLink, &user.ID)
+				if err != nil {
+					return err
+				}
+				if err := tx.Create(flowState); err != nil {
+					return err
+				}
+				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, flowType); terr != nil {
 					if errors.Is(terr, MaxFrequencyLimitError) {
 						now := time.Now()
 						left := user.ConfirmationSentAt.Add(config.SMTP.MaxFrequency).Sub(now) / time.Second
@@ -260,7 +291,9 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		metering.RecordLogin("password", user.ID)
 		return sendJSON(w, http.StatusOK, token)
 	}
-
+	if isPKCEFlow(flowType) {
+		return sendJSON(w, http.StatusOK, map[string]interface{}{})
+	}
 	return sendJSON(w, http.StatusOK, user)
 }
 
diff --git a/internal/api/token.go b/internal/api/token.go
index 91922aa4..2391f304 100644
--- a/internal/api/token.go
+++ b/internal/api/token.go
@@ -538,7 +538,7 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 
 				mailer := a.Mailer(ctx)
 				referrer := a.getReferrer(r)
-				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength); terr != nil {
+				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, models.ImplicitFlow); terr != nil {
 					return internalServerError("Error sending confirmation mail").WithInternalError(terr)
 				}
 				return unauthorizedError("Error unverified email")
@@ -608,7 +608,7 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request)
 	flowState, err := models.FindFlowStateByAuthCode(db, params.AuthCode)
 	// Sanity check in case user ID was not set properly
 	if models.IsNotFoundError(err) || flowState.UserID == nil {
-		return forbiddenError("invalid oauth state, please ensure oauth redirect has successfully completed")
+		return forbiddenError("invalid flow state, no valid flow state found")
 	} else if err != nil {
 		return err
 	}
@@ -627,7 +627,11 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request)
 	var token *AccessTokenResponse
 	err = db.Transaction(func(tx *storage.Connection) error {
 		var terr error
-		token, terr = a.issueRefreshToken(ctx, tx, user, models.OAuth, grantParams)
+		authMethod, err := models.ParseAuthenticationMethod(flowState.AuthenticationMethod)
+		if err != nil {
+			return err
+		}
+		token, terr = a.issueRefreshToken(ctx, tx, user, authMethod, grantParams)
 		if terr != nil {
 			return oauthError("server_error", terr.Error())
 		}
diff --git a/internal/api/verify.go b/internal/api/verify.go
index e8a4b5bf..6b287fc5 100644
--- a/internal/api/verify.go
+++ b/internal/api/verify.go
@@ -89,7 +89,14 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 		grantParams models.GrantParams
 		err         error
 		token       *AccessTokenResponse
+		authCode    string
 	)
+	var flowType models.FlowType
+	if strings.HasPrefix(params.Token, PKCEPrefix) {
+		flowType = models.PKCEFlow
+	} else {
+		flowType = models.ImplicitFlow
+	}
 	if err := params.Validate(); err != nil {
 		return err
 	}
@@ -99,7 +106,7 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 
 		params.Token = strings.ReplaceAll(params.Token, "-", "")
 		aud := a.requestAud(ctx, r)
-		user, terr = a.verifyEmailLink(ctx, tx, params, aud)
+		user, terr = a.verifyEmailLink(ctx, tx, params, aud, flowType)
 		if terr != nil {
 			return terr
 		}
@@ -124,15 +131,20 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 		if terr != nil {
 			return terr
 		}
+		if isImplicitFlow(flowType) {
+			token, terr = a.issueRefreshToken(ctx, tx, user, models.OTP, grantParams)
 
-		token, terr = a.issueRefreshToken(ctx, tx, user, models.OTP, grantParams)
-
-		if terr != nil {
-			return terr
-		}
+			if terr != nil {
+				return terr
+			}
 
-		if terr = a.setCookieTokens(config, token, false, w); terr != nil {
-			return internalServerError("Failed to set JWT cookie. %s", terr)
+			if terr = a.setCookieTokens(config, token, false, w); terr != nil {
+				return internalServerError("Failed to set JWT cookie. %s", terr)
+			}
+		} else if isPKCEFlow(flowType) {
+			if authCode, terr = issueAuthCode(tx, user, a.config.External.FlowStateExpiryDuration); terr != nil {
+				return badRequestError("No associated flow state found. %s", terr)
+			}
 		}
 		return nil
 	})
@@ -145,15 +157,16 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 			return nil
 		}
 	}
-
 	rurl := params.RedirectTo
-	if token != nil {
+	if isImplicitFlow(flowType) && token != nil {
 		q := url.Values{}
 		q.Set("type", params.Type)
-
 		rurl = token.AsRedirectURL(rurl, q)
+	} else if isPKCEFlow(flowType) {
+		q := url.Values{}
+		q.Set("code", authCode)
+		rurl += "?" + q.Encode()
 	}
-
 	http.Redirect(w, r, rurl, http.StatusSeeOther)
 	return nil
 }
@@ -225,7 +238,6 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request) error {
 		}
 		return nil
 	})
-
 	if err != nil {
 		return err
 	}
@@ -403,12 +415,11 @@ func (a *API) emailChangeVerify(r *http.Request, ctx context.Context, conn *stor
 	return user, nil
 }
 
-func (a *API) verifyEmailLink(ctx context.Context, conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) {
+func (a *API) verifyEmailLink(ctx context.Context, conn *storage.Connection, params *VerifyParams, aud string, flowType models.FlowType) (*models.User, error) {
 	config := a.config
 
 	var user *models.User
 	var err error
-
 	switch params.Type {
 	case signupVerification, inviteVerification:
 		user, err = models.FindUserByConfirmationToken(conn, params.Token)
@@ -444,6 +455,7 @@ func (a *API) verifyEmailLink(ctx context.Context, conn *storage.Connection, par
 	if isExpired {
 		return nil, expiredTokenError("Email link is invalid or has expired").WithInternalError(errRedirectWithQuery)
 	}
+
 	return user, nil
 }
 
@@ -454,6 +466,7 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 	var user *models.User
 	var err error
 	var tokenHash string
+
 	if isPhoneOtpVerification(params) {
 		params.Phone, err = validatePhone(params.Phone)
 		if err != nil {
diff --git a/internal/mailer/template.go b/internal/mailer/template.go
index 8f323c5f..b9e69615 100644
--- a/internal/mailer/template.go
+++ b/internal/mailer/template.go
@@ -107,8 +107,8 @@ func (m *TemplateMailer) InviteMail(user *models.User, otp, referrerURL string)
 // ConfirmationMail sends a signup confirmation mail to a new user
 func (m *TemplateMailer) ConfirmationMail(user *models.User, otp, referrerURL string) error {
 	redirectParam := encodeRedirectParam(referrerURL)
-
-	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Confirmation, "token="+user.ConfirmationToken+"&type=signup"+redirectParam)
+	fragment := "token=" + user.ConfirmationToken + "&type=signup" + redirectParam
+	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Confirmation, fragment)
 	if err != nil {
 		return err
 	}
@@ -224,7 +224,6 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent,
 // RecoveryMail sends a password recovery mail
 func (m *TemplateMailer) RecoveryMail(user *models.User, otp, referrerURL string) error {
 	redirectParam := encodeRedirectParam(referrerURL)
-
 	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Recovery, "token="+user.RecoveryToken+"&type=recovery"+redirectParam)
 	if err != nil {
 		return err
@@ -250,11 +249,12 @@ func (m *TemplateMailer) RecoveryMail(user *models.User, otp, referrerURL string
 // MagicLinkMail sends a login link mail
 func (m *TemplateMailer) MagicLinkMail(user *models.User, otp, referrerURL string) error {
 	redirectParam := encodeRedirectParam(referrerURL)
-
-	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Recovery, "token="+user.RecoveryToken+"&type=magiclink"+redirectParam)
+	fragment := "token=" + user.RecoveryToken + "&type=magiclink" + redirectParam
+	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Recovery, fragment)
 	if err != nil {
 		return err
 	}
+
 	data := map[string]interface{}{
 		"SiteURL":         m.Config.SiteURL,
 		"ConfirmationURL": url,
diff --git a/internal/models/factor.go b/internal/models/factor.go
index 555ad90e..957b24e8 100644
--- a/internal/models/factor.go
+++ b/internal/models/factor.go
@@ -2,6 +2,7 @@ package models
 
 import (
 	"database/sql"
+	"fmt"
 	"time"
 
 	"github.com/gobuffalo/pop/v5"
@@ -37,6 +38,8 @@ const (
 	OTP
 	TOTPSignIn
 	SSOSAML
+	MagicLink
+	EmailSignup
 )
 
 func (authMethod AuthenticationMethod) String() string {
@@ -51,10 +54,34 @@ func (authMethod AuthenticationMethod) String() string {
 		return "totp"
 	case SSOSAML:
 		return "sso/saml"
+	case MagicLink:
+		return "magiclink"
+	case EmailSignup:
+		return "email/signup"
 	}
 	return ""
 }
 
+func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) {
+	switch authMethod {
+	case "oauth":
+		return OAuth, nil
+	case "password":
+		return PasswordGrant, nil
+	case "otp":
+		return OTP, nil
+	case "totp":
+		return TOTPSignIn, nil
+	case "sso/saml":
+		return SSOSAML, nil
+	case "magiclink":
+		return MagicLink, nil
+	case "email/signup":
+		return EmailSignup, nil
+	}
+	return 0, fmt.Errorf("unsupported authentication method %q", authMethod)
+}
+
 type Factor struct {
 	ID           uuid.UUID   `json:"id" db:"id"`
 	User         User        `json:"-" belongs_to:"user"`
diff --git a/internal/models/flow_state.go b/internal/models/flow_state.go
index 9abeda65..d9eb4df7 100644
--- a/internal/models/flow_state.go
+++ b/internal/models/flow_state.go
@@ -5,8 +5,10 @@ import (
 	"crypto/subtle"
 	"database/sql"
 	"encoding/base64"
+	"fmt"
 	"github.com/pkg/errors"
 	"github.com/supabase/gotrue/internal/storage"
+	"strings"
 	"time"
 
 	"github.com/gofrs/uuid"
@@ -19,6 +21,7 @@ type FlowState struct {
 	ID                   uuid.UUID  `json:"id" db:"id"`
 	UserID               *uuid.UUID `json:"user_id,omitempty" db:"user_id"`
 	AuthCode             string     `json:"auth_code" db:"auth_code"`
+	AuthenticationMethod string     `json:"authentication_method" db:"authentication_method"`
 	CodeChallenge        string     `json:"code_challenge" db:"code_challenge"`
 	CodeChallengeMethod  string     `json:"code_challenge_method" db:"code_challenge_method"`
 	ProviderType         string     `json:"provider_type" db:"provider_type"`
@@ -45,23 +48,65 @@ func (authMethod CodeChallengeMethod) String() string {
 	return ""
 }
 
+func ParseCodeChallengeMethod(authMethod string) (CodeChallengeMethod, error) {
+	switch strings.ToLower(authMethod) {
+	case "s256":
+		return SHA256, nil
+	case "plain":
+		return Plain, nil
+	}
+	return 0, fmt.Errorf("unsupported code_challenge method %q", authMethod)
+}
+
+type FlowType int
+
+const (
+	PKCEFlow FlowType = iota
+	ImplicitFlow
+)
+
+func (flowType FlowType) String() string {
+	switch flowType {
+	case PKCEFlow:
+		return "pkce"
+	case ImplicitFlow:
+		return "implicit"
+	}
+	return ""
+}
+
 func (FlowState) TableName() string {
 	tableName := "flow_state"
 	return tableName
 }
 
-func NewFlowState(providerType, codeChallenge string, codeChallengeMethod CodeChallengeMethod) (*FlowState, error) {
+func NewFlowState(providerType, codeChallenge string, codeChallengeMethod CodeChallengeMethod, authenticationMethod AuthenticationMethod) (*FlowState, error) {
 	id := uuid.Must(uuid.NewV4())
+	authCode := uuid.Must(uuid.NewV4())
+	flowState := &FlowState{
+		ID:                   id,
+		ProviderType:         providerType,
+		CodeChallenge:        codeChallenge,
+		CodeChallengeMethod:  codeChallengeMethod.String(),
+		AuthCode:             authCode.String(),
+		AuthenticationMethod: authenticationMethod.String(),
+	}
+	return flowState, nil
+}
 
+func NewFlowStateWithUserID(providerType, codeChallenge string, codeChallengeMethod CodeChallengeMethod, authenticationMethod AuthenticationMethod, userID *uuid.UUID) (*FlowState, error) {
+	id := uuid.Must(uuid.NewV4())
 	authCode := uuid.Must(uuid.NewV4())
-	oauth := &FlowState{
-		ID:                  id,
-		ProviderType:        providerType,
-		CodeChallenge:       codeChallenge,
-		CodeChallengeMethod: codeChallengeMethod.String(),
-		AuthCode:            authCode.String(),
+	flowState := &FlowState{
+		ID:                   id,
+		ProviderType:         providerType,
+		CodeChallenge:        codeChallenge,
+		CodeChallengeMethod:  codeChallengeMethod.String(),
+		AuthCode:             authCode.String(),
+		AuthenticationMethod: authenticationMethod.String(),
+		UserID:               userID,
 	}
-	return oauth, nil
+	return flowState, nil
 }
 
 func FindFlowStateByAuthCode(tx *storage.Connection, authCode string) (*FlowState, error) {
@@ -88,6 +133,18 @@ func FindFlowStateByID(tx *storage.Connection, id string) (*FlowState, error) {
 	return obj, nil
 }
 
+func FindFlowStateByUserID(tx *storage.Connection, id string) (*FlowState, error) {
+	obj := &FlowState{}
+	if err := tx.Eager().Q().Where("user_id = ?", id).Order("created_at asc").First(obj); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, FlowStateNotFoundError{}
+		}
+		return nil, errors.Wrap(err, "error finding flow state")
+	}
+
+	return obj, nil
+}
+
 func (f *FlowState) VerifyPKCE(codeVerifier string) error {
 	switch f.CodeChallengeMethod {
 	case SHA256.String():
diff --git a/migrations/20230402418590_add_authentication_method_to_flow_state_table.up.sql b/migrations/20230402418590_add_authentication_method_to_flow_state_table.up.sql
new file mode 100644
index 00000000..41ebf6e6
--- /dev/null
+++ b/migrations/20230402418590_add_authentication_method_to_flow_state_table.up.sql
@@ -0,0 +1,2 @@
+alter table {{index .Options "Namespace" }}.flow_state
+add column authentication_method text not null;
diff --git a/openapi.yaml b/openapi.yaml
index 46e1241f..98dc2098 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -234,6 +234,7 @@ paths:
                   format: uri
                   description: >
                     (Optional) URL to redirect back into the app on after verification completes successfully. If not specified will use the "Site URL" configuration option. If not allowed per the allow list it will use the "Site URL" configuration option.
+
       responses:
         200:
           description: An access and refresh token.
@@ -325,6 +326,13 @@ paths:
                   phone: "+1234567890"
                   password: password1
                   channel: whatsapp
+              "email+password+pkce":
+                value:
+                  email: user@example.com
+                  password: password1
+                  flow_type: pkce
+                  code_challenge_method: s256
+                  code_challenge: elU6u5zyqQT2f92GRQUq6PautAeNDf4DQPayyR0ek_c&
             schema:
               type: object
               properties:
@@ -343,6 +351,13 @@ paths:
                   type: string
                 data:
                   type: object
+                code_challenge:
+                  type: string
+                code_challenge_method:
+                  type: string
+                  enum:
+                    - plain
+                    - s256
                 gotrue_meta_security:
                   $ref: "#/components/schemas/GoTrueMetaSecurity"
       responses:
@@ -522,6 +537,13 @@ paths:
                   type: boolean
                 data:
                   type: object
+                code_challenge_method:
+                  type: string
+                  enum:
+                    - s256
+                    - plain
+                code_challenge:
+                  type: string
                 gotrue_meta_security:
                   $ref: "#/components/schemas/GoTrueMetaSecurity"
       responses:


Test patch:
diff --git a/internal/api/otp_test.go b/internal/api/otp_test.go
index 9ee50efb..0a6f0153 100644
--- a/internal/api/otp_test.go
+++ b/internal/api/otp_test.go
@@ -38,6 +38,109 @@ func (ts *OtpTestSuite) SetupTest() {
 
 }
 
+func (ts *OtpTestSuite) TestOtpPKCE() {
+	ts.Config.External.Phone.Enabled = true
+	testCodeChallenge := "testtesttesttesttesttesttestteststeststesttesttesttest"
+
+	var buffer bytes.Buffer
+	cases := []struct {
+		desc     string
+		params   OtpParams
+		expected struct {
+			code     int
+			response map[string]interface{}
+		}
+	}{
+		{
+			desc: "Test (PKCE) Success Magiclink Otp",
+			params: OtpParams{
+				Email:               "test@example.com",
+				CreateUser:          true,
+				CodeChallengeMethod: "s256",
+				CodeChallenge:       testCodeChallenge,
+			},
+			expected: struct {
+				code     int
+				response map[string]interface{}
+			}{
+				http.StatusOK,
+				make(map[string]interface{}),
+			},
+		},
+		{
+			desc: "Test (PKCE) Failure, no code challenge",
+			params: OtpParams{
+				Email:               "test@example.com",
+				CreateUser:          true,
+				CodeChallengeMethod: "s256",
+			},
+			expected: struct {
+				code     int
+				response map[string]interface{}
+			}{
+				http.StatusBadRequest,
+				map[string]interface{}{
+					"code": float64(http.StatusBadRequest),
+					"msg":  "PKCE flow requires code_challenge_method and code_challenge",
+				},
+			},
+		},
+		{
+			desc: "Test (PKCE) Failure, no code challenge method",
+			params: OtpParams{
+				Email:         "test@example.com",
+				CreateUser:    true,
+				CodeChallenge: testCodeChallenge,
+			},
+			expected: struct {
+				code     int
+				response map[string]interface{}
+			}{
+				http.StatusBadRequest,
+				map[string]interface{}{
+					"code": float64(http.StatusBadRequest),
+					"msg":  "PKCE flow requires code_challenge_method and code_challenge",
+				},
+			},
+		},
+		{
+			desc: "Test (PKCE) Success, phone with valid params",
+			params: OtpParams{
+				Phone:               "123456789",
+				CreateUser:          true,
+				CodeChallengeMethod: "s256",
+				CodeChallenge:       testCodeChallenge,
+			},
+			expected: struct {
+				code     int
+				response map[string]interface{}
+			}{
+				http.StatusBadRequest,
+				map[string]interface{}{
+					"code": float64(http.StatusBadRequest),
+					"msg":  "Error sending sms:",
+				},
+			},
+		},
+	}
+	for _, c := range cases {
+		ts.Run(c.desc, func() {
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params))
+
+			req := httptest.NewRequest(http.MethodPost, "/otp", &buffer)
+			req.Header.Set("Content-Type", "application/json")
+
+			w := httptest.NewRecorder()
+			ts.API.handler.ServeHTTP(w, req)
+
+			require.Equal(ts.T(), c.expected.code, w.Code)
+			data := make(map[string]interface{})
+			require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
+
+		})
+	}
+}
+
 func (ts *OtpTestSuite) TestOtp() {
 	// Configured to allow testing of invalid channel params
 	ts.Config.External.Phone.Enabled = true
diff --git a/internal/api/token_test.go b/internal/api/token_test.go
index 967878d9..f6acc131 100644
--- a/internal/api/token_test.go
+++ b/internal/api/token_test.go
@@ -5,8 +5,10 @@ import (
 	"crypto/sha256"
 	"encoding/base64"
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"testing"
 	"time"
@@ -142,7 +144,7 @@ func (ts *TokenTestSuite) TestTokenPKCEGrantFailure() {
 	invalidVerifier := codeVerifier + "123"
 	codeChallenge := sha256.Sum256([]byte(codeVerifier))
 	challenge := base64.RawURLEncoding.EncodeToString(codeChallenge[:])
-	flowState, err := models.NewFlowState("github", challenge, models.SHA256)
+	flowState, err := models.NewFlowState("github", challenge, models.SHA256, models.OAuth)
 	require.NoError(ts.T(), err)
 	flowState.AuthCode = authCode
 	require.NoError(ts.T(), ts.API.db.Create(flowState))
@@ -351,3 +353,62 @@ func (ts *TokenTestSuite) TestTokenRefreshWithUnexpiredSession() {
 	ts.API.handler.ServeHTTP(w, req)
 	assert.Equal(ts.T(), http.StatusOK, w.Code)
 }
+
+func (ts *TokenTestSuite) TestMagicLinkPKCESignIn() {
+	var buffer bytes.Buffer
+	// Send OTP
+	codeVerifier := "4a9505b9-0857-42bb-ab3c-098b4d28ddc2"
+	codeChallenge := sha256.Sum256([]byte(codeVerifier))
+	challenge := base64.RawURLEncoding.EncodeToString(codeChallenge[:])
+
+	req := httptest.NewRequest(http.MethodPost, "/otp", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(OtpParams{
+		Email:               "test@example.com",
+		CreateUser:          true,
+		CodeChallengeMethod: "s256",
+		CodeChallenge:       challenge,
+	}))
+	req = httptest.NewRequest(http.MethodPost, "/otp", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+
+	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+
+	// Verify OTP
+	requestUrl := fmt.Sprintf("http://localhost/verify?type=%v&token=%v&flow_type=%v", "magiclink", u.RecoveryToken, models.PKCEFlow.String())
+	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)
+	authCode := f.Get("code")
+	assert.NotEmpty(ts.T(), authCode)
+	// Extract token and sign in
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"code_verifier": codeVerifier,
+		"auth_code":     authCode,
+	}))
+	req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=pkce", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+	w = httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+	verifyResp := &AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&verifyResp))
+	require.NotEmpty(ts.T(), verifyResp.Token)
+
+}
diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go
index 2a315855..5ba1c0e6 100644
--- a/internal/api/verify_test.go
+++ b/internal/api/verify_test.go
@@ -182,7 +182,6 @@ func (ts *VerifyTestSuite) TestExpiredConfirmationToken() {
 
 	f, err := url.ParseQuery(rurl.Fragment)
 	require.NoError(ts.T(), err)
-	fmt.Println(f)
 	assert.Equal(ts.T(), "401", f.Get("error_code"))
 	assert.Equal(ts.T(), "Email link is invalid or has expired", f.Get("error_description"))
 	assert.Equal(ts.T(), "unauthorized_client", f.Get("error"))
@@ -523,6 +522,67 @@ 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"
+	u.RecoveryToken = "pkce_recovery_token"
+	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
+	}{
+		{
+			desc: "Verify banned user on signup",
+			payload: &VerifyParams{
+				Type:  "signup",
+				Token: u.ConfirmationToken,
+			},
+		},
+		{
+			desc: "Verify magiclink",
+			payload: &VerifyParams{
+				Type:  "magiclink",
+				Token: u.RecoveryToken,
+			},
+		},
+	}
+	for _, c := range cases {
+		var buffer bytes.Buffer
+		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload))
+		codeChallenge := "codechallengecodechallengcodechallengcodechallengcodechallenge" + c.payload.Type
+		flowState, err := models.NewFlowState(models.MagicLink.String(), codeChallenge, models.SHA256, models.MagicLink)
+		require.NoError(ts.T(), err)
+		flowState.UserID = &(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)