Repository: supabase/gotrue
Pull request #707 (2022-09-27 17:13:28)

refactor: add access to token used for magic-link generation
Magic links often need to be customized. This PR allows folks to customize the link by providing access to the `HashedToken`.

kangmingtay: hmm can you also add it to the `RecoveryMail`, `InviteMail`, `EmailChangeMail` and `ConfirmationMail`?

Patch:
diff --git a/api/api.go b/api/api.go
index 9e884e4f..bd254076 100644
--- a/api/api.go
+++ b/api/api.go
@@ -156,6 +156,13 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 		if api.config.SAML.Enabled {
 			r.Route("/sso", func(r *router) {
+				r.With(api.limitHandler(
+					// Allow requests at the specified rate per 5 minutes.
+					tollbooth.NewLimiter(api.config.RateLimitSso/(60*5), &limiter.ExpirableOptions{
+						DefaultExpirationTTL: time.Hour,
+					}).SetBurst(30),
+				)).With(api.verifyCaptcha).Post("/", api.SingleSignOn)
+
 				r.Route("/saml", func(r *router) {
 					r.Get("/metadata", api.SAMLMetadata)
 
diff --git a/api/helpers.go b/api/helpers.go
index 30ce9aa5..c0283eb9 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -1,11 +1,9 @@
 package api
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
-	"io"
 	"net"
 	"net/http"
 	"net/http/httptrace"
@@ -14,6 +12,7 @@ import (
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/utilities"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 )
@@ -237,19 +236,5 @@ func isStringInSlice(checkValue string, list []string) bool {
 
 // getBodyBytes returns a byte array of the request's Body.
 func getBodyBytes(req *http.Request) ([]byte, error) {
-	if req.Body == nil || req.Body == http.NoBody {
-		return nil, nil
-	}
-
-	originalBody := req.Body
-	defer originalBody.Close()
-
-	buf, err := io.ReadAll(originalBody)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Body = io.NopCloser(bytes.NewReader(buf))
-
-	return buf, nil
+	return utilities.GetBodyBytes(req)
 }
diff --git a/api/middleware.go b/api/middleware.go
index 7f1048d5..51b46218 100644
--- a/api/middleware.go
+++ b/api/middleware.go
@@ -9,7 +9,6 @@ import (
 
 	"github.com/netlify/gotrue/observability"
 	"github.com/netlify/gotrue/security"
-	"github.com/sirupsen/logrus"
 
 	"github.com/didip/tollbooth/v5"
 	"github.com/didip/tollbooth/v5/limiter"
@@ -139,6 +138,7 @@ func (a *API) requireEmailProvider(w http.ResponseWriter, req *http.Request) (co
 func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.Context, error) {
 	ctx := req.Context()
 	config := a.config
+
 	if !config.Security.Captcha.Enabled {
 		return ctx, nil
 	}
@@ -149,28 +149,17 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 	if shouldIgnore := isIgnoreCaptchaRoute(req); shouldIgnore {
 		return ctx, nil
 	}
-	if config.Security.Captcha.Provider != "hcaptcha" {
-		logrus.WithField("provider", config.Security.Captcha.Provider).Warn("Unsupported captcha provider")
-		return nil, internalServerError("server misconfigured")
-	}
-	secret := strings.TrimSpace(config.Security.Captcha.Secret)
-	if secret == "" {
-		return nil, internalServerError("server misconfigured")
-	}
 
-	verificationResult, err := security.VerifyRequest(req, secret)
+	verificationResult, err := security.VerifyRequest(req, strings.TrimSpace(config.Security.Captcha.Secret))
 	if err != nil {
-		logrus.WithField("err", err).Infof("failed to validate result")
-		return nil, internalServerError("request validation failure")
-	}
-	if verificationResult == security.VerificationProcessFailure {
-		return nil, internalServerError("request validation failure")
-	} else if verificationResult == security.UserRequestFailed {
-		return nil, badRequestError("request disallowed")
+		return nil, internalServerError("hCaptcha verification process failed").WithInternalError(err)
 	}
-	if verificationResult != security.SuccessfullyVerified {
-		return nil, internalServerError("")
+
+	if !verificationResult.Success {
+		return nil, badRequestError("hCaptcha protection: request disallowed (%s)", strings.Join(verificationResult.ErrorCodes, ", "))
+
 	}
+
 	return ctx, nil
 }
 
diff --git a/api/sso.go b/api/sso.go
index 1ac00640..f481ccea 100644
--- a/api/sso.go
+++ b/api/sso.go
@@ -1,407 +1,132 @@
 package api
 
 import (
-	"context"
 	"encoding/json"
-	"io"
 	"net/http"
-	"net/url"
-	"regexp"
-	"unicode/utf8"
 
 	"github.com/crewjam/saml"
-	"github.com/crewjam/saml/samlsp"
-	"github.com/go-chi/chi"
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/models"
-	"github.com/netlify/gotrue/observability"
 	"github.com/netlify/gotrue/storage"
+	"github.com/netlify/gotrue/utilities"
 )
 
-var (
-	ssoProviderResourceIDPattern = regexp.MustCompile("(?i)^[a-z0-9_-]{5,256}$")
-)
-
-// loadSSOProvider looks for an idp_id parameter in the URL route and loads the SSO provider
-// with that ID (or resource ID) and adds it to the context.
-func (a *API) loadSSOProvider(w http.ResponseWriter, r *http.Request) (context.Context, error) {
-	var err error
-	var provider *models.SSOProvider
-
-	idpParam := chi.URLParam(r, "idp_id")
-
-	if idpID, err := uuid.FromString(idpParam); err == nil {
-		// idpParam is a UUIDv4
-		provider, err = models.FindSSOProviderByID(a.db, idpID)
-		if err != nil {
-			if !models.IsNotFoundError(err) {
-				return nil, internalServerError("Database error finding SSO Identity Provider").WithInternalError(err)
-			}
-			// not found with the ID, maybe it's a ResourceID?
-		}
-	}
-
-	if provider == nil {
-		// provider wasn't found, consider idpParam is a resource ID
-
-		if !ssoProviderResourceIDPattern.MatchString(idpParam) {
-			// it is definitely not a resource ID
-			return nil, notFoundError("SSO Identity Provider not found")
-		}
-
-		provider, err = models.FindSSOProviderByResourceID(a.db, idpParam)
-		if err != nil {
-			if models.IsNotFoundError(err) {
-				return nil, notFoundError("SSO Identity Provider not found")
-			} else {
-				return nil, internalServerError("Database error finding SSO Identity Provider").WithInternalError(err)
-			}
-		}
-	}
-
-	observability.LogEntrySetField(r, "sso_provider_id", provider.ID.String())
-	if provider.ResourceID != nil {
-		resourceID := *provider.ResourceID
-		observability.LogEntrySetField(r, "sso_provider_resource_id", resourceID)
-	}
-
-	return withSSOProvider(r.Context(), provider), nil
-}
-
-// adminSSOProvidersList lists all SAML SSO Identity Providers in the system. Does
-// not deal with pagination at this time.
-func (a *API) adminSSOProvidersList(w http.ResponseWriter, r *http.Request) error {
-	providers, err := models.FindAllSAMLProviders(a.db)
-	if err != nil {
-		return err
-	}
-
-	for i := range providers {
-		// remove metadata XML so that the returned JSON is not ginormous
-		providers[i].SAMLProvider.MetadataXML = ""
-	}
-
-	return sendJSON(w, http.StatusOK, map[string]interface{}{
-		"items": providers,
-	})
+type SingleSignOnParams struct {
+	ProviderID       uuid.UUID `json:"provider_id"`
+	Domain           string    `json:"domain"`
+	RedirectTo       string    `json:"redirect_to"`
+	SkipHTTPRedirect *bool     `json:"skip_http_redirect"`
 }
 
-type CreateSSOProviderParams struct {
-	ResourceID string `json:"resource_id"`
-	Type       string `json:"type"`
-
-	MetadataURL      string                      `json:"metadata_url"`
-	MetadataXML      string                      `json:"metadata_xml"`
-	Domains          []string                    `json:"domains"`
-	AttributeMapping models.SAMLAttributeMapping `json:"attribute_mapping"`
+type SingleSignOnResponse struct {
+	URL string `json:"url"`
 }
 
-func (p *CreateSSOProviderParams) validate(forUpdate bool) error {
-	if !forUpdate && p.Type != "saml" {
-		return badRequestError("Only 'saml' supported for SSO provider type")
-	} else if p.ResourceID != "" && !ssoProviderResourceIDPattern.MatchString(p.ResourceID) {
-		return badRequestError("Resource IDs can only contain letters, digits, dashes and underscores, have a minimum lenth of 5 and a maximum of 256")
-	} else if p.MetadataURL != "" && p.MetadataXML != "" {
-		return badRequestError("Only one of metadata_xml or metadata_url needs to be set")
-	} else if !forUpdate && p.MetadataURL == "" && p.MetadataXML == "" {
-		return badRequestError("Either metadata_xml or metadata_url must be set")
-	} else if p.MetadataURL != "" {
-		metadataURL, err := url.ParseRequestURI(p.MetadataURL)
-		if err != nil {
-			return badRequestError("metadata_url is not a valid URL")
-		}
+func (p *SingleSignOnParams) validate() (bool, error) {
+	hasProviderID := p.ProviderID != uuid.Nil
+	hasDomain := p.Domain != ""
 
-		if metadataURL.Scheme != "https" {
-			return badRequestError("metadata_url is not a HTTPS URL")
-		}
+	if hasProviderID && hasDomain {
+		return hasProviderID, badRequestError("Only one of provider_id or domain supported")
+	} else if !hasProviderID && !hasDomain {
+		return hasProviderID, badRequestError("A provider_id or domain needs to be provided")
 	}
 
-	// TODO validate p.AttributeMapping
-	// TODO validate domains
-
-	return nil
+	return hasProviderID, nil
 }
 
-func (p *CreateSSOProviderParams) metadata(ctx context.Context) ([]byte, *saml.EntityDescriptor, error) {
-	var rawMetadata []byte
-	var err error
-
-	if p.MetadataXML != "" {
-		rawMetadata = []byte(p.MetadataXML)
-	} else if p.MetadataURL != "" {
-		rawMetadata, err = fetchSAMLMetadata(ctx, p.MetadataURL)
-		if err != nil {
-			return nil, nil, err
-		}
-	} else {
-		// impossible situation if you called validate() prior
-		return nil, nil, nil
-	}
-
-	metadata, err := parseSAMLMetadata(rawMetadata)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	return rawMetadata, metadata, nil
-}
-
-func parseSAMLMetadata(rawMetadata []byte) (*saml.EntityDescriptor, error) {
-	if !utf8.Valid(rawMetadata) {
-		return nil, badRequestError("SAML Metadata XML contains invalid UTF-8 characters, which are not supported at this time")
-	}
-
-	metadata, err := samlsp.ParseMetadata(rawMetadata)
-	if err != nil {
-		return nil, err
-	}
-
-	if metadata.EntityID == "" {
-		return nil, badRequestError("SAML Metadata does not contain an EntityID")
-	}
-
-	if len(metadata.IDPSSODescriptors) < 1 {
-		return nil, badRequestError("SAML Metadata does not contain any IDPSSODescriptor")
-	}
-
-	if len(metadata.IDPSSODescriptors) > 1 {
-		return nil, badRequestError("SAML Metadata contains multiple IDPSSODescriptors")
-	}
-
-	return metadata, nil
-}
-
-func fetchSAMLMetadata(ctx context.Context, url string) ([]byte, error) {
-	req, err := http.NewRequest(http.MethodGet, url, nil)
-	if err != nil {
-		return nil, badRequestError("Unable to create a request to metadata_url").WithInternalError(err)
-	}
-
-	req = req.WithContext(ctx)
-
-	req.Header.Set("Accept", "application/xml;charset=UTF-8")
-	req.Header.Set("Accept-Charset", "UTF-8")
-
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		return nil, badRequestError("HTTP %v error fetching SAML Metadata from URL '%s'", resp.StatusCode, url)
-	}
-
-	data, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	return data, nil
-}
-
-// adminSSOProvidersCreate creates a new SAML Identity Provider in the system.
-func (a *API) adminSSOProvidersCreate(w http.ResponseWriter, r *http.Request) error {
+// SingleSignOn handles the single-sign-on flow for a provided SSO domain or provider.
+func (a *API) SingleSignOn(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
+	db := a.db.WithContext(ctx)
 
 	body, err := getBodyBytes(r)
 	if err != nil {
 		return internalServerError("Unable to read request body").WithInternalError(err)
 	}
 
-	var params CreateSSOProviderParams
-	if err := json.Unmarshal(body, &params); err != nil {
-		return badRequestError("Unable to parse JSON").WithInternalError(err)
-	}
+	var params SingleSignOnParams
 
-	if err := params.validate(false /* <- forUpdate */); err != nil {
-		return err
-	}
-
-	rawMetadata, metadata, err := params.metadata(ctx)
-	if err != nil {
-		return err
+	if err := json.Unmarshal(body, &params); err != nil {
+		return badRequestError("Unable to parse request body as JSON").WithInternalError(err)
 	}
 
-	if params.ResourceID != "" {
-		if _, err := models.FindSSOProviderByResourceID(a.db, params.ResourceID); err != nil {
-			if !models.IsNotFoundError(err) {
-				return internalServerError("Unable to find SSO provider by resource ID").WithInternalError(err)
-			}
-		} else {
-			return badRequestError("A SSO provider with this resource ID (%s) already exists", params.ResourceID)
-		}
-	}
+	hasProviderID := false
 
-	existingProvider, err := models.FindSAMLProviderByEntityID(a.db, metadata.EntityID)
-	if err != nil && !models.IsNotFoundError(err) {
+	if hasProviderID, err = params.validate(); err != nil {
 		return err
 	}
-	if existingProvider != nil {
-		return badRequestError("SAML Identity Provider with this EntityID (%s) already exists", metadata.EntityID)
-	}
-
-	provider := &models.SSOProvider{
-		// TODO handle Name, Description, Attribute Mapping
-		SAMLProvider: models.SAMLProvider{
-			EntityID:    metadata.EntityID,
-			MetadataXML: string(rawMetadata),
-		},
-	}
-
-	if params.ResourceID != "" {
-		provider.ResourceID = &params.ResourceID
-	}
-
-	if params.MetadataURL != "" {
-		provider.SAMLProvider.MetadataURL = &params.MetadataURL
-	}
 
-	for _, domain := range params.Domains {
+	var ssoProvider *models.SSOProvider
 
-		existingProvider, err := models.FindSSOProviderByDomain(a.db, domain)
-		if err != nil && !models.IsNotFoundError(err) {
-			return err
+	if hasProviderID {
+		ssoProvider, err = models.FindSSOProviderByID(db, params.ProviderID)
+		if models.IsNotFoundError(err) {
+			return notFoundError("No such SSO provider")
+		} else if err != nil {
+			return internalServerError("Unable to find SSO provider by ID").WithInternalError(err)
 		}
-		if existingProvider != nil {
-			return badRequestError("SSO Domain '%s' is already assigned to an SSO identity provider (%s)", domain, existingProvider.ID.String())
+	} else {
+		ssoProvider, err = models.FindSSOProviderByDomain(db, params.Domain)
+		if models.IsNotFoundError(err) {
+			return notFoundError("No SSO provider assigned for this domain")
+		} else if err != nil {
+			return internalServerError("Unable to find SSO provider by domain").WithInternalError(err)
 		}
-
-		provider.SSODomains = append(provider.SSODomains, models.SSODomain{
-			Domain: domain,
-		})
 	}
 
-	if err := a.db.Transaction(func(tx *storage.Connection) error {
-		return tx.Eager().Create(provider)
-	}); err != nil {
-		return err
+	entityDescriptor, err := ssoProvider.SAMLProvider.EntityDescriptor()
+	if err != nil {
+		return internalServerError("Error parsing SAML Metadata for SAML provider").WithInternalError(err)
 	}
 
-	return sendJSON(w, http.StatusCreated, provider)
-}
-
-// adminSSOProvidersGet returns an existing SAML Identity Provider in the system.
-func (a *API) adminSSOProvidersGet(w http.ResponseWriter, r *http.Request) error {
-	provider := getSSOProvider(r.Context())
-
-	return sendJSON(w, http.StatusOK, provider)
-}
+	// TODO: fetch new metadata if validUntil < time.Now()
 
-// adminSSOProvidersUpdate updates a provider with the provided diff values.
-func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) error {
-	ctx := r.Context()
+	serviceProvider := a.getSAMLServiceProvider(entityDescriptor, false /* <- idpInitiated */)
 
-	body, err := getBodyBytes(r)
+	authnRequest, err := serviceProvider.MakeAuthenticationRequest(
+		serviceProvider.GetSSOBindingLocation(saml.HTTPRedirectBinding),
+		saml.HTTPRedirectBinding,
+		saml.HTTPPostBinding,
+	)
 	if err != nil {
-		return internalServerError("Unable to read request body").WithInternalError(err)
-	}
-
-	var params CreateSSOProviderParams
-	if err := json.Unmarshal(body, &params); err != nil {
-		return badRequestError("Unable to parse JSON").WithInternalError(err)
-	}
-
-	if err := params.validate(true /* <- forUpdate */); err != nil {
-		return err
+		return internalServerError("Error creating SAML Authentication Request").WithInternalError(err)
 	}
 
-	modified := false
-
-	provider := getSSOProvider(ctx)
-
-	if params.ResourceID != "" && (provider.ResourceID == nil || params.ResourceID != *provider.ResourceID) {
-		// resource ID is being updated
-
-		existingProvider, err := models.FindSSOProviderByResourceID(a.db, params.ResourceID)
-		if err != nil && !models.IsNotFoundError(err) {
-			return err
-		}
-
-		if existingProvider != nil {
-			return badRequestError("SSO Provider Resource ID (%s) can't be updated, already assigned to another provider (%s)", params.ResourceID, existingProvider.ID.String())
-		}
-
-		provider.ResourceID = &params.ResourceID
-		modified = true
+	relayState := models.SAMLRelayState{
+		SSOProviderID: ssoProvider.ID,
+		RequestID:     authnRequest.ID,
+		FromIPAddress: utilities.GetIPAddress(r),
+		RedirectTo:    params.RedirectTo,
 	}
 
-	if params.MetadataXML != "" || params.MetadataURL != "" {
-		// metadata is being updated
-		rawMetadata, metadata, err := params.metadata(ctx)
-		if err != nil {
-			return err
+	if err := db.Transaction(func(tx *storage.Connection) error {
+		if terr := tx.Create(&relayState); terr != nil {
+			return internalServerError("Error creating SAML relay state from sign up").WithInternalError(err)
 		}
 
-		if provider.SAMLProvider.EntityID != metadata.EntityID {
-			return badRequestError("SAML Metadata can be updated only if the EntityID matches for the provider; expected '%s' but got '%s'", provider.SAMLProvider.EntityID, metadata.EntityID)
-		}
-
-		if params.MetadataURL != "" {
-			provider.SAMLProvider.MetadataURL = &params.MetadataURL
-		}
-
-		provider.SAMLProvider.MetadataXML = string(rawMetadata)
-		modified = true
+		return nil
+	}); err != nil {
+		return err
 	}
 
-	var createDomains []string
-	keepDomains := make(map[string]bool)
-
-	for _, domain := range params.Domains {
-		existingProvider, err := models.FindSSOProviderByDomain(a.db, domain)
-		if err != nil && !models.IsNotFoundError(err) {
-			return err
-		}
-		if existingProvider != nil {
-			if existingProvider.ID == provider.ID {
-				keepDomains[domain] = true
-			} else {
-				return badRequestError("SSO domain '%s' already assigned to another provider (%s)", domain, existingProvider.ID.String())
-			}
-		} else {
-			createDomains = append(createDomains, domain)
-		}
+	ssoRedirectURL, err := authnRequest.Redirect(relayState.ID.String(), serviceProvider)
+	if err != nil {
+		return internalServerError("Error creating SAML authentication request redirect URL").WithInternalError(err)
 	}
 
-	var updatedDomains []models.SSODomain
+	skipHTTPRedirect := false
 
-	for _, domain := range provider.SSODomains {
-		if keepDomains[domain.Domain] {
-			updatedDomains = append(updatedDomains, domain)
-		}
+	if params.SkipHTTPRedirect != nil {
+		skipHTTPRedirect = *params.SkipHTTPRedirect
 	}
 
-	for _, domain := range createDomains {
-		updatedDomains = append(updatedDomains, models.SSODomain{
-			Domain: domain,
+	if skipHTTPRedirect {
+		return sendJSON(w, http.StatusOK, SingleSignOnResponse{
+			URL: ssoRedirectURL.String(),
 		})
 	}
 
-	modified = modified || len(createDomains) > 0
-
-	provider.SSODomains = updatedDomains
-
-	if modified {
-		if err := a.db.Transaction(func(tx *storage.Connection) error {
-			return tx.Eager().Update(provider)
-		}); err != nil {
-			return err
-		}
-	}
-
-	return sendJSON(w, http.StatusOK, provider)
-}
-
-// adminSSOProvidersDelete deletes a SAML identity provider.
-func (a *API) adminSSOProvidersDelete(w http.ResponseWriter, r *http.Request) error {
-	provider := getSSOProvider(r.Context())
-
-	if err := a.db.Transaction(func(tx *storage.Connection) error {
-		return tx.Eager().Destroy(provider)
-	}); err != nil {
-		return err
-	}
-
-	return sendJSON(w, http.StatusOK, provider)
+	http.Redirect(w, r, ssoRedirectURL.String(), http.StatusSeeOther)
+	return nil
 }
diff --git a/api/ssoadmin.go b/api/ssoadmin.go
new file mode 100644
index 00000000..1ac00640
--- /dev/null
+++ b/api/ssoadmin.go
@@ -0,0 +1,407 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/url"
+	"regexp"
+	"unicode/utf8"
+
+	"github.com/crewjam/saml"
+	"github.com/crewjam/saml/samlsp"
+	"github.com/go-chi/chi"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/observability"
+	"github.com/netlify/gotrue/storage"
+)
+
+var (
+	ssoProviderResourceIDPattern = regexp.MustCompile("(?i)^[a-z0-9_-]{5,256}$")
+)
+
+// loadSSOProvider looks for an idp_id parameter in the URL route and loads the SSO provider
+// with that ID (or resource ID) and adds it to the context.
+func (a *API) loadSSOProvider(w http.ResponseWriter, r *http.Request) (context.Context, error) {
+	var err error
+	var provider *models.SSOProvider
+
+	idpParam := chi.URLParam(r, "idp_id")
+
+	if idpID, err := uuid.FromString(idpParam); err == nil {
+		// idpParam is a UUIDv4
+		provider, err = models.FindSSOProviderByID(a.db, idpID)
+		if err != nil {
+			if !models.IsNotFoundError(err) {
+				return nil, internalServerError("Database error finding SSO Identity Provider").WithInternalError(err)
+			}
+			// not found with the ID, maybe it's a ResourceID?
+		}
+	}
+
+	if provider == nil {
+		// provider wasn't found, consider idpParam is a resource ID
+
+		if !ssoProviderResourceIDPattern.MatchString(idpParam) {
+			// it is definitely not a resource ID
+			return nil, notFoundError("SSO Identity Provider not found")
+		}
+
+		provider, err = models.FindSSOProviderByResourceID(a.db, idpParam)
+		if err != nil {
+			if models.IsNotFoundError(err) {
+				return nil, notFoundError("SSO Identity Provider not found")
+			} else {
+				return nil, internalServerError("Database error finding SSO Identity Provider").WithInternalError(err)
+			}
+		}
+	}
+
+	observability.LogEntrySetField(r, "sso_provider_id", provider.ID.String())
+	if provider.ResourceID != nil {
+		resourceID := *provider.ResourceID
+		observability.LogEntrySetField(r, "sso_provider_resource_id", resourceID)
+	}
+
+	return withSSOProvider(r.Context(), provider), nil
+}
+
+// adminSSOProvidersList lists all SAML SSO Identity Providers in the system. Does
+// not deal with pagination at this time.
+func (a *API) adminSSOProvidersList(w http.ResponseWriter, r *http.Request) error {
+	providers, err := models.FindAllSAMLProviders(a.db)
+	if err != nil {
+		return err
+	}
+
+	for i := range providers {
+		// remove metadata XML so that the returned JSON is not ginormous
+		providers[i].SAMLProvider.MetadataXML = ""
+	}
+
+	return sendJSON(w, http.StatusOK, map[string]interface{}{
+		"items": providers,
+	})
+}
+
+type CreateSSOProviderParams struct {
+	ResourceID string `json:"resource_id"`
+	Type       string `json:"type"`
+
+	MetadataURL      string                      `json:"metadata_url"`
+	MetadataXML      string                      `json:"metadata_xml"`
+	Domains          []string                    `json:"domains"`
+	AttributeMapping models.SAMLAttributeMapping `json:"attribute_mapping"`
+}
+
+func (p *CreateSSOProviderParams) validate(forUpdate bool) error {
+	if !forUpdate && p.Type != "saml" {
+		return badRequestError("Only 'saml' supported for SSO provider type")
+	} else if p.ResourceID != "" && !ssoProviderResourceIDPattern.MatchString(p.ResourceID) {
+		return badRequestError("Resource IDs can only contain letters, digits, dashes and underscores, have a minimum lenth of 5 and a maximum of 256")
+	} else if p.MetadataURL != "" && p.MetadataXML != "" {
+		return badRequestError("Only one of metadata_xml or metadata_url needs to be set")
+	} else if !forUpdate && p.MetadataURL == "" && p.MetadataXML == "" {
+		return badRequestError("Either metadata_xml or metadata_url must be set")
+	} else if p.MetadataURL != "" {
+		metadataURL, err := url.ParseRequestURI(p.MetadataURL)
+		if err != nil {
+			return badRequestError("metadata_url is not a valid URL")
+		}
+
+		if metadataURL.Scheme != "https" {
+			return badRequestError("metadata_url is not a HTTPS URL")
+		}
+	}
+
+	// TODO validate p.AttributeMapping
+	// TODO validate domains
+
+	return nil
+}
+
+func (p *CreateSSOProviderParams) metadata(ctx context.Context) ([]byte, *saml.EntityDescriptor, error) {
+	var rawMetadata []byte
+	var err error
+
+	if p.MetadataXML != "" {
+		rawMetadata = []byte(p.MetadataXML)
+	} else if p.MetadataURL != "" {
+		rawMetadata, err = fetchSAMLMetadata(ctx, p.MetadataURL)
+		if err != nil {
+			return nil, nil, err
+		}
+	} else {
+		// impossible situation if you called validate() prior
+		return nil, nil, nil
+	}
+
+	metadata, err := parseSAMLMetadata(rawMetadata)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return rawMetadata, metadata, nil
+}
+
+func parseSAMLMetadata(rawMetadata []byte) (*saml.EntityDescriptor, error) {
+	if !utf8.Valid(rawMetadata) {
+		return nil, badRequestError("SAML Metadata XML contains invalid UTF-8 characters, which are not supported at this time")
+	}
+
+	metadata, err := samlsp.ParseMetadata(rawMetadata)
+	if err != nil {
+		return nil, err
+	}
+
+	if metadata.EntityID == "" {
+		return nil, badRequestError("SAML Metadata does not contain an EntityID")
+	}
+
+	if len(metadata.IDPSSODescriptors) < 1 {
+		return nil, badRequestError("SAML Metadata does not contain any IDPSSODescriptor")
+	}
+
+	if len(metadata.IDPSSODescriptors) > 1 {
+		return nil, badRequestError("SAML Metadata contains multiple IDPSSODescriptors")
+	}
+
+	return metadata, nil
+}
+
+func fetchSAMLMetadata(ctx context.Context, url string) ([]byte, error) {
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return nil, badRequestError("Unable to create a request to metadata_url").WithInternalError(err)
+	}
+
+	req = req.WithContext(ctx)
+
+	req.Header.Set("Accept", "application/xml;charset=UTF-8")
+	req.Header.Set("Accept-Charset", "UTF-8")
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return nil, badRequestError("HTTP %v error fetching SAML Metadata from URL '%s'", resp.StatusCode, url)
+	}
+
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	return data, nil
+}
+
+// adminSSOProvidersCreate creates a new SAML Identity Provider in the system.
+func (a *API) adminSSOProvidersCreate(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return internalServerError("Unable to read request body").WithInternalError(err)
+	}
+
+	var params CreateSSOProviderParams
+	if err := json.Unmarshal(body, &params); err != nil {
+		return badRequestError("Unable to parse JSON").WithInternalError(err)
+	}
+
+	if err := params.validate(false /* <- forUpdate */); err != nil {
+		return err
+	}
+
+	rawMetadata, metadata, err := params.metadata(ctx)
+	if err != nil {
+		return err
+	}
+
+	if params.ResourceID != "" {
+		if _, err := models.FindSSOProviderByResourceID(a.db, params.ResourceID); err != nil {
+			if !models.IsNotFoundError(err) {
+				return internalServerError("Unable to find SSO provider by resource ID").WithInternalError(err)
+			}
+		} else {
+			return badRequestError("A SSO provider with this resource ID (%s) already exists", params.ResourceID)
+		}
+	}
+
+	existingProvider, err := models.FindSAMLProviderByEntityID(a.db, metadata.EntityID)
+	if err != nil && !models.IsNotFoundError(err) {
+		return err
+	}
+	if existingProvider != nil {
+		return badRequestError("SAML Identity Provider with this EntityID (%s) already exists", metadata.EntityID)
+	}
+
+	provider := &models.SSOProvider{
+		// TODO handle Name, Description, Attribute Mapping
+		SAMLProvider: models.SAMLProvider{
+			EntityID:    metadata.EntityID,
+			MetadataXML: string(rawMetadata),
+		},
+	}
+
+	if params.ResourceID != "" {
+		provider.ResourceID = &params.ResourceID
+	}
+
+	if params.MetadataURL != "" {
+		provider.SAMLProvider.MetadataURL = &params.MetadataURL
+	}
+
+	for _, domain := range params.Domains {
+
+		existingProvider, err := models.FindSSOProviderByDomain(a.db, domain)
+		if err != nil && !models.IsNotFoundError(err) {
+			return err
+		}
+		if existingProvider != nil {
+			return badRequestError("SSO Domain '%s' is already assigned to an SSO identity provider (%s)", domain, existingProvider.ID.String())
+		}
+
+		provider.SSODomains = append(provider.SSODomains, models.SSODomain{
+			Domain: domain,
+		})
+	}
+
+	if err := a.db.Transaction(func(tx *storage.Connection) error {
+		return tx.Eager().Create(provider)
+	}); err != nil {
+		return err
+	}
+
+	return sendJSON(w, http.StatusCreated, provider)
+}
+
+// adminSSOProvidersGet returns an existing SAML Identity Provider in the system.
+func (a *API) adminSSOProvidersGet(w http.ResponseWriter, r *http.Request) error {
+	provider := getSSOProvider(r.Context())
+
+	return sendJSON(w, http.StatusOK, provider)
+}
+
+// adminSSOProvidersUpdate updates a provider with the provided diff values.
+func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return internalServerError("Unable to read request body").WithInternalError(err)
+	}
+
+	var params CreateSSOProviderParams
+	if err := json.Unmarshal(body, &params); err != nil {
+		return badRequestError("Unable to parse JSON").WithInternalError(err)
+	}
+
+	if err := params.validate(true /* <- forUpdate */); err != nil {
+		return err
+	}
+
+	modified := false
+
+	provider := getSSOProvider(ctx)
+
+	if params.ResourceID != "" && (provider.ResourceID == nil || params.ResourceID != *provider.ResourceID) {
+		// resource ID is being updated
+
+		existingProvider, err := models.FindSSOProviderByResourceID(a.db, params.ResourceID)
+		if err != nil && !models.IsNotFoundError(err) {
+			return err
+		}
+
+		if existingProvider != nil {
+			return badRequestError("SSO Provider Resource ID (%s) can't be updated, already assigned to another provider (%s)", params.ResourceID, existingProvider.ID.String())
+		}
+
+		provider.ResourceID = &params.ResourceID
+		modified = true
+	}
+
+	if params.MetadataXML != "" || params.MetadataURL != "" {
+		// metadata is being updated
+		rawMetadata, metadata, err := params.metadata(ctx)
+		if err != nil {
+			return err
+		}
+
+		if provider.SAMLProvider.EntityID != metadata.EntityID {
+			return badRequestError("SAML Metadata can be updated only if the EntityID matches for the provider; expected '%s' but got '%s'", provider.SAMLProvider.EntityID, metadata.EntityID)
+		}
+
+		if params.MetadataURL != "" {
+			provider.SAMLProvider.MetadataURL = &params.MetadataURL
+		}
+
+		provider.SAMLProvider.MetadataXML = string(rawMetadata)
+		modified = true
+	}
+
+	var createDomains []string
+	keepDomains := make(map[string]bool)
+
+	for _, domain := range params.Domains {
+		existingProvider, err := models.FindSSOProviderByDomain(a.db, domain)
+		if err != nil && !models.IsNotFoundError(err) {
+			return err
+		}
+		if existingProvider != nil {
+			if existingProvider.ID == provider.ID {
+				keepDomains[domain] = true
+			} else {
+				return badRequestError("SSO domain '%s' already assigned to another provider (%s)", domain, existingProvider.ID.String())
+			}
+		} else {
+			createDomains = append(createDomains, domain)
+		}
+	}
+
+	var updatedDomains []models.SSODomain
+
+	for _, domain := range provider.SSODomains {
+		if keepDomains[domain.Domain] {
+			updatedDomains = append(updatedDomains, domain)
+		}
+	}
+
+	for _, domain := range createDomains {
+		updatedDomains = append(updatedDomains, models.SSODomain{
+			Domain: domain,
+		})
+	}
+
+	modified = modified || len(createDomains) > 0
+
+	provider.SSODomains = updatedDomains
+
+	if modified {
+		if err := a.db.Transaction(func(tx *storage.Connection) error {
+			return tx.Eager().Update(provider)
+		}); err != nil {
+			return err
+		}
+	}
+
+	return sendJSON(w, http.StatusOK, provider)
+}
+
+// adminSSOProvidersDelete deletes a SAML identity provider.
+func (a *API) adminSSOProvidersDelete(w http.ResponseWriter, r *http.Request) error {
+	provider := getSSOProvider(r.Context())
+
+	if err := a.db.Transaction(func(tx *storage.Connection) error {
+		return tx.Eager().Destroy(provider)
+	}); err != nil {
+		return err
+	}
+
+	return sendJSON(w, http.StatusOK, provider)
+}
diff --git a/conf/configuration.go b/conf/configuration.go
index 7937bc39..9b813e12 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -2,8 +2,10 @@ package conf
 
 import (
 	"errors"
+	"fmt"
 	"net/url"
 	"os"
+	"strings"
 	"time"
 
 	"github.com/gobwas/glob"
@@ -96,6 +98,7 @@ type GlobalConfiguration struct {
 	RateLimitEmailSent    float64 `split_words:"true" default:"30"`
 	RateLimitVerify       float64 `split_words:"true" default:"30"`
 	RateLimitTokenRefresh float64 `split_words:"true" default:"30"`
+	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"`
@@ -219,6 +222,24 @@ type CaptchaConfiguration struct {
 	Secret   string `json:"provider_secret"`
 }
 
+func (c *CaptchaConfiguration) Validate() error {
+	if !c.Enabled {
+		return nil
+	}
+
+	if c.Provider != "hcaptcha" {
+		return fmt.Errorf("unsupported captcha provider: %s", c.Provider)
+	}
+
+	c.Secret = strings.TrimSpace(c.Secret)
+
+	if c.Secret == "" {
+		return errors.New("hcaptcha provider secret is empty")
+	}
+
+	return nil
+}
+
 type SecurityConfiguration struct {
 	Captcha                               CaptchaConfiguration `json:"captcha"`
 	RefreshTokenRotationEnabled           bool                 `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
@@ -226,6 +247,10 @@ type SecurityConfiguration struct {
 	UpdatePasswordRequireReauthentication bool                 `json:"update_password_require_reauthentication" split_words:"true"`
 }
 
+func (c *SecurityConfiguration) Validate() error {
+	return c.Captcha.Validate()
+}
+
 func loadEnvironment(filename string) error {
 	var err error
 	if filename != "" {
@@ -393,6 +418,7 @@ func (c *GlobalConfiguration) Validate() error {
 		&c.Metrics,
 		&c.SMTP,
 		&c.SAML,
+		&c.Security,
 	}
 
 	for _, validatable := range validatables {
diff --git a/conf/saml.go b/conf/saml.go
index f78ff7f3..88045e94 100644
--- a/conf/saml.go
+++ b/conf/saml.go
@@ -3,10 +3,12 @@ package conf
 import (
 	"crypto/rsa"
 	"crypto/x509"
+	"crypto/x509/pkix"
 	"encoding/base64"
 	"errors"
 	"fmt"
 	"math/big"
+	"net"
 	"net/url"
 	"time"
 )
@@ -73,6 +75,12 @@ func (c *SAMLConfiguration) PopulateFields(externalURL string) error {
 		return fmt.Errorf("saml: unable to parse external URL for SAML, check API_EXTERNAL_URL: %w", err)
 	}
 
+	host := ""
+	host, _, err = net.SplitHostPort(parsedURL.Host)
+	if err != nil {
+		host = parsedURL.Host
+	}
+
 	// SAML does not care much about the contents of the certificate, it
 	// only uses it as a vessel for the public key; therefore we set these
 	// fixed values.
@@ -84,11 +92,14 @@ func (c *SAMLConfiguration) PopulateFields(externalURL string) error {
 		SerialNumber: big.NewInt(0),
 		IsCA:         false,
 		DNSNames: []string{
-			"_samlsp." + parsedURL.Host,
+			"_samlsp." + host,
 		},
 		KeyUsage:  x509.KeyUsageDigitalSignature,
 		NotBefore: time.UnixMilli(0).UTC(),
 		NotAfter:  time.UnixMilli(0).UTC().AddDate(200, 0, 0),
+		Subject: pkix.Name{
+			CommonName: "SAML 2.0 Certificate for " + host,
+		},
 	}
 
 	certDer, err := x509.CreateCertificate(nil, certTemplate, certTemplate, c.RSAPublicKey, c.RSAPrivateKey)
diff --git a/crypto/password.go b/crypto/password.go
new file mode 100644
index 00000000..8f6f6446
--- /dev/null
+++ b/crypto/password.go
@@ -0,0 +1,111 @@
+package crypto
+
+import (
+	"context"
+	"errors"
+
+	"github.com/netlify/gotrue/observability"
+	"go.opentelemetry.io/otel/attribute"
+	metricinstrument "go.opentelemetry.io/otel/metric/instrument"
+	"golang.org/x/crypto/bcrypt"
+)
+
+type HashCost = int
+
+const (
+	// DefaultHashCost represents the default
+	// hashing cost for any hashing algorithm.
+	DefaultHashCost HashCost = iota
+
+	// QuickHashCosts represents the quickest
+	// hashing cost for any hashing algorithm,
+	// useful for tests only.
+	QuickHashCost HashCost = iota
+)
+
+// PasswordHashCost is the current pasword hashing cost
+// for all new hashes generated with
+// GenerateHashFromPassword.
+var PasswordHashCost = DefaultHashCost
+
+type metricCounter interface {
+	Add(ctx context.Context, incr int64, attrs ...attribute.KeyValue)
+}
+
+func obtainMetricCounter(name, desc string) metricCounter {
+	counter, err := observability.Meter("gotrue").SyncInt64().Counter(name, metricinstrument.WithDescription(desc))
+	if err != nil {
+		panic(err)
+	}
+
+	return counter
+}
+
+var (
+	generateFromPasswordSubmittedCounter = obtainMetricCounter("gotrue_generate_from_password_submitted", "Number of submitted GenerateFromPassword hashing attempts")
+	generateFromPasswordCompletedCounter = obtainMetricCounter("gotrue_generate_from_password_completed", "Number of completed GenerateFromPassword hashing attempts")
+)
+
+var (
+	compareHashAndPasswordSubmittedCounter = obtainMetricCounter("gotrue_compare_hash_and_password_submitted", "Number of submitted CompareHashAndPassword hashing attempts")
+	compareHashAndPasswordCompletedCounter = obtainMetricCounter("gotrue_compare_hash_and_password_completed", "Number of completed CompareHashAndPassword hashing attempts")
+)
+
+// CompareHashAndPassword compares the hash and
+// password, returns nil if equal otherwise an error. Context can be used to
+// cancel the hashing if the algorithm supports it.
+func CompareHashAndPassword(ctx context.Context, hash, password string) error {
+	hashCost, err := bcrypt.Cost([]byte(hash))
+	if err != nil {
+		return err
+	}
+
+	attributes := []attribute.KeyValue{
+		attribute.String("alg", "bcrypt"),
+		attribute.Int("bcrypt_cost", hashCost),
+	}
+
+	compareHashAndPasswordSubmittedCounter.Add(ctx, 1, attributes...)
+	defer func() {
+		attributes = append(attributes, attribute.Bool(
+			"match",
+			!errors.Is(err, bcrypt.ErrMismatchedHashAndPassword),
+		))
+
+		compareHashAndPasswordCompletedCounter.Add(ctx, 1, attributes...)
+	}()
+
+	err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
+
+	return err
+}
+
+// GenerateFromPassword generates a password hash from a
+// password, using PasswordHashCost. Context can be used to cancel the hashing
+// if the algorithm supports it.
+func GenerateFromPassword(ctx context.Context, password string) (string, error) {
+	var hashCost int
+
+	switch PasswordHashCost {
+	case QuickHashCost:
+		hashCost = bcrypt.MinCost
+
+	default:
+		hashCost = bcrypt.DefaultCost
+	}
+
+	attributes := []attribute.KeyValue{
+		attribute.String("alg", "bcrypt"),
+		attribute.Int("bcrypt_cost", hashCost),
+	}
+
+	generateFromPasswordSubmittedCounter.Add(ctx, 1, attributes...)
+	defer generateFromPasswordCompletedCounter.Add(ctx, 1, attributes...)
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), hashCost)
+	if err != nil {
+		return "", err
+	}
+
+	return string(hash), nil
+}
diff --git a/mailer/template.go b/mailer/template.go
index bae1e9ff..65b5fd12 100644
--- a/mailer/template.go
+++ b/mailer/template.go
@@ -91,6 +91,7 @@ func (m *TemplateMailer) InviteMail(user *models.User, otp, referrerURL string)
 		"ConfirmationURL": url,
 		"Email":           user.Email,
 		"Token":           otp,
+		"TokenHash":       user.ConfirmationToken,
 		"Data":            user.UserMetaData,
 	}
 
@@ -116,6 +117,7 @@ func (m *TemplateMailer) ConfirmationMail(user *models.User, otp, referrerURL st
 		"ConfirmationURL": url,
 		"Email":           user.Email,
 		"Token":           otp,
+		"TokenHash":       user.ConfirmationToken,
 		"Data":            user.UserMetaData,
 	}
 
@@ -189,13 +191,14 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent,
 		if err != nil {
 			return err
 		}
-		go func(address, token, template string) {
+		go func(address, token, tokenHash, template string) {
 			data := map[string]interface{}{
 				"SiteURL":         m.Config.SiteURL,
 				"ConfirmationURL": url,
 				"Email":           user.GetEmail(),
 				"NewEmail":        user.EmailChange,
 				"Token":           token,
+				"TokenHash":       tokenHash,
 				"Data":            user.UserMetaData,
 			}
 			errors <- m.Mailer.Mail(
@@ -205,7 +208,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent,
 				defaultEmailChangeMail,
 				data,
 			)
-		}(email.Address, email.Otp, email.Template)
+		}(email.Address, email.Otp, email.TokenHash, email.Template)
 	}
 
 	for i := 0; i < len(emails); i++ {
@@ -231,6 +234,7 @@ func (m *TemplateMailer) RecoveryMail(user *models.User, otp, referrerURL string
 		"ConfirmationURL": url,
 		"Email":           user.Email,
 		"Token":           otp,
+		"TokenHash":       user.RecoveryToken,
 		"Data":            user.UserMetaData,
 	}
 
@@ -256,6 +260,7 @@ func (m *TemplateMailer) MagicLinkMail(user *models.User, otp, referrerURL strin
 		"ConfirmationURL": url,
 		"Email":           user.Email,
 		"Token":           otp,
+		"TokenHash":       user.RecoveryToken,
 		"Data":            user.UserMetaData,
 	}
 
diff --git a/models/user.go b/models/user.go
index b89c94fd..a3245cd1 100644
--- a/models/user.go
+++ b/models/user.go
@@ -1,19 +1,18 @@
 package models
 
 import (
+	"context"
 	"database/sql"
 	"strings"
 	"time"
 
 	"github.com/gobuffalo/pop/v5"
 	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/crypto"
 	"github.com/netlify/gotrue/storage"
 	"github.com/pkg/errors"
-	"golang.org/x/crypto/bcrypt"
 )
 
-var PasswordHashCost = bcrypt.DefaultCost
-
 // User respresents a registered user with email/password authentication
 type User struct {
 	ID uuid.UUID `json:"id" db:"id"`
@@ -71,7 +70,7 @@ func NewUser(phone, email, password, aud string, userData map[string]interface{}
 	if err != nil {
 		return nil, errors.Wrap(err, "Error generating unique id")
 	}
-	pw, err := hashPassword(password)
+	pw, err := crypto.GenerateFromPassword(context.Background(), password)
 	if err != nil {
 		return nil, err
 	}
@@ -220,18 +219,9 @@ func (u *User) SetPhone(tx *storage.Connection, phone string) error {
 	return tx.UpdateOnly(u, "phone")
 }
 
-// hashPassword generates a hashed password from a plaintext string
-func hashPassword(password string) (string, error) {
-	pw, err := bcrypt.GenerateFromPassword([]byte(password), PasswordHashCost)
-	if err != nil {
-		return "", err
-	}
-	return string(pw), nil
-}
-
 // UpdatePassword updates the user's password
 func (u *User) UpdatePassword(tx *storage.Connection, password string) error {
-	pw, err := hashPassword(password)
+	pw, err := crypto.GenerateFromPassword(context.Background(), password)
 	if err != nil {
 		return err
 	}
@@ -247,7 +237,7 @@ func (u *User) UpdatePhone(tx *storage.Connection, phone string) error {
 
 // Authenticate a user from a password
 func (u *User) Authenticate(password string) bool {
-	err := bcrypt.CompareHashAndPassword([]byte(u.EncryptedPassword), []byte(password))
+	err := crypto.CompareHashAndPassword(context.Background(), u.EncryptedPassword, password)
 	return err == nil
 }
 
diff --git a/security/hcaptcha.go b/security/hcaptcha.go
index ba8e6da1..427e762f 100644
--- a/security/hcaptcha.go
+++ b/security/hcaptcha.go
@@ -1,10 +1,7 @@
 package security
 
 import (
-	"bytes"
 	"encoding/json"
-	"fmt"
-	"io"
 	"log"
 	"net/http"
 	"net/url"
@@ -15,7 +12,6 @@ import (
 
 	"github.com/netlify/gotrue/utilities"
 	"github.com/pkg/errors"
-	"github.com/sirupsen/logrus"
 )
 
 type GotrueRequest struct {
@@ -32,14 +28,6 @@ type VerificationResponse struct {
 	Hostname   string   `json:"hostname"`
 }
 
-type VerificationResult int
-
-const (
-	UserRequestFailed VerificationResult = iota
-	VerificationProcessFailure
-	SuccessfullyVerified
-)
-
 var Client *http.Client
 
 func init() {
@@ -56,29 +44,30 @@ func init() {
 	Client = &http.Client{Timeout: defaultTimeout}
 }
 
-func VerifyRequest(r *http.Request, secretKey string) (VerificationResult, error) {
-	res := GotrueRequest{}
-	bodyBytes, err := io.ReadAll(r.Body)
+func VerifyRequest(r *http.Request, secretKey string) (VerificationResponse, error) {
+	bodyBytes, err := utilities.GetBodyBytes(r)
 	if err != nil {
-		return UserRequestFailed, err
+		return VerificationResponse{}, err
 	}
-	if err := r.Body.Close(); err != nil {
-		return UserRequestFailed, err
+
+	var requestBody GotrueRequest
+
+	if err := json.Unmarshal(bodyBytes, &requestBody); err != nil {
+		return VerificationResponse{}, errors.Wrap(err, "request body was not JSON")
 	}
 
-	// re-init body so downstream route handlers don't get borked
-	r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
+	captchaResponse := strings.TrimSpace(requestBody.Security.Token)
 
-	jsonDecoder := json.NewDecoder(bytes.NewBuffer(bodyBytes))
-	err = jsonDecoder.Decode(&res)
-	if err != nil || strings.TrimSpace(res.Security.Token) == "" {
-		return UserRequestFailed, errors.Wrap(err, "couldn't decode captcha info")
+	if captchaResponse == "" {
+		return VerificationResponse{}, errors.New("no hCaptcha response (captcha_token) found in request")
 	}
+
 	clientIP := utilities.GetIPAddress(r)
-	return verifyCaptchaCode(res.Security.Token, secretKey, clientIP)
+
+	return verifyCaptchaCode(captchaResponse, secretKey, clientIP)
 }
 
-func verifyCaptchaCode(token string, secretKey string, clientIP string) (VerificationResult, error) {
+func verifyCaptchaCode(token string, secretKey string, clientIP string) (VerificationResponse, error) {
 	data := url.Values{}
 	data.Set("secret", secretKey)
 	data.Set("response", token)
@@ -87,24 +76,21 @@ func verifyCaptchaCode(token string, secretKey string, clientIP string) (Verific
 
 	r, err := http.NewRequest("POST", "https://hcaptcha.com/siteverify", strings.NewReader(data.Encode()))
 	if err != nil {
-		return VerificationProcessFailure, errors.Wrap(err, "couldn't initialize request object for hcaptcha check")
+		return VerificationResponse{}, errors.Wrap(err, "couldn't initialize request object for hcaptcha check")
 	}
 	r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
 	r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
 	res, err := Client.Do(r)
 	if err != nil {
-		return VerificationProcessFailure, errors.Wrap(err, "failed to verify hcaptcha token")
+		return VerificationResponse{}, errors.Wrap(err, "failed to verify hcaptcha response")
 	}
-	verResult := VerificationResponse{}
 	defer res.Body.Close()
-	decoder := json.NewDecoder(res.Body)
-	err = decoder.Decode(&verResult)
-	if err != nil {
-		return VerificationProcessFailure, errors.Wrap(err, "failed to decode hcaptcha response")
-	}
-	logrus.WithField("result", verResult).Info("obtained hcaptcha verification result")
-	if !verResult.Success {
-		return UserRequestFailed, fmt.Errorf("user request suppressed by hcaptcha")
+
+	var verificationResponse VerificationResponse
+
+	if err := json.NewDecoder(res.Body).Decode(&verificationResponse); err != nil {
+		return VerificationResponse{}, errors.Wrap(err, "failed to decode hcaptcha response: not JSON")
 	}
-	return SuccessfullyVerified, nil
+
+	return verificationResponse, nil
 }
diff --git a/utilities/request.go b/utilities/request.go
index 3e244b7b..cf61ba6e 100644
--- a/utilities/request.go
+++ b/utilities/request.go
@@ -1,6 +1,8 @@
 package utilities
 
 import (
+	"bytes"
+	"io"
 	"net"
 	"net/http"
 	"strings"
@@ -33,3 +35,22 @@ func GetIPAddress(r *http.Request) string {
 
 	return ip
 }
+
+// GetBodyBytes reads the whole request body properly into a byte array.
+func GetBodyBytes(req *http.Request) ([]byte, error) {
+	if req.Body == nil || req.Body == http.NoBody {
+		return nil, nil
+	}
+
+	originalBody := req.Body
+	defer originalBody.Close()
+
+	buf, err := io.ReadAll(originalBody)
+	if err != nil {
+		return nil, err
+	}
+
+	req.Body = io.NopCloser(bytes.NewReader(buf))
+
+	return buf, nil
+}


Test patch:
diff --git a/api/api_test.go b/api/api_test.go
index d8faf948..a7b6d3c9 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -5,11 +5,10 @@ import (
 	"testing"
 
 	"github.com/netlify/gotrue/conf"
-	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/crypto"
 	"github.com/netlify/gotrue/storage"
 	"github.com/netlify/gotrue/storage/test"
 	"github.com/stretchr/testify/require"
-	"golang.org/x/crypto/bcrypt"
 )
 
 const (
@@ -18,7 +17,7 @@ const (
 )
 
 func init() {
-	models.PasswordHashCost = bcrypt.MinCost
+	crypto.PasswordHashCost = crypto.QuickHashCost
 }
 
 // setupAPIForTest creates a new API to run tests with.
diff --git a/api/middleware_test.go b/api/middleware_test.go
index d5cdf3f6..fa95e1a5 100644
--- a/api/middleware_test.go
+++ b/api/middleware_test.go
@@ -114,25 +114,6 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
 		expectedCode int
 		expectedMsg  string
 	}{
-		{
-			"Unsupported provider",
-			&conf.CaptchaConfiguration{
-				Enabled:  true,
-				Provider: "test",
-			},
-			http.StatusInternalServerError,
-			"server misconfigured",
-		},
-		{
-			"Missing secret",
-			&conf.CaptchaConfiguration{
-				Enabled:  true,
-				Provider: "hcaptcha",
-				Secret:   "",
-			},
-			http.StatusInternalServerError,
-			"server misconfigured",
-		},
 		{
 			"Captcha validation failed",
 			&conf.CaptchaConfiguration{
@@ -140,8 +121,8 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
 				Provider: "hcaptcha",
 				Secret:   "test",
 			},
-			http.StatusInternalServerError,
-			"request validation failure",
+			http.StatusBadRequest,
+			"hCaptcha protection: request disallowed (not-using-dummy-secret)",
 		},
 	}
 	for _, c := range cases {
diff --git a/api/sso_test.go b/api/sso_test.go
index 158552c7..e136b18e 100644
--- a/api/sso_test.go
+++ b/api/sso_test.go
@@ -7,6 +7,7 @@ import (
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	jwt "github.com/golang-jwt/jwt"
@@ -515,6 +516,163 @@ func (ts *SSOTestSuite) TestAdminDeleteSSOProvider() {
 	}
 }
 
+func (ts *SSOTestSuite) TestSingleSignOn() {
+	providers := []struct {
+		ID      string
+		Request map[string]interface{}
+	}{
+		{
+			// creates a SAML provider (EXAMPLE-A)
+			// does not have a domain mapping
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-A"),
+			},
+		},
+		{
+			// creates a SAML provider (EXAMPLE-B)
+			// does have a domain mapping on example.com
+			Request: map[string]interface{}{
+				"type": "saml",
+				"domains": []string{
+					"example.com",
+				},
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-B"),
+			},
+		},
+	}
+
+	for i, example := range providers {
+		body, err := json.Marshal(example.Request)
+		require.NoError(ts.T(), err)
+
+		req := httptest.NewRequest(http.MethodPost, "http://localhost/admin/sso/providers", bytes.NewBuffer(body))
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		response, err := io.ReadAll(w.Body)
+		require.NoError(ts.T(), err)
+
+		var payload struct {
+			ID string `json:"id"`
+		}
+
+		require.NoError(ts.T(), json.Unmarshal(response, &payload))
+
+		providers[i].ID = payload.ID
+	}
+
+	examples := []struct {
+		Code    int
+		Request map[string]interface{}
+		URL     string
+	}{
+		{
+			// call /sso with provider_id (EXAMPLE-A)
+			// should be successful and redirect to the EXAMPLE-A SSO URL
+			Request: map[string]interface{}{
+				"provider_id": providers[0].ID,
+			},
+			Code: http.StatusSeeOther,
+			URL:  "https://accounts.google.com/o/saml2?idpid=EXAMPLE-A",
+		},
+		{
+			// call /sso with domain=example.com (provider=EXAMPLE-B)
+			// should be successful and redirect to the EXAMPLE-B SSO URL
+			Request: map[string]interface{}{
+				"domain": "example.com",
+			},
+			Code: http.StatusSeeOther,
+			URL:  "https://accounts.google.com/o/saml2?idpid=EXAMPLE-B",
+		},
+		{
+			// call /sso with domain=example.com (provider=EXAMPLE-B)
+			// should be successful and redirect to the EXAMPLE-B SSO URL
+			Request: map[string]interface{}{
+				"domain":             "example.com",
+				"skip_http_redirect": true,
+			},
+			Code: http.StatusOK,
+			URL:  "https://accounts.google.com/o/saml2?idpid=EXAMPLE-B",
+		},
+		{
+			// call /sso with domain=example.org (no such provider)
+			// should be unsuccessful with 404
+			Request: map[string]interface{}{
+				"domain": "example.org",
+			},
+			Code: http.StatusNotFound,
+		},
+		{
+			// call /sso with a provider_id=<random-uuid> (no such provider)
+			// should be unsuccessful with 404
+			Request: map[string]interface{}{
+				"provider_id": "14d906bf-9bd5-4734-b7d1-3904e240610e",
+			},
+			Code: http.StatusNotFound,
+		},
+	}
+
+	for _, example := range examples {
+		body, err := json.Marshal(example.Request)
+		require.NoError(ts.T(), err)
+
+		req := httptest.NewRequest(http.MethodPost, "http://localhost/sso", bytes.NewBuffer(body))
+		// no authorization header intentional, this is a login endpoint
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		require.Equal(ts.T(), w.Code, example.Code)
+
+		locationURLString := ""
+
+		if example.Code == http.StatusSeeOther {
+			locationURLString = w.Header().Get("Location")
+		} else if example.Code == http.StatusOK {
+			var response struct {
+				URL string `json:"url"`
+			}
+
+			require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&response))
+
+			require.NotEmpty(ts.T(), response.URL)
+
+			locationURLString = response.URL
+		} else {
+			continue
+		}
+
+		locationURL, err := url.ParseRequestURI(locationURLString)
+		require.NoError(ts.T(), err)
+
+		locationQuery, err := url.ParseQuery(locationURL.RawQuery)
+
+		require.NoError(ts.T(), err)
+
+		samlQueryParams := []string{
+			"SAMLRequest",
+			"RelayState",
+			"SigAlg",
+			"Signature",
+		}
+
+		for _, param := range samlQueryParams {
+			require.True(ts.T(), locationQuery.Has(param))
+		}
+
+		for _, param := range samlQueryParams {
+			locationQuery.Del(param)
+		}
+
+		locationURL.RawQuery = locationQuery.Encode()
+
+		require.Equal(ts.T(), locationURL.String(), example.URL)
+	}
+}
+
 func TestSSOCreateParamsValidation(t *testing.T) {
 	// TODO
 }
diff --git a/models/user_test.go b/models/user_test.go
index eb75a599..e6de8c6d 100644
--- a/models/user_test.go
+++ b/models/user_test.go
@@ -4,18 +4,18 @@ import (
 	"testing"
 
 	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/crypto"
 	"github.com/netlify/gotrue/storage"
 	"github.com/netlify/gotrue/storage/test"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
-	"golang.org/x/crypto/bcrypt"
 )
 
 const modelsTestConfig = "../hack/test.env"
 
 func init() {
-	PasswordHashCost = bcrypt.MinCost
+	crypto.PasswordHashCost = crypto.QuickHashCost
 }
 
 type UserTestSuite struct {