Repository: supabase/gotrue
Pull request #555 (2022-07-20 10:46:28)

feat(saml): Add SAML metadata XML at `/saml/metadata`
Exposes the [SAML service provider metadata](https://kantarainitiative.github.io/SAMLprofiles/saml2int.html#_metadata_and_trust_management_2) on the `/saml/metadata` route.


Patch:
diff --git a/api/api.go b/api/api.go
index 116707de..f8686753 100644
--- a/api/api.go
+++ b/api/api.go
@@ -35,6 +35,8 @@ type API struct {
 	db      *storage.Connection
 	config  *conf.GlobalConfiguration
 	version string
+
+	samlMetadata []byte
 }
 
 // ListenAndServe starts the REST API
@@ -175,14 +177,16 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 			r.Post("/generate_link", api.GenerateLink)
 		})
 
-		r.Route("/saml", func(r *router) {
-			r.Route("/acs", func(r *router) {
-				r.Use(api.loadSAMLState)
-				r.Post("/", api.ExternalProviderCallback)
-			})
+		if api.config.SAML.Enabled {
+			r.Route("/saml", func(r *router) {
+				r.Route("/acs", func(r *router) {
+					r.Use(api.loadSAMLState)
+					r.Post("/", api.ExternalProviderCallback)
+				})
 
-			r.Get("/metadata", api.SAMLMetadata)
-		})
+				r.Get("/metadata", api.SAMLMetadata)
+			})
+		}
 	})
 
 	if globalConfig.MultiInstanceMode {
diff --git a/api/external_saml.go b/api/external_saml.go
index fa74d1cd..1c31ea3c 100644
--- a/api/external_saml.go
+++ b/api/external_saml.go
@@ -58,19 +58,3 @@ func (a *API) samlCallback(ctx context.Context, r *http.Request) (*provider.User
 	}
 	return userData, nil
 }
-
-// SAMLMetadata returns metadata information about the SAML provider
-func (a *API) SAMLMetadata(w http.ResponseWriter, r *http.Request) error {
-	ctx := r.Context()
-	config := getConfig(ctx)
-
-	samlProvider, err := provider.NewSamlProvider(config.External.Saml, a.db, getInstanceID(ctx))
-	if err != nil {
-		return internalServerError("Could not create SAML Provider: %+v", err).WithInternalError(err)
-	}
-
-	metadata, err := samlProvider.SPMetadata()
-	w.Header().Set("Content-Type", "application/xml")
-	w.Write(metadata)
-	return nil
-}
diff --git a/api/saml.go b/api/saml.go
new file mode 100644
index 00000000..b9ca3f88
--- /dev/null
+++ b/api/saml.go
@@ -0,0 +1,47 @@
+package api
+
+import (
+	"encoding/xml"
+	"net/http"
+	"net/url"
+
+	"github.com/crewjam/saml"
+	"github.com/crewjam/saml/samlsp"
+)
+
+// getSAMLServiceProvider generates a new service provider object with the
+// (optionally) provided descriptor (metadata) for the identity provider.
+func (a *API) getSAMLServiceProvider(identityProvider *saml.EntityDescriptor) *saml.ServiceProvider {
+	externalURL, err := url.ParseRequestURI(a.config.API.ExternalURL)
+	if err != nil {
+		// this should not fail as a.config should have been validated using #Validate()
+		panic(err)
+	}
+
+	provider := samlsp.DefaultServiceProvider(samlsp.Options{
+		URL:         *externalURL,
+		Key:         a.config.SAML.RSAPrivateKey,
+		Certificate: a.config.SAML.Certificate,
+		SignRequest: true,
+	})
+
+	return &provider
+}
+
+// SAMLMetadata serves GoTrue's SAML Service Provider metadata file.
+func (a *API) SAMLMetadata(w http.ResponseWriter, r *http.Request) error {
+	serviceProvider := a.getSAMLServiceProvider(nil)
+
+	metadata := serviceProvider.Metadata()
+
+	metadataXML, err := xml.Marshal(metadata)
+	if err != nil {
+		return err
+	}
+
+	w.Header().Set("Content-Type", "application/xml")
+	w.Header().Set("Cache-Control", "public, max-age=600") // cache at CDN for 10 minutes
+	_, err = w.Write(metadataXML)
+
+	return err
+}
diff --git a/conf/configuration.go b/conf/configuration.go
index b5380a97..690f0f7b 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -4,6 +4,7 @@ import (
 	"database/sql/driver"
 	"encoding/json"
 	"errors"
+	"net/url"
 	"os"
 	"time"
 
@@ -47,6 +48,10 @@ type DBConfiguration struct {
 	MigrationsPath string `json:"migrations_path" split_words:"true" default:"./migrations"`
 }
 
+func (c *DBConfiguration) Validate() error {
+	return nil
+}
+
 // JWTConfiguration holds all the JWT related configuration.
 type JWTConfiguration struct {
 	Secret           string   `json:"secret" required:"true"`
@@ -57,15 +62,30 @@ type JWTConfiguration struct {
 	DefaultGroupName string   `json:"default_group_name" split_words:"true"`
 }
 
+type APIConfiguration struct {
+	Host            string
+	Port            int `envconfig:"PORT" default:"8081"`
+	Endpoint        string
+	RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"`
+	ExternalURL     string `json:"external_url" envconfig:"API_EXTERNAL_URL"`
+}
+
+func (a *APIConfiguration) Validate() error {
+	if a.ExternalURL != "" {
+		// sometimes, in tests, ExternalURL is empty and we regard that
+		// as a valid value
+		_, err := url.ParseRequestURI(a.ExternalURL)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 // GlobalConfiguration holds all the configuration that applies to all instances.
 type GlobalConfiguration struct {
-	API struct {
-		Host            string
-		Port            int `envconfig:"PORT" default:"8081"`
-		Endpoint        string
-		RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"`
-		ExternalURL     string `json:"external_url" envconfig:"API_EXTERNAL_URL"`
-	}
+	API                   APIConfiguration
 	DB                    DBConfiguration
 	External              ProviderConfiguration
 	Logging               LoggingConfig `envconfig:"LOG"`
@@ -80,6 +100,26 @@ type GlobalConfiguration struct {
 	SAML                  SAMLConfiguration `json:"saml"`
 }
 
+func (c *GlobalConfiguration) Validate() error {
+	validatables := []interface {
+		Validate() error
+	}{
+		&c.API,
+		&c.DB,
+		&c.Tracing,
+		&c.SMTP,
+		&c.SAML,
+	}
+
+	for _, validatable := range validatables {
+		if err := validatable.Validate(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 func (c *GlobalConfiguration) ApplyDefaults() error {
 	if c.SAML.Enabled {
 		if err := c.SAML.PopulateFields(c.API.ExternalURL); err != nil {
@@ -137,6 +177,10 @@ type SMTPConfiguration struct {
 	SenderName   string        `json:"sender_name" split_words:"true"`
 }
 
+func (c *SMTPConfiguration) Validate() error {
+	return nil
+}
+
 type MailerConfiguration struct {
 	Autoconfirm              bool                      `json:"autoconfirm"`
 	Subjects                 EmailContentConfiguration `json:"subjects"`
@@ -272,6 +316,10 @@ func LoadGlobal(filename string) (*GlobalConfiguration, error) {
 		config.SMTP.MaxFrequency = 1 * time.Minute
 	}
 
+	if err := config.Validate(); err != nil {
+		return nil, err
+	}
+
 	if err := config.ApplyDefaults(); err != nil {
 		return nil, err
 	}
diff --git a/conf/tracing.go b/conf/tracing.go
index 8f51ffe9..519a6622 100644
--- a/conf/tracing.go
+++ b/conf/tracing.go
@@ -16,6 +16,10 @@ type TracingConfig struct {
 	Tags        map[string]string
 }
 
+func (c *TracingConfig) Validate() error {
+	return nil
+}
+
 func (tc *TracingConfig) tracingAddr() string {
 	return fmt.Sprintf("%s:%s", tc.Host, tc.Port)
 }
diff --git a/go.mod b/go.mod
index 2746cd13..b5afd760 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62
 	github.com/beevik/etree v1.1.0
 	github.com/coreos/go-oidc/v3 v3.0.0
+	github.com/crewjam/saml v0.4.8 // indirect
 	github.com/didip/tollbooth/v5 v5.1.1
 	github.com/fatih/color v1.10.0 // indirect
 	github.com/go-chi/chi v4.0.2+incompatible
@@ -48,7 +49,7 @@ require (
 	github.com/sirupsen/logrus v1.7.0
 	github.com/spf13/cobra v1.1.3
 	github.com/stretchr/testify v1.6.1
-	golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
+	golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed
 	golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
 	golang.org/x/sync v0.0.0-20201207232520-09787c993a3a // indirect
diff --git a/go.sum b/go.sum
index e059f2cb..65da0eb3 100644
--- a/go.sum
+++ b/go.sum
@@ -90,6 +90,10 @@ github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwc
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo=
+github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4=
+github.com/crewjam/saml v0.4.8 h1:XPrpg7DOIqh7AvMXPMRzHylzzb/ltgnfRRilwCgYb5A=
+github.com/crewjam/saml v0.4.8/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -178,6 +182,8 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
 github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
 github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
+github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -216,6 +222,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@@ -598,6 +606,8 @@ golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed h1:YoWVYYAfvQ4ddHv3OKmIvX7NCAhFGTj62VP2l2kfBbA=
+golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -666,6 +676,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200927032502-5d4f70055728/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba h1:6u6sik+bn/y7vILcYkK3iwTBWN7WtBvB0+SZswQnbf8=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -927,6 +938,7 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=


Test patch:
diff --git a/api/external_saml_test.go b/api/external_saml_test.go
deleted file mode 100644
index 9ed225a5..00000000
--- a/api/external_saml_test.go
+++ /dev/null
@@ -1,220 +0,0 @@
-package api
-
-import (
-	"crypto/x509"
-	"encoding/base64"
-	"encoding/pem"
-	"encoding/xml"
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"net/url"
-	"path/filepath"
-	"strings"
-	"testing"
-	"text/template"
-	"time"
-
-	"github.com/beevik/etree"
-	"github.com/gofrs/uuid"
-	"github.com/netlify/gotrue/conf"
-	"github.com/netlify/gotrue/models"
-	"github.com/russellhaering/gosaml2/types"
-	dsig "github.com/russellhaering/goxmldsig"
-	"github.com/stretchr/testify/require"
-	"github.com/stretchr/testify/suite"
-)
-
-type ExternalSamlTestSuite struct {
-	suite.Suite
-	API        *API
-	Config     *conf.Configuration
-	instanceID uuid.UUID
-}
-
-func TestExternalSaml(t *testing.T) {
-	api, config, instanceID, err := setupAPIForTestForInstance()
-	require.NoError(t, err)
-
-	ts := &ExternalSamlTestSuite{
-		API:        api,
-		Config:     config,
-		instanceID: instanceID,
-	}
-	defer api.db.Close()
-
-	suite.Run(t, ts)
-}
-
-func (ts *ExternalSamlTestSuite) SetupTest() {
-	models.TruncateAll(ts.API.db)
-}
-
-func (ts *ExternalSamlTestSuite) docFromTemplate(path string, data interface{}) *etree.Document {
-	doc := etree.NewDocument()
-
-	templ, err := template.ParseFiles(path)
-	ts.Require().NoError(err)
-	read, write := io.Pipe()
-	go func() {
-		defer write.Close()
-		err := templ.Execute(write, data)
-		ts.Require().NoError(err)
-	}()
-	_, err = doc.ReadFrom(read)
-	ts.Require().NoError(err)
-
-	return doc
-}
-
-func (ts *ExternalSamlTestSuite) setupSamlExampleResponse(keyStore dsig.X509KeyStore) string {
-	path := filepath.Join("testdata", "saml-response.xml")
-	type ResponseParams struct {
-		Now       string
-		NotBefore string
-		NotAfter  string
-	}
-	now := time.Now()
-	doc := ts.docFromTemplate(path, ResponseParams{
-		Now:       now.Format(time.RFC3339),
-		NotBefore: now.Add(-5 * time.Minute).Format(time.RFC3339),
-		NotAfter:  now.Add(5 * time.Minute).Format(time.RFC3339),
-	})
-
-	// sign
-	resp := doc.SelectElement("Response")
-	ctx := dsig.NewDefaultSigningContext(keyStore)
-	sig, err := ctx.ConstructSignature(resp, true)
-	ts.Require().NoError(err, "Response signature failed")
-	respWithSig := resp.Copy()
-	var children []etree.Token
-	children = append(children, respWithSig.Child[0])     // issuer is always first
-	children = append(children, sig)                      // next is the signature
-	children = append(children, respWithSig.Child[1:]...) // then all other children
-	respWithSig.Child = children
-	doc.SetRoot(respWithSig)
-
-	docRaw, err := doc.WriteToBytes()
-	ts.Require().NoError(err)
-
-	return base64.StdEncoding.EncodeToString(docRaw)
-}
-
-func (ts *ExternalSamlTestSuite) setupSamlExampleState() string {
-	req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=saml", nil)
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
-	ts.Require().Equal(http.StatusFound, w.Code)
-
-	u, err := url.Parse(w.Header().Get("Location"))
-	ts.Require().NoError(err, "redirect url parse failed")
-
-	urlBase, _ := url.Parse(u.String())
-	urlBase.RawQuery = ""
-	ts.Equal(urlBase.String(), "https://idp/saml2test/redirect")
-
-	q := u.Query()
-	state := q.Get("RelayState")
-	ts.Require().NotEmpty(state)
-	return state
-}
-
-func (ts *ExternalSamlTestSuite) setupSamlMetadata() (*httptest.Server, dsig.X509KeyStore) {
-	idpKeyStore := dsig.RandomKeyStoreForTest()
-	_, idpCert, _ := idpKeyStore.GetKeyPair()
-
-	path := filepath.Join("testdata", "saml-idp-metadata.xml")
-	type MetadataParams struct {
-		Cert string
-	}
-	doc := ts.docFromTemplate(path, MetadataParams{Cert: base64.StdEncoding.EncodeToString(idpCert)})
-	metadata, err := doc.WriteToString()
-	ts.Require().NoError(err)
-
-	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("Content-Type", "application/xml")
-		w.WriteHeader(200)
-		io.WriteString(w, metadata)
-	}))
-	return server, idpKeyStore
-}
-
-func (ts *ExternalSamlTestSuite) setupSamlSPCert() (string, string) {
-	spKeyStore := dsig.RandomKeyStoreForTest()
-	key, cert, _ := spKeyStore.GetKeyPair()
-	keyBytes := pem.EncodeToMemory(&pem.Block{
-		Type:  "PRIVATE KEY",
-		Bytes: x509.MarshalPKCS1PrivateKey(key),
-	})
-	certBytes := pem.EncodeToMemory(&pem.Block{
-		Type:  "CERTIFICATE",
-		Bytes: cert,
-	})
-	return string(keyBytes), string(certBytes)
-}
-
-func (ts *ExternalSamlTestSuite) TestSignupExternalSaml_Callback() {
-	ts.SetupTest()
-	server, idpKeyStore := ts.setupSamlMetadata()
-	defer server.Close()
-	ts.Config.External.Saml.MetadataURL = server.URL
-
-	key, cert := ts.setupSamlSPCert()
-	ts.Config.External.Saml.SigningKey = key
-	ts.Config.External.Saml.SigningCert = cert
-
-	form := url.Values{}
-	form.Add("RelayState", ts.setupSamlExampleState())
-	form.Add("SAMLResponse", ts.setupSamlExampleResponse(idpKeyStore))
-	req := httptest.NewRequest(http.MethodPost, "http://localhost/saml/acs", strings.NewReader(form.Encode()))
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
-
-	ts.Require().Equal(http.StatusFound, w.Code)
-
-	u, err := url.Parse(w.Header().Get("Location"))
-	ts.Require().NoError(err, "redirect url parse failed")
-
-	v, err := url.ParseQuery(u.Fragment)
-	ts.Require().NoError(err)
-	ts.Empty(v.Get("error_description"))
-	ts.Empty(v.Get("error"))
-
-	ts.NotEmpty(v.Get("access_token"))
-	ts.NotEmpty(v.Get("refresh_token"))
-	ts.NotEmpty(v.Get("expires_in"))
-	ts.Equal("bearer", v.Get("token_type"))
-
-	// ensure user has been created
-	_, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "saml@example.com", ts.Config.JWT.Aud)
-	ts.Require().NoError(err)
-}
-
-func (ts *ExternalSamlTestSuite) TestMetadata() {
-	server, _ := ts.setupSamlMetadata()
-	defer server.Close()
-	ts.Config.External.Saml.MetadataURL = server.URL
-
-	key, cert := ts.setupSamlSPCert()
-	ts.Config.External.Saml.SigningKey = key
-	ts.Config.External.Saml.SigningCert = cert
-
-	req := httptest.NewRequest(http.MethodGet, "http://localhost/saml/metadata", nil)
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
-
-	ts.Require().Equal(http.StatusOK, w.Code)
-
-	md := &types.EntityDescriptor{}
-	err := xml.NewDecoder(w.Body).Decode(md)
-	ts.Require().NoError(err)
-
-	ts.Equal("http://localhost/saml", md.EntityID)
-	for _, acs := range md.SPSSODescriptor.AssertionConsumerServices {
-		if acs.Binding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" {
-			ts.Equal("http://localhost/saml/acs", acs.Location)
-			break
-		}
-	}
-}
diff --git a/api/saml_test.go b/api/saml_test.go
new file mode 100644
index 00000000..0264346b
--- /dev/null
+++ b/api/saml_test.go
@@ -0,0 +1,46 @@
+package api
+
+import (
+	tst "testing"
+
+	"encoding/xml"
+	"net/http"
+	"net/http/httptest"
+
+	"github.com/crewjam/saml"
+	"github.com/netlify/gotrue/conf"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSAMLMetadataWithAPI(t *tst.T) {
+	config := &conf.GlobalConfiguration{}
+	config.API.ExternalURL = "https://projectref.supabase.co/auth/v1/"
+	config.SAML.Enabled = true
+	config.SAML.PrivateKey = "MIIEpAIBAAKCAQEA7i+IEc9ziokzITndZre0fGtCP+34ID8EPR6BOgdUnPtNr/0PSB0g0gwpcgb1GYCgqEsz5tkP0hvHOBYJJi9Q0rbxzBVz6FodNUaV0gN2LGjuJ7ISvKbmNvSjjtsbMlOY42bWIn2lVVvZX3Em0YouY+TEHae6m0P46YbM+g86LdLZA+4xU+nIssAAgqxi31prJrEt7cj9jU0vP4qqGJvGy1Z+SfjKyL3ibLLn/ES3VPK9YUNNak8t+4vRruEpDx3dXFLzezz7scT6cY1w1fUhwyZuakxruAuXL7hYisitUVcQNNh6as3GPaxBYeHCxRKGd6UvXvmid2Ji+BcyRaNJywIDAQABAoIBAQCzxipkjvi9Morl5B/onHVchzRMvldON2ICo5iT7N5/Ueo0D8POATY5c7aAeyHZqs0X2RMGhQS85/x4p6EmMgZF1JEyIWsHj6SGBo2kIrq6EETYrz4XJ72Q8xrUAypG1PQLhx3OkJkOkTHDKMtM0ofrG8quO1/MOwSPdhAWGRInbI0swhLMFuudoAca7JZUOTKEwf2ZoSSfd34T3d+JlbWwc3sI0FyLVGQk4a4Nn5nA6GcQGqNUtZV/kowGTTKLmXtl9JsIHhdDrdfUbWaTxU7kH1GI8c/N3SJLFYf4VjqvO6y/rGV0gu4kkVll6lyIiJY0H7HVID8Uluq9LriG/u4xAoGBAP8m7s3MfzLiJZcN76qOKAY6ifLdHQsCxILVtBTxWRNfbBtwBeKUuXASwBPZvrlVZY8zuvRS5IsFSmsNCykC/NN/ixme2sATQ95gIr5/99cn/VCJN1WAg+vEIKTe8vQ9/xvLRlmam5aNSb+IZK2drxiNCqY0KgK7o582s0ao40DZAoGBAO76Ki0JXchpZHm+P/S0VryvzkOfOo8NPhqVwbf16ClSKs0xh5qm2qiCoL9dT/LuP9hKNKuXITe05Tlvt2jJprVKEa7ZiWChJKoYMK49xO2bhugbD3EWfZzwPlqWkXWp9J0M2734H0BZ5C/S2uJOa6OM43JdjiDlZtv0eV0K4TlDAoGACw2NtL6SGAsx46xH4JSN6U8p4KpxcqOpDZ3iUjHuUOeGUF3280zMB63YQkPhfjfT0XNFOwZIPjl5cs+61wj5GNRimzaFdWKgQwbZGnWCsABcedun2P1bOaBiZaH+1lPFNFgG8STAbrIqKrDm45p8Fk0t0+tYkou8V85Pz9TLG2kCgYBSAjNbBS3TyqcaSDl2ZjSlx8cG2aukz6ySPvYdhRvIolPh2q9oWP4MeddkFEeNXEk4li6A+/oAPemzziyonxrAd8ydrjVAncwROv/pq3Ta/VZMbIzeCBPPOXqMZ8M/F7XD91epV26SDMcLylYm9zZlB8I9yvEtRMwJi1ningswNQKBgQDCh4dFLP5Eg9ML9pHqzD8WMfKSzoMNQtDR/hcLFZyGkilB+/lyIOCJwQLNKiF41tzq9Zb0I4WYEszqcvsVX3i6WC+D+/LsmjLDI90y99SsmNrChvAGdiClS1lgX9XmvHKaaK3/EUCdueLGP5Gfwf9NBq7VZ9Pyzw5olh1xWiehpw=="
+
+	require.NoError(t, config.ApplyDefaults())
+
+	require.NotNil(t, config.SAML.Certificate)
+
+	api := NewAPI(config, nil)
+
+	// Setup request
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/saml/metadata", nil)
+
+	w := httptest.NewRecorder()
+	api.handler.ServeHTTP(w, req)
+	require.Equal(t, w.Code, http.StatusOK)
+
+	t.Logf("SAML Metadata XML: %q", w.Body.Bytes())
+
+	metadata := saml.EntityDescriptor{}
+	require.NoError(t, xml.Unmarshal(w.Body.Bytes(), &metadata))
+
+	require.Equal(t, metadata.EntityID, "https://projectref.supabase.co/auth/v1/saml/metadata")
+	require.Equal(t, metadata.SPSSODescriptors[0].AssertionConsumerServices[0].Location, "https://projectref.supabase.co/auth/v1/saml/acs")
+	require.Equal(t, metadata.SPSSODescriptors[0].AssertionConsumerServices[1].Location, "https://projectref.supabase.co/auth/v1/saml/acs")
+	require.Equal(t, metadata.SPSSODescriptors[0].SingleLogoutServices[0].Location, "https://projectref.supabase.co/auth/v1/saml/slo")
+
+	require.Equal(t, metadata.SPSSODescriptors[0].KeyDescriptors[0].Use, "encryption")
+	require.Equal(t, metadata.SPSSODescriptors[0].KeyDescriptors[1].Use, "signing")
+}