Repository: supabase/gotrue
Pull request #561 (2022-07-22 14:38:22)

feat(saml): Add SSO models, basic ACS
Add basic models for SSO with SAML and a prototype assertion consumer service implementation.


Patch:
diff --git a/api/api.go b/api/api.go
index f8686753..25aca878 100644
--- a/api/api.go
+++ b/api/api.go
@@ -181,7 +181,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 			r.Route("/saml", func(r *router) {
 				r.Route("/acs", func(r *router) {
 					r.Use(api.loadSAMLState)
-					r.Post("/", api.ExternalProviderCallback)
+					r.Post("/", api.SAMLAssertionConsumerService)
 				})
 
 				r.Get("/metadata", api.SAMLMetadata)
diff --git a/api/external.go b/api/external.go
index d8516e8a..f07d4dbf 100644
--- a/api/external.go
+++ b/api/external.go
@@ -103,6 +103,16 @@ func (a *API) ExternalProviderCallback(w http.ResponseWriter, r *http.Request) e
 	return nil
 }
 
+// SAMLAssertionConsumerService handles the ACS endpoint
+func (a *API) SAMLAssertionConsumerService(w http.ResponseWriter, r *http.Request) error {
+	// TODO: I'm really not super happy with the handling of this in
+	// internalExternalProviderCallback -- needs to be refactored.
+	newReq := r.WithContext(withExternalProviderType(r.Context(), "saml"))
+
+	a.redirectErrors(a.internalExternalProviderCallback, w, newReq)
+	return nil
+}
+
 func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
 	config := a.getConfig(ctx)
@@ -432,8 +442,6 @@ func (a *API) Provider(ctx context.Context, name string, scopes string, query *u
 		return provider.NewTwitterProvider(config.External.Twitter, scopes)
 	case "workos":
 		return provider.NewWorkOSProvider(config.External.WorkOS, query)
-	case "saml":
-		return provider.NewSamlProvider(config.External.Saml, a.db, getInstanceID(ctx))
 	case "zoom":
 		return provider.NewZoomProvider(config.External.Zoom)
 	default:
diff --git a/api/external_saml.go b/api/external_saml.go
index 1c31ea3c..4a4533af 100644
--- a/api/external_saml.go
+++ b/api/external_saml.go
@@ -3,8 +3,6 @@ package api
 import (
 	"context"
 	"net/http"
-
-	"github.com/netlify/gotrue/api/provider"
 )
 
 func (a *API) loadSAMLState(w http.ResponseWriter, r *http.Request) (context.Context, error) {
@@ -17,44 +15,3 @@ func (a *API) loadSAMLState(w http.ResponseWriter, r *http.Request) (context.Con
 
 	return a.loadExternalState(ctx, state)
 }
-
-func (a *API) samlCallback(ctx context.Context, r *http.Request) (*provider.UserProvidedData, error) {
-	config := a.getConfig(ctx)
-
-	samlProvider, err := provider.NewSamlProvider(config.External.Saml, a.db, getInstanceID(ctx))
-	if err != nil {
-		return nil, badRequestError("Could not initialize SAML provider: %+v", err).WithInternalError(err)
-	}
-
-	samlResponse := r.FormValue("SAMLResponse")
-	if samlResponse == "" {
-		return nil, badRequestError("SAML Response is missing")
-	}
-
-	assertionInfo, err := samlProvider.ServiceProvider.RetrieveAssertionInfo(samlResponse)
-	if err != nil {
-		return nil, internalServerError("Parsing SAML assertion failed: %+v", err).WithInternalError(err)
-	}
-
-	if assertionInfo.WarningInfo.InvalidTime {
-		return nil, forbiddenError("SAML response has invalid time")
-	}
-
-	if assertionInfo.WarningInfo.NotInAudience {
-		return nil, forbiddenError("SAML response is not in audience")
-	}
-
-	if assertionInfo == nil {
-		return nil, internalServerError("SAML Assertion is missing")
-	}
-	userData := &provider.UserProvidedData{
-		Emails: []provider.Email{{
-			Email:    assertionInfo.NameID,
-			Verified: true,
-		}},
-		Metadata: &provider.Claims{
-			Subject: assertionInfo.NameID,
-		},
-	}
-	return userData, nil
-}
diff --git a/api/provider/saml.go b/api/provider/saml.go
deleted file mode 100644
index 807438aa..00000000
--- a/api/provider/saml.go
+++ /dev/null
@@ -1,263 +0,0 @@
-package provider
-
-import (
-	"crypto/rand"
-	"crypto/rsa"
-	"crypto/tls"
-	"crypto/x509"
-	"encoding/base64"
-	"encoding/pem"
-	"encoding/xml"
-	"errors"
-	"fmt"
-	"io/ioutil"
-	"math/big"
-	"net/http"
-	"net/url"
-	"strings"
-	"time"
-
-	"github.com/netlify/gotrue/models"
-	"github.com/netlify/gotrue/storage"
-
-	"github.com/gofrs/uuid"
-	"github.com/netlify/gotrue/conf"
-	saml2 "github.com/russellhaering/gosaml2"
-	"github.com/russellhaering/gosaml2/types"
-	dsig "github.com/russellhaering/goxmldsig"
-	"golang.org/x/oauth2"
-)
-
-type SamlProvider struct {
-	ServiceProvider *saml2.SAMLServiceProvider
-}
-
-type ConfigX509KeyStore struct {
-	InstanceID uuid.UUID
-	DB         *storage.Connection
-	Conf       conf.SamlProviderConfiguration
-}
-
-func getMetadata(url string) (*types.EntityDescriptor, error) {
-	res, err := http.Get(url)
-	if err != nil {
-		return nil, err
-	}
-
-	if res.StatusCode >= 300 {
-		return nil, fmt.Errorf("Request failed with status %s", res.Status)
-	}
-
-	rawMetadata, err := ioutil.ReadAll(res.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	metadata := &types.EntityDescriptor{}
-	err = xml.Unmarshal(rawMetadata, metadata)
-	if err != nil {
-		return nil, err
-	}
-
-	// TODO: cache in memory
-
-	return metadata, nil
-}
-
-// NewSamlProvider creates a Saml account provider.
-func NewSamlProvider(ext conf.SamlProviderConfiguration, db *storage.Connection, instanceId uuid.UUID) (*SamlProvider, error) {
-	if !ext.Enabled {
-		return nil, errors.New("SAML Provider is not enabled")
-	}
-
-	if _, err := url.Parse(ext.MetadataURL); err != nil {
-		return nil, fmt.Errorf("Metadata URL is invalid: %+v", err)
-	}
-
-	meta, err := getMetadata(ext.MetadataURL)
-	if err != nil {
-		return nil, fmt.Errorf("Fetching metadata failed: %+v", err)
-	}
-
-	baseURI, err := url.Parse(strings.Trim(ext.APIBase, "/"))
-	if err != nil || ext.APIBase == "" {
-		return nil, fmt.Errorf("Invalid API base URI: %s", ext.APIBase)
-	}
-
-	var ssoService types.SingleSignOnService
-	foundService := false
-	for _, service := range meta.IDPSSODescriptor.SingleSignOnServices {
-		if service.Binding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" {
-			ssoService = service
-			foundService = true
-			break
-		}
-	}
-	if !foundService {
-		return nil, errors.New("No valid SSO service found in IDP metadata")
-	}
-
-	certStore := dsig.MemoryX509CertificateStore{
-		Roots: []*x509.Certificate{},
-	}
-
-	for _, kd := range meta.IDPSSODescriptor.KeyDescriptors {
-		for _, xcert := range kd.KeyInfo.X509Data.X509Certificates {
-			if xcert.Data == "" {
-				continue
-			}
-			certData, err := base64.StdEncoding.DecodeString(xcert.Data)
-			if err != nil {
-				continue
-			}
-
-			idpCert, err := x509.ParseCertificate(certData)
-			if err != nil {
-				continue
-			}
-
-			certStore.Roots = append(certStore.Roots, idpCert)
-		}
-	}
-
-	keyStore := &ConfigX509KeyStore{
-		InstanceID: instanceId,
-		DB:         db,
-		Conf:       ext,
-	}
-
-	sp := &saml2.SAMLServiceProvider{
-		IdentityProviderSSOURL:      ssoService.Location,
-		IdentityProviderIssuer:      meta.EntityID,
-		AssertionConsumerServiceURL: baseURI.String() + "/saml/acs",
-		ServiceProviderIssuer:       baseURI.String() + "/saml",
-		SignAuthnRequests:           true,
-		AudienceURI:                 baseURI.String() + "/saml",
-		IDPCertificateStore:         &certStore,
-		SPKeyStore:                  keyStore,
-		AllowMissingAttributes:      true,
-	}
-
-	p := &SamlProvider{
-		ServiceProvider: sp,
-	}
-	return p, nil
-}
-
-func (p SamlProvider) AuthCodeURL(tokenString string, args ...oauth2.AuthCodeOption) string {
-	url, err := p.ServiceProvider.BuildAuthURL(tokenString)
-	if err != nil {
-		return ""
-	}
-	return url
-}
-
-func (p SamlProvider) SPMetadata() ([]byte, error) {
-	metadata, err := p.ServiceProvider.Metadata()
-	if err != nil {
-		return nil, err
-	}
-
-	// the typing for encryption methods currently causes the xml to violate the spec
-	// therefore they are removed since they are optional anyways and mostly unused
-	metadata.SPSSODescriptor.KeyDescriptors[1].EncryptionMethods = []types.EncryptionMethod{}
-
-	rawMetadata, err := xml.Marshal(metadata)
-	if err != nil {
-		return nil, err
-	}
-
-	return rawMetadata, nil
-}
-
-func (ks ConfigX509KeyStore) GetKeyPair() (*rsa.PrivateKey, []byte, error) {
-	if ks.Conf.SigningCert == "" && ks.Conf.SigningKey == "" {
-		return ks.CreateSigningCert()
-	}
-
-	keyPair, err := tls.X509KeyPair([]byte(ks.Conf.SigningCert), []byte(ks.Conf.SigningKey))
-	if err != nil {
-		return nil, nil, fmt.Errorf("Parsing key pair failed: %+v", err)
-	}
-
-	var privKey *rsa.PrivateKey
-	switch key := keyPair.PrivateKey.(type) {
-	case *rsa.PrivateKey:
-		privKey = key
-	default:
-		return nil, nil, errors.New("Private key is not an RSA key")
-	}
-
-	return privKey, keyPair.Certificate[0], nil
-}
-
-func (ks ConfigX509KeyStore) CreateSigningCert() (*rsa.PrivateKey, []byte, error) {
-	key, err := rsa.GenerateKey(rand.Reader, 2048)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	currentTime := time.Now()
-
-	certBody := &x509.Certificate{
-		SerialNumber: big.NewInt(1),
-		NotBefore:    currentTime.Add(-5 * time.Minute),
-		NotAfter:     currentTime.Add(365 * 24 * time.Hour),
-
-		KeyUsage:              x509.KeyUsageDigitalSignature,
-		ExtKeyUsage:           []x509.ExtKeyUsage{},
-		BasicConstraintsValid: true,
-	}
-
-	cert, err := x509.CreateCertificate(rand.Reader, certBody, certBody, &key.PublicKey, key)
-	if err != nil {
-		return nil, nil, fmt.Errorf("Failed to create certificate: %+v", err)
-	}
-
-	if err := ks.SaveConfig(cert, key); err != nil {
-		return nil, nil, fmt.Errorf("Saving signing keypair failed: %+v", err)
-	}
-
-	return key, cert, nil
-}
-
-func (ks ConfigX509KeyStore) SaveConfig(cert []byte, key *rsa.PrivateKey) error {
-	if ks.InstanceID == uuid.Nil {
-		return nil
-	}
-
-	pemCert := &pem.Block{
-		Type:  "CERTIFICATE",
-		Bytes: cert,
-	}
-
-	certBytes := pem.EncodeToMemory(pemCert)
-	if certBytes == nil {
-		return errors.New("Could not encode certificate")
-	}
-
-	pemKey := &pem.Block{
-		Type:  "PRIVATE KEY",
-		Bytes: x509.MarshalPKCS1PrivateKey(key),
-	}
-
-	keyBytes := pem.EncodeToMemory(pemKey)
-	if keyBytes == nil {
-		return errors.New("Could not encode key")
-	}
-
-	instance, err := models.GetInstance(ks.DB, ks.InstanceID)
-	if err != nil {
-		return err
-	}
-
-	conf := instance.BaseConfig
-	conf.External.Saml.SigningCert = string(certBytes)
-	conf.External.Saml.SigningKey = string(keyBytes)
-
-	if err := instance.UpdateConfig(ks.DB, conf); err != nil {
-		return err
-	}
-
-	return nil
-}
diff --git a/api/saml.go b/api/saml.go
index b9ca3f88..9c4ad20e 100644
--- a/api/saml.go
+++ b/api/saml.go
@@ -1,12 +1,16 @@
 package api
 
 import (
+	"context"
+	"encoding/base64"
 	"encoding/xml"
 	"net/http"
 	"net/url"
 
 	"github.com/crewjam/saml"
 	"github.com/crewjam/saml/samlsp"
+	"github.com/netlify/gotrue/api/provider"
+	"github.com/netlify/gotrue/models"
 )
 
 // getSAMLServiceProvider generates a new service provider object with the
@@ -45,3 +49,74 @@ func (a *API) SAMLMetadata(w http.ResponseWriter, r *http.Request) error {
 
 	return err
 }
+
+// samlCallback implements the main Assertion Consumer Service endpoint behavior.
+func (a *API) samlCallback(ctx context.Context, r *http.Request) (*provider.UserProvidedData, error) {
+	if r.FormValue("SAMLart") != "" {
+		// TODO: SAML Artifact callbacks are only possible when you can
+		// identify the IdP via RelayState i.e. only the SP initiated
+		// flow is supported
+		return nil, badRequestError("SAML Response with artifacts not supported at this time")
+	}
+
+	samlResponse := r.FormValue("SAMLResponse")
+	if samlResponse == "" {
+		return nil, badRequestError("SAMLResponse is missing")
+	}
+
+	responseXML, err := base64.StdEncoding.DecodeString(samlResponse)
+	if err != nil {
+		return nil, badRequestError("SAMLResponse is not a valid Base64 string")
+	}
+
+	var peekAssertion saml.Assertion
+	err = xml.Unmarshal(responseXML, peekAssertion)
+	if err != nil {
+		return nil, badRequestError("SAMLResponse is not a valid XML SAML assertion")
+	}
+
+	entityId := peekAssertion.Issuer.Value
+
+	ssoProvider, err := models.FindSAMLProviderForEntityID(a.db, entityId)
+	if err != nil {
+		return nil, err
+	}
+
+	if ssoProvider == nil {
+		return nil, badRequestError("SAML Assertion has unrecognized Issuer")
+	}
+
+	idpMetadata, err := samlsp.ParseMetadata([]byte(ssoProvider.SAMLProvider.MetadataXML))
+	if err != nil {
+		return nil, err
+	}
+
+	// TODO: fetch new metadata if possible when validUntil < time.Now()
+
+	serviceProvider := a.getSAMLServiceProvider(idpMetadata)
+	assertion, err := serviceProvider.ParseResponse(r, nil)
+	if err != nil {
+		return nil, badRequestError("SAML Assertion is not valid")
+	}
+
+	var email string
+
+	for _, stmt := range assertion.AttributeStatements {
+		for _, attr := range stmt.Attributes {
+			if attr.Name == "email" {
+				email = attr.Values[0].Value
+				break
+			}
+		}
+	}
+
+	return &provider.UserProvidedData{
+		Emails: []provider.Email{
+			{
+				Email:    email,
+				Verified: true,
+				Primary:  true,
+			},
+		},
+	}, nil
+}
diff --git a/migrations/20220721094530_add_saml.up.sql b/migrations/20220721094530_add_saml.up.sql
new file mode 100644
index 00000000..f9f7088b
--- /dev/null
+++ b/migrations/20220721094530_add_saml.up.sql
@@ -0,0 +1,39 @@
+-- Multi-instance mode (see auth.instances) table intentionally not supported and ignored.
+
+create table if not exists auth.sso_providers (
+	id uuid not null,
+	created_at timestamptz null,
+	updated_at timestamptz null,
+	primary key (id)
+);
+
+comment on table auth.sso_providers is 'Auth: Manages SSO identity provider information; see saml_providers for SAML.';
+
+create table if not exists auth.sso_domains (
+	id uuid not null,
+	sso_provider_id uuid not null,
+	domain text not null unique,
+	created_at timestamptz null,
+	updated_at timestamptz null,
+	primary key (id),
+	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade,
+	constraint "domain not empty" check (char_length(domain) > 0)
+);
+
+comment on table auth.sso_domains is 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.';
+
+create table if not exists auth.saml_providers (
+	id uuid not null,
+	sso_provider_id uuid not null,
+	entity_id text not null unique,
+	metadata_xml text not null,
+	metadata_url text null,
+	created_at timestamptz null,
+	updated_at timestamptz null,
+	primary key (id),
+	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade,
+	constraint "metadata_xml not empty" check (char_length(metadata_xml) > 0),
+	constraint "entity_id not empty" check (char_length(entity_id) > 0)
+);
+
+comment on table auth.saml_providers is 'Auth: Manages SAML Identity Provider connections.';
diff --git a/models/connection.go b/models/connection.go
index 7c01a1de..49645136 100644
--- a/models/connection.go
+++ b/models/connection.go
@@ -32,15 +32,22 @@ type SortField struct {
 
 func TruncateAll(conn *storage.Connection) error {
 	return conn.Transaction(func(tx *storage.Connection) error {
-		if err := tx.RawQuery("TRUNCATE " + (&pop.Model{Value: User{}}).TableName() + " CASCADE").Exec(); err != nil {
-			return err
+		tables := []string{
+			(&pop.Model{Value: User{}}).TableName(),
+			(&pop.Model{Value: RefreshToken{}}).TableName(),
+			(&pop.Model{Value: AuditLogEntry{}}).TableName(),
+			(&pop.Model{Value: Instance{}}).TableName(),
+			(&pop.Model{Value: SSOProvider{}}).TableName(),
+			(&pop.Model{Value: SSODomain{}}).TableName(),
+			(&pop.Model{Value: SAMLProvider{}}).TableName(),
 		}
-		if err := tx.RawQuery("TRUNCATE " + (&pop.Model{Value: RefreshToken{}}).TableName() + " CASCADE").Exec(); err != nil {
-			return err
-		}
-		if err := tx.RawQuery("TRUNCATE " + (&pop.Model{Value: AuditLogEntry{}}).TableName() + " CASCADE").Exec(); err != nil {
-			return err
+
+		for _, tableName := range tables {
+			if err := tx.RawQuery("TRUNCATE " + tableName + " CASCADE").Exec(); err != nil {
+				return err
+			}
 		}
-		return tx.RawQuery("TRUNCATE " + (&pop.Model{Value: Instance{}}).TableName() + " CASCADE").Exec()
+
+		return nil
 	})
 }
diff --git a/models/sso.go b/models/sso.go
new file mode 100644
index 00000000..20825bb1
--- /dev/null
+++ b/models/sso.go
@@ -0,0 +1,89 @@
+package models
+
+import (
+	"database/sql"
+	"strings"
+	"time"
+
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/storage"
+	"github.com/pkg/errors"
+)
+
+type SSOProvider struct {
+	ID uuid.UUID `db:"id"`
+
+	SAMLProvider SAMLProvider `has_one:"saml_providers" fk_id:"sso_provider_id"`
+	SSODomains   []SSODomain  `has_many:"sso_domains" fk_id:"sso_provider_id"`
+
+	CreatedAt time.Time `db:"created_at"`
+	UpdatedAt time.Time `db:"updated_at"`
+}
+
+func (p SSOProvider) TableName() string {
+	return "sso_providers"
+}
+
+type SAMLProvider struct {
+	ID uuid.UUID `db:"id"`
+
+	SSOProvider   *SSOProvider `belongs_to:"sso_providers"`
+	SSOProviderID uuid.UUID    `db:"sso_provider_id"`
+
+	EntityID    string `db:"entity_id"`
+	MetadataXML string `db:"metadata_xml"`
+	MetadataURL string `db:"metadata_url"`
+
+	CreatedAt time.Time `db:"created_at"`
+	UpdatedAt time.Time `db:"updated_at"`
+}
+
+func (p SAMLProvider) TableName() string {
+	return "saml_providers"
+}
+
+type SSODomain struct {
+	ID uuid.UUID `db:"id"`
+
+	SSOProvider   *SSOProvider `belongs_to:"sso_providers"`
+	SSOProviderID uuid.UUID    `db:"sso_provider_id"`
+
+	Domain string `db:"domain"`
+
+	CreatedAt time.Time `db:"created_at"`
+	UpdatedAt time.Time `db:"updated_at"`
+}
+
+func (d SSODomain) TableName() string {
+	return "sso_domains"
+}
+
+func FindSSOProviderForEmailAddress(tx *storage.Connection, emailAddress string) (*SSOProvider, error) {
+	parts := strings.Split(emailAddress, "@")
+	emailDomain := parts[1]
+
+	var ssoDomain SSODomain
+
+	if err := tx.Eager().Q().Where("domain = ?", emailDomain).First(&ssoDomain); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, nil
+		}
+
+		return nil, errors.Wrap(err, "error finding SSO provider based on email address domain")
+	}
+
+	return ssoDomain.SSOProvider, nil
+}
+
+func FindSAMLProviderForEntityID(tx *storage.Connection, entityId string) (*SSOProvider, error) {
+	var samlProvider SAMLProvider
+	if err := tx.Eager().Q().Where("entity_id = ?", entityId).First(&samlProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, nil
+		}
+
+		return nil, errors.Wrap(err, "error finding SAML SSO provider by EntityID")
+	}
+
+	return samlProvider.SSOProvider, nil
+}


Test patch:
diff --git a/models/sso_test.go b/models/sso_test.go
new file mode 100644
index 00000000..372dc2b1
--- /dev/null
+++ b/models/sso_test.go
@@ -0,0 +1,230 @@
+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() {
+	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: "",
+					},
+				},
+			},
+		},
+	}
+
+	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)
+		require.NoError(ts.T(), err, "Example %d failed with error", i)
+
+		if nil == example.Provider {
+			require.Nil(ts.T(), rp)
+		} else {
+			require.Equal(ts.T(), rp.ID, example.Provider.ID)
+		}
+	}
+}
+
+func (ts *SSOTestSuite) TestFindSAMLProviderForEntityID() {
+	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 := FindSAMLProviderForEntityID(ts.db, example.EntityID)
+		require.NoError(ts.T(), err, "Example %d failed with error", i)
+
+		if nil == example.Provider {
+			require.Nil(ts.T(), rp)
+		} else {
+			require.Equal(ts.T(), rp.ID, example.Provider.ID)
+		}
+	}
+}