Repository: supabase/gotrue
Pull request #771 (2022-10-24 16:36:03)

feat(saml): add SSO/SAML admin endpoints (disabled by default)
Adds SSO/SAML admin endpoints, which are disabled by default.

Differences from the SAML POC:

- Routes are RESTful under `/admin/sso/providers`.
- Extensive test suite that covers a minimum of 70% of all new code.

A few more test cases need to be added in follow up PRs, and the OpenAPI specs to be updated.


Patch:
diff --git a/api/api.go b/api/api.go
index 7f0f1d1d..219b6ce8 100644
--- a/api/api.go
+++ b/api/api.go
@@ -193,6 +193,23 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 			})
 
 			r.Post("/generate_link", api.GenerateLink)
+
+			if api.config.SAML.Enabled {
+				r.Route("/sso", func(r *router) {
+					r.Route("/providers", func(r *router) {
+						r.Get("/", api.adminSSOProvidersList)
+						r.Post("/", api.adminSSOProvidersCreate)
+
+						r.Route("/{idp_id}", func(r *router) {
+							r.Use(api.loadSSOProvider)
+
+							r.Get("/", api.adminSSOProvidersGet)
+							r.Put("/", api.adminSSOProvidersUpdate)
+							r.Delete("/", api.adminSSOProvidersDelete)
+						})
+					})
+				})
+			}
 		})
 	})
 
diff --git a/api/context.go b/api/context.go
index ef893e0f..d7a03c5a 100644
--- a/api/context.go
+++ b/api/context.go
@@ -27,6 +27,7 @@ const (
 	adminUserKey            = contextKey("admin_user")
 	oauthTokenKey           = contextKey("oauth_token") // for OAuth1.0, also known as request token
 	oauthVerifierKey        = contextKey("oauth_verifier")
+	ssoProviderKey          = contextKey("sso_provider")
 )
 
 // withToken adds the JWT token to the context.
@@ -209,3 +210,15 @@ func getOAuthVerifier(ctx context.Context) string {
 	}
 	return obj.(string)
 }
+
+func withSSOProvider(ctx context.Context, provider *models.SSOProvider) context.Context {
+	return context.WithValue(ctx, ssoProviderKey, provider)
+}
+
+func getSSOProvider(ctx context.Context) *models.SSOProvider {
+	obj := ctx.Value(ssoProviderKey)
+	if obj == nil {
+		return nil
+	}
+	return obj.(*models.SSOProvider)
+}
diff --git a/api/sso.go b/api/sso.go
new file mode 100644
index 00000000..1ac00640
--- /dev/null
+++ b/api/sso.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/models/connection.go b/models/connection.go
index dae66955..a372b5fa 100644
--- a/models/connection.go
+++ b/models/connection.go
@@ -43,6 +43,11 @@ func TruncateAll(conn *storage.Connection) error {
 			(&pop.Model{Value: Factor{}}).TableName(),
 			(&pop.Model{Value: Challenge{}}).TableName(),
 			(&pop.Model{Value: AMRClaim{}}).TableName(),
+			(&pop.Model{Value: SSOProvider{}}).TableName(),
+			(&pop.Model{Value: SSODomain{}}).TableName(),
+			(&pop.Model{Value: SSOSession{}}).TableName(),
+			(&pop.Model{Value: SAMLProvider{}}).TableName(),
+			(&pop.Model{Value: SAMLRelayState{}}).TableName(),
 		}
 
 		for _, tableName := range tables {
diff --git a/models/errors.go b/models/errors.go
index e4ab935e..8b6e717f 100644
--- a/models/errors.go
+++ b/models/errors.go
@@ -17,9 +17,13 @@ func IsNotFoundError(err error) bool {
 		return true
 	case IdentityNotFoundError, *IdentityNotFoundError:
 		return true
-	case ChallengeNotFoundError:
+	case ChallengeNotFoundError, *ChallengeNotFoundError:
 		return true
-	case FactorNotFoundError:
+	case FactorNotFoundError, *FactorNotFoundError:
+		return true
+	case SSOProviderNotFoundError, *SSOProviderNotFoundError:
+		return true
+	case SAMLRelayStateNotFoundError, *SAMLRelayStateNotFoundError:
 		return true
 	}
 	return false
@@ -85,3 +89,19 @@ type TotpSecretNotFoundError struct{}
 func (e TotpSecretNotFoundError) Error() string {
 	return "Totp Secret not found"
 }
+
+// SSOProviderNotFoundError represents an error when a SSO Provider can't be
+// found.
+type SSOProviderNotFoundError struct{}
+
+func (e SSOProviderNotFoundError) Error() string {
+	return "SSO Identity Provider not found"
+}
+
+// SAMLRelayStateNotFoundError represents an error when a SAML relay state
+// can't be found.
+type SAMLRelayStateNotFoundError struct{}
+
+func (e SAMLRelayStateNotFoundError) Error() string {
+	return "SAML RelayState not found"
+}
diff --git a/models/sso.go b/models/sso.go
new file mode 100644
index 00000000..f258eb06
--- /dev/null
+++ b/models/sso.go
@@ -0,0 +1,234 @@
+package models
+
+import (
+	"database/sql"
+	"database/sql/driver"
+	"encoding/json"
+	"strings"
+	"time"
+
+	"github.com/crewjam/saml"
+	"github.com/crewjam/saml/samlsp"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/storage"
+	"github.com/pkg/errors"
+)
+
+type SSOProvider struct {
+	ID         uuid.UUID `db:"id" json:"id"`
+	ResourceID *string   `db:"resource_id" json:"resource_id,omitempty"`
+
+	SAMLProvider SAMLProvider `has_one:"saml_providers" fk_id:"sso_provider_id" json:"saml,omitempty"`
+	SSODomains   []SSODomain  `has_many:"sso_domains" fk_id:"sso_provider_id" json:"domains"`
+
+	CreatedAt time.Time `db:"created_at" json:"created_at"`
+	UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
+
+func (p SSOProvider) TableName() string {
+	return "sso_providers"
+}
+
+func (p SSOProvider) Type() string {
+	return "saml"
+}
+
+type SAMLAttribute struct {
+	Name    string      `json:"name,omitempty"`
+	Names   []string    `json:"names,omitempty"`
+	Default interface{} `json:"default,omitempty"`
+}
+
+type SAMLAttributeMapping struct {
+	Keys map[string]SAMLAttribute `json:"keys,omitempty"`
+}
+
+func (m *SAMLAttributeMapping) Scan(src interface{}) error {
+	b, ok := src.([]byte)
+	if !ok {
+		return errors.New("scan source was not []byte")
+	}
+	err := json.Unmarshal(b, m)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (m SAMLAttributeMapping) Value() (driver.Value, error) {
+	b, err := json.Marshal(m)
+	if err != nil {
+		return nil, err
+	}
+	return string(b), nil
+}
+
+type SAMLProvider struct {
+	ID uuid.UUID `db:"id" json:"-"`
+
+	SSOProvider   *SSOProvider `belongs_to:"sso_providers" json:"-"`
+	SSOProviderID uuid.UUID    `db:"sso_provider_id" json:"-"`
+
+	EntityID    string  `db:"entity_id" json:"entity_id"`
+	MetadataXML string  `db:"metadata_xml" json:"metadata_xml,omitempty"`
+	MetadataURL *string `db:"metadata_url" json:"metadata_url,omitempty"`
+
+	AttributeMapping SAMLAttributeMapping `db:"attribute_mapping" json:"attribute_mapping,omitempty"`
+
+	CreatedAt time.Time `db:"created_at" json:"-"`
+	UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func (p SAMLProvider) TableName() string {
+	return "saml_providers"
+}
+
+func (p SAMLProvider) EntityDescriptor() (*saml.EntityDescriptor, error) {
+	return samlsp.ParseMetadata([]byte(p.MetadataXML))
+}
+
+type SSODomain struct {
+	ID uuid.UUID `db:"id" json:"-"`
+
+	SSOProvider   *SSOProvider `belongs_to:"sso_providers" json:"-"`
+	SSOProviderID uuid.UUID    `db:"sso_provider_id" json:"-"`
+
+	Domain string `db:"domain" json:"domain"`
+
+	CreatedAt time.Time `db:"created_at" json:"-"`
+	UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func (d SSODomain) TableName() string {
+	return "sso_domains"
+}
+
+type SSOSession struct {
+	ID uuid.UUID `db:"id"`
+
+	Session   *Session  `belongs_to:"session"`
+	SessionID uuid.UUID `db:"session_id"`
+
+	SSOProvider   *SSOProvider `belongs_to:"sso_providers"`
+	SSOProviderID uuid.UUID    `db:"sso_provider_id"`
+
+	NotBefore time.Time `db:"not_before"`
+	NotAfter  time.Time `db:"not_after"`
+
+	IdPInitiated bool `db:"idp_initiated"`
+
+	CreatedAt time.Time `db:"created_at" json:"-"`
+	UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func (s SSOSession) TableName() string {
+	return "sso_sessions"
+}
+
+type SAMLRelayState struct {
+	ID uuid.UUID `db:"id"`
+
+	SSOProviderID uuid.UUID `db:"sso_provider_id"`
+
+	RequestID     string  `db:"request_id"`
+	ForEmail      *string `db:"for_email"`
+	FromIPAddress string  `db:"from_ip_address"`
+
+	RedirectTo string `db:"redirect_to"`
+
+	CreatedAt time.Time `db:"created_at" json:"-"`
+	UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func (s SAMLRelayState) TableName() string {
+	return "saml_relay_states"
+}
+
+func FindSSOProviderByResourceID(tx *storage.Connection, resourceID string) (*SSOProvider, error) {
+	var ssoProvider SSOProvider
+	if err := tx.Eager().Q().Where("resource_id = ?", strings.ToLower(resourceID)).First(&ssoProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SSO provider by resource ID")
+	}
+
+	return &ssoProvider, nil
+}
+
+func FindSAMLProviderByEntityID(tx *storage.Connection, entityId string) (*SSOProvider, error) {
+	var samlProvider SAMLProvider
+	if err := tx.Q().Where("entity_id = ?", entityId).First(&samlProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SAML SSO provider by EntityID")
+	}
+
+	var ssoProvider SSOProvider
+	if err := tx.Eager().Q().Where("id = ?", samlProvider.SSOProviderID).First(&ssoProvider); err != nil {
+		return nil, errors.Wrap(err, "error finding SAML SSO provider by ID (via EntityID)")
+	}
+
+	return &ssoProvider, nil
+}
+
+func FindSSOProviderByID(tx *storage.Connection, id uuid.UUID) (*SSOProvider, error) {
+	var ssoProvider SSOProvider
+
+	if err := tx.Eager().Q().Where("id = ?", id).First(&ssoProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SAML SSO provider by ID")
+	}
+
+	return &ssoProvider, nil
+}
+
+func FindSSOProviderForEmailAddress(tx *storage.Connection, emailAddress string) (*SSOProvider, error) {
+	parts := strings.Split(emailAddress, "@")
+	emailDomain := strings.ToLower(parts[1])
+
+	return FindSSOProviderByDomain(tx, emailDomain)
+}
+
+func FindSSOProviderByDomain(tx *storage.Connection, domain string) (*SSOProvider, error) {
+	var ssoDomain SSODomain
+
+	if err := tx.Q().Where("domain = ?", domain).First(&ssoDomain); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SAML SSO domain")
+	}
+
+	var ssoProvider SSOProvider
+	if err := tx.Eager().Q().Where("id = ?", ssoDomain.SSOProviderID).First(&ssoProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SAML SSO provider by ID (via domain)")
+	}
+
+	return &ssoProvider, nil
+}
+
+func FindAllSAMLProviders(tx *storage.Connection) ([]SSOProvider, error) {
+	var providers []SSOProvider
+
+	if err := tx.Eager().All(&providers); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, nil
+		}
+
+		return nil, errors.Wrap(err, "error loading all SAML SSO providers")
+	}
+
+	return providers, nil
+}


Test patch:
diff --git a/api/sso_test.go b/api/sso_test.go
new file mode 100644
index 00000000..158552c7
--- /dev/null
+++ b/api/sso_test.go
@@ -0,0 +1,520 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	jwt "github.com/golang-jwt/jwt"
+	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/models"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+)
+
+type SSOTestSuite struct {
+	suite.Suite
+	API      *API
+	Config   *conf.GlobalConfiguration
+	AdminJWT string
+}
+
+func TestSSO(t *testing.T) {
+	api, config, err := setupAPIForTest()
+	require.NoError(t, err)
+
+	ts := &SSOTestSuite{
+		API:    api,
+		Config: config,
+	}
+	defer api.db.Close()
+
+	if config.SAML.Enabled {
+		suite.Run(t, ts)
+	}
+}
+
+func (ts *SSOTestSuite) SetupTest() {
+	models.TruncateAll(ts.API.db)
+
+	claims := &GoTrueClaims{
+		Role: "supabase_admin",
+	}
+	token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret))
+	require.NoError(ts.T(), err, "Error generating admin jwt")
+
+	ts.AdminJWT = token
+}
+
+func (ts *SSOTestSuite) TestNonAdminJWT() {
+	// TODO
+}
+
+func (ts *SSOTestSuite) TestAdminListEmptySSOProviders() {
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/admin/sso/providers", nil)
+	req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+	w := httptest.NewRecorder()
+
+	ts.API.handler.ServeHTTP(w, req)
+
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+
+	body, err := io.ReadAll(w.Body)
+	require.NoError(ts.T(), err)
+
+	var result struct {
+		Items     []interface{} `json:"items"`
+		NextToken string        `json:"next_token"`
+	}
+
+	require.NoError(ts.T(), json.Unmarshal(body, &result))
+	require.Equal(ts.T(), len(result.Items), 0)
+	require.Equal(ts.T(), result.NextToken, "")
+}
+
+func (ts *SSOTestSuite) TestAdminGetSSOProviderNotExist() {
+	examples := []struct {
+		URL string
+	}{
+		{
+			URL: "http://localhost/admin/sso/providers/unknown-provider-resource-id",
+		},
+		{
+			URL: "http://localhost/admin/sso/providers/1",
+		},
+		{
+			URL: "http://localhost/admin/sso/providers/0",
+		},
+		{
+			URL: "http://localhost/admin/sso/providers/677477db-3f51-4038-bc05-c6bb9bdc3c32",
+		},
+	}
+
+	for _, example := range examples {
+		req := httptest.NewRequest(http.MethodGet, example.URL, nil)
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		require.Equal(ts.T(), http.StatusNotFound, w.Code)
+	}
+}
+
+func validSAMLIDPMetadata(entityID string) string {
+	return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="%s">
+  <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
+    <md:KeyDescriptor use="signing">
+      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+        <ds:X509Data>
+          <ds:X509Certificate>MIIDdDCCAlygAwIBAgIGAYKSjRZiMA0GCSqGSIb3DQEBCwUAMHsxFDASBgNVBAoTC0dvb2dsZSBJ
+bmMuMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MQ8wDQYDVQQDEwZHb29nbGUxGDAWBgNVBAsTD0dv
+b2dsZSBGb3IgV29yazELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWEwHhcNMjIwODEy
+MTQ1NDU1WhcNMjcwODExMTQ1NDU1WjB7MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEWMBQGA1UEBxMN
+TW91bnRhaW4gVmlldzEPMA0GA1UEAxMGR29vZ2xlMRgwFgYDVQQLEw9Hb29nbGUgRm9yIFdvcmsx
+CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEAlncFzErcnZm7ZWO71NZStnCIAoYNKf6Uw3LPLzcvk0YrA/eBC3PVDHSfahi+apGO
+Ytdq7IQUvBdto3rJTvP49fjyO0WLbAbiPC+dILt2Gx9kttxpSp99Bf+8ObL/fTy5Y2oHbJBfBX1V
+qfDQIY0fcej3AndFYUOE0gZXyeSbnROB8W1PzHxOc7rq1mlas0rvyja7AK4gwXjIwyIGsFDmHnve
+buqWOYMzOT9oD+iQq9BWYVHkXGZn0BXzKtnw9w8I3IxQdndUoCl95pYRIvdl1b0dWdO9cXtSsTkL
+kAa8B/mCQcF4W2M3t/yKtrcLcRTALg3/Hc+Xz+3BpY/fSDk1SwIDAQABMA0GCSqGSIb3DQEBCwUA
+A4IBAQCER02WLf6bKwTGVD/3VTntetIiETuPs46Dum8blbsg+2BYdAHIQcB9cLuMRosIw0nYj54m
+SfiyfoWGcx3CkMup1MtKyWu+SqDHl9Bpf+GFLG0ngKD/zB6xwpv/TCi+g/FBYe2TvzD6B1V0z7Vs
+Xf+Gc2TWBKmCuKf/g2AUt7IQLpOaqxuJVoZjp4sEMov6d3FnaoHQEd0lg+XmnYfLNtwe3QRSU0BD
+x6lVV4kXi0x0n198/gkjnA85rPZoZ6dmqHtkcM0Gabgg6KEE5ubSDlWDsdv27uANceCZAoxd1+in
+4/KqqkhynnbJs7Op5ZX8cckiHGGTGHNb35kys/XukuCo</ds:X509Certificate>
+        </ds:X509Data>
+      </ds:KeyInfo>
+    </md:KeyDescriptor>
+    <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
+    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="%s"/>
+    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="%s"/>
+  </md:IDPSSODescriptor>
+</md:EntityDescriptor>`, entityID, entityID, entityID)
+}
+
+func (ts *SSOTestSuite) TestAdminCreateSSOProvider() {
+	examples := []struct {
+		StatusCode int
+		Request    map[string]interface{}
+	}{
+		{
+			StatusCode: http.StatusBadRequest,
+			Request:    map[string]interface{}{},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type": "saml",
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type": "oidc",
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-A"),
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "abcdefgh",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-B"),
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE"),
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE"),
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "duplicate",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE-RID-A"),
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "duplicate",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE-RID-B"),
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-DOMAIN-A"),
+				"domains": []string{
+					"example.com",
+				},
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-DOMAIN-B"),
+				"domains": []string{
+					"example.com",
+				},
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "1",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-INVALID-RESOURCE-ID"),
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_url": "https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-METADATA-URL-TOO",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-METADATA-URL-TOO"),
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_url": "http://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-METADATA-OVER-HTTP",
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_url": "https://accounts.google.com\\o/saml2?idpid=EXAMPLE-WITH-INVALID-METADATA-URL",
+			},
+		},
+		// TODO: add example with metadata_url
+	}
+
+	for i, example := range examples {
+		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)
+
+		require.Equal(ts.T(), example.StatusCode, w.Code, "Example %d failed with body %q", i, response)
+
+		if example.StatusCode != http.StatusCreated {
+			continue
+		}
+
+		// now check if the provider can be queried (GET)
+		var provider struct {
+			ID string `json:"id"`
+		}
+
+		require.NoError(ts.T(), json.Unmarshal(response, &provider))
+
+		req = httptest.NewRequest(http.MethodGet, "http://localhost/admin/sso/providers/"+provider.ID, nil)
+		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)
+
+		require.Equal(ts.T(), http.StatusOK, w.Code)
+
+		originalProviderID := provider.ID
+		provider.ID = ""
+
+		require.NoError(ts.T(), json.Unmarshal(response, &provider))
+		require.Equal(ts.T(), provider.ID, originalProviderID)
+
+		// now check if the provider can be queried (List)
+		var providers struct {
+			Items []struct {
+				ID string `json:"id"`
+			} `json:"items"`
+		}
+
+		req = httptest.NewRequest(http.MethodGet, "http://localhost/admin/sso/providers", nil)
+		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)
+
+		require.NoError(ts.T(), json.Unmarshal(response, &providers))
+
+		contained := false
+		for _, listProvider := range providers.Items {
+			if listProvider.ID == provider.ID {
+				contained = true
+				break
+			}
+		}
+
+		require.True(ts.T(), contained)
+	}
+}
+
+func (ts *SSOTestSuite) TestAdminUpdateSSOProvider() {
+	providers := []struct {
+		ID      string
+		Request map[string]interface{}
+	}{
+		{
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-A"),
+			},
+		},
+		{
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-C"),
+				"domains": []string{
+					"example.com",
+				},
+			},
+		},
+		{
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "example-d",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-D"),
+			},
+		},
+	}
+
+	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 {
+		ID      string
+		Status  int
+		Request map[string]interface{}
+	}{
+		{
+			ID:     providers[0].ID,
+			Status: http.StatusBadRequest, // changing entity ID
+			Request: map[string]interface{}{
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-B"),
+			},
+		},
+		{
+			ID:     providers[0].ID,
+			Status: http.StatusBadRequest, // domain already exists
+			Request: map[string]interface{}{
+				"domains": []string{
+					"example.com",
+				},
+			},
+		},
+		{
+			ID:     providers[1].ID,
+			Status: http.StatusOK,
+			Request: map[string]interface{}{
+				"domains": []string{
+					"example.com",
+					"example.org",
+				},
+			},
+		},
+		{
+			ID:     providers[0].ID,
+			Status: http.StatusOK,
+			Request: map[string]interface{}{
+				"resource_id": "deadbeef",
+			},
+		},
+		{
+			ID:     providers[2].ID,
+			Status: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"resource_id": "deadbeef",
+			},
+		},
+	}
+
+	for _, example := range examples {
+		body, err := json.Marshal(example.Request)
+		require.NoError(ts.T(), err)
+
+		req := httptest.NewRequest(http.MethodPut, "http://localhost/admin/sso/providers/"+example.ID, bytes.NewBuffer(body))
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		require.Equal(ts.T(), w.Code, example.Status)
+	}
+}
+
+func (ts *SSOTestSuite) TestAdminDeleteSSOProvider() {
+	providers := []struct {
+		ID      string
+		Request map[string]interface{}
+	}{
+		{
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-A"),
+			},
+		},
+	}
+
+	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 {
+		ID     string
+		Status int
+	}{
+		{
+			ID:     providers[0].ID,
+			Status: http.StatusOK,
+		},
+	}
+
+	for _, example := range examples {
+		req := httptest.NewRequest(http.MethodDelete, "http://localhost/admin/sso/providers/"+example.ID, nil)
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		require.Equal(ts.T(), w.Code, example.Status)
+	}
+
+	check := []struct {
+		ID string
+	}{
+		{
+			ID: providers[0].ID,
+		},
+	}
+
+	for _, example := range check {
+		req := httptest.NewRequest(http.MethodGet, "http://localhost/admin/sso/providers/"+example.ID, nil)
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		require.Equal(ts.T(), http.StatusNotFound, w.Code)
+	}
+}
+
+func TestSSOCreateParamsValidation(t *testing.T) {
+	// TODO
+}
diff --git a/models/sso_test.go b/models/sso_test.go
new file mode 100644
index 00000000..d51ee7dd
--- /dev/null
+++ b/models/sso_test.go
@@ -0,0 +1,241 @@
+package models
+
+import (
+	tst "testing"
+
+	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/storage"
+	"github.com/netlify/gotrue/storage/test"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+)
+
+type SSOTestSuite struct {
+	suite.Suite
+
+	db *storage.Connection
+}
+
+func (ts *SSOTestSuite) SetupTest() {
+	TruncateAll(ts.db)
+}
+
+func TestSSO(t *tst.T) {
+	globalConfig, err := conf.LoadGlobal(modelsTestConfig)
+	require.NoError(t, err)
+
+	conn, err := test.SetupDBConnection(globalConfig)
+	require.NoError(t, err)
+
+	ts := &SSOTestSuite{
+		db: conn,
+	}
+	defer ts.db.Close()
+
+	suite.Run(t, ts)
+}
+
+func (ts *SSOTestSuite) TestConstraints() {
+	resourceID := func(rid string) *string {
+		return &rid
+	}
+
+	type exampleSpec struct {
+		Provider *SSOProvider
+	}
+
+	examples := []exampleSpec{
+		{
+			Provider: &SSOProvider{
+				SAMLProvider: SAMLProvider{
+					EntityID:    "",
+					MetadataXML: "<example />",
+				},
+			},
+		},
+		{
+			Provider: &SSOProvider{
+				SAMLProvider: SAMLProvider{
+					EntityID:    "https://example.com/saml/metadata",
+					MetadataXML: "",
+				},
+			},
+		},
+		{
+			Provider: &SSOProvider{
+				SAMLProvider: SAMLProvider{
+					EntityID:    "https://example.com/saml/metadata",
+					MetadataXML: "<example />",
+				},
+				SSODomains: []SSODomain{
+					{
+						Domain: "",
+					},
+				},
+			},
+		},
+		{
+			Provider: &SSOProvider{
+				ResourceID: resourceID(""),
+			},
+		},
+	}
+
+	for i, example := range examples {
+		require.Error(ts.T(), ts.db.Eager().Create(example.Provider), "Example %d should have failed with error", i)
+	}
+}
+
+func (ts *SSOTestSuite) TestDomainUniqueness() {
+	require.NoError(ts.T(), ts.db.Eager().Create(&SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata1",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+		},
+	}))
+
+	require.Error(ts.T(), ts.db.Eager().Create(&SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata2",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+		},
+	}))
+}
+
+func (ts *SSOTestSuite) TestEntityIDUniqueness() {
+	require.NoError(ts.T(), ts.db.Eager().Create(&SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+		},
+	}))
+
+	require.Error(ts.T(), ts.db.Eager().Create(&SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.net",
+			},
+		},
+	}))
+}
+
+func (ts *SSOTestSuite) TestFindSSOProviderForEmailAddress() {
+	provider := &SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+			{
+				Domain: "example.org",
+			},
+		},
+	}
+
+	require.NoError(ts.T(), ts.db.Eager().Create(provider), "provider creation failed")
+
+	type exampleSpec struct {
+		Address  string
+		Provider *SSOProvider
+	}
+
+	examples := []exampleSpec{
+		{
+			Address:  "someone@example.com",
+			Provider: provider,
+		},
+		{
+			Address:  "someone@example.org",
+			Provider: provider,
+		},
+		{
+			Address:  "someone@example.net",
+			Provider: nil,
+		},
+	}
+
+	for i, example := range examples {
+		rp, err := FindSSOProviderForEmailAddress(ts.db, example.Address)
+
+		if nil == example.Provider {
+			require.Nil(ts.T(), rp)
+			require.True(ts.T(), IsNotFoundError(err), "Example %d failed with error %w", i, err)
+		} else {
+			require.Nil(ts.T(), err, "Example %d failed with error %w", i, err)
+			require.Equal(ts.T(), rp.ID, example.Provider.ID)
+		}
+	}
+}
+
+func (ts *SSOTestSuite) TestFindSAMLProviderByEntityID() {
+	provider := &SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+			{
+				Domain: "example.org",
+			},
+		},
+	}
+
+	require.NoError(ts.T(), ts.db.Eager().Create(provider))
+
+	type exampleSpec struct {
+		EntityID string
+		Provider *SSOProvider
+	}
+
+	examples := []exampleSpec{
+		{
+			EntityID: "https://example.com/saml/metadata",
+			Provider: provider,
+		},
+		{
+			EntityID: "https://example.com/saml/metadata/",
+			Provider: nil,
+		},
+		{
+			EntityID: "",
+			Provider: nil,
+		},
+	}
+
+	for i, example := range examples {
+		rp, err := FindSAMLProviderByEntityID(ts.db, example.EntityID)
+
+		if nil == example.Provider {
+			require.True(ts.T(), IsNotFoundError(err), "Example %d failed with error", i)
+			require.Nil(ts.T(), rp)
+		} else {
+			require.Nil(ts.T(), err, "Example %d failed with error %w", i, err)
+			require.Equal(ts.T(), rp.ID, example.Provider.ID)
+		}
+	}
+}