diff --git a/internal/api/api.go b/internal/api/api.go
index a09d045cb6..17ce68d7b6 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -293,7 +293,6 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
})
})
})
-
})
})
diff --git a/internal/api/apierrors/errorcode.go b/internal/api/apierrors/errorcode.go
index 2a151924ba..b764de80de 100644
--- a/internal/api/apierrors/errorcode.go
+++ b/internal/api/apierrors/errorcode.go
@@ -57,6 +57,7 @@ const (
ErrorCodeSAMLAssertionNoEmail ErrorCode = "saml_assertion_no_email"
ErrorCodeUserAlreadyExists ErrorCode = "user_already_exists"
ErrorCodeSSOProviderNotFound ErrorCode = "sso_provider_not_found"
+ ErrorCodeSSOProviderDisabled ErrorCode = "sso_provider_disabled"
ErrorCodeSAMLMetadataFetchFailed ErrorCode = "saml_metadata_fetch_failed"
ErrorCodeSAMLIdPAlreadyExists ErrorCode = "saml_idp_already_exists"
ErrorCodeSSODomainAlreadyExists ErrorCode = "sso_domain_already_exists"
diff --git a/internal/api/samlacs.go b/internal/api/samlacs.go
index 104511b171..df840f0636 100644
--- a/internal/api/samlacs.go
+++ b/internal/api/samlacs.go
@@ -103,6 +103,12 @@ func (a *API) handleSamlAcs(w http.ResponseWriter, r *http.Request) error {
return apierrors.NewInternalServerError("Unable to find SSO Provider from SAML RelayState")
}
+ if !ssoProvider.IsEnabled() {
+ return apierrors.NewNotFoundError(
+ apierrors.ErrorCodeSSOProviderDisabled,
+ "SSO Provider assigned for this domain is currently disabled")
+ }
+
initiatedBy = "sp"
entityId = ssoProvider.SAMLProvider.EntityID
redirectTo = relayState.RedirectTo
@@ -156,6 +162,12 @@ func (a *API) handleSamlAcs(w http.ResponseWriter, r *http.Request) error {
return err
}
+ if !ssoProvider.IsEnabled() {
+ return apierrors.NewNotFoundError(
+ apierrors.ErrorCodeSSOProviderDisabled,
+ "SSO Provider assigned for this domain is currently disabled")
+ }
+
idpMetadata, err := ssoProvider.SAMLProvider.EntityDescriptor()
if err != nil {
return err
diff --git a/internal/api/sso.go b/internal/api/sso.go
index 83667cfec5..9b803f4ce0 100644
--- a/internal/api/sso.go
+++ b/internal/api/sso.go
@@ -52,25 +52,8 @@ func (a *API) SingleSignOn(w http.ResponseWriter, r *http.Request) error {
if hasProviderID, err = params.validate(); err != nil {
return err
}
- codeChallengeMethod := params.CodeChallengeMethod
- codeChallenge := params.CodeChallenge
-
- if err := validatePKCEParams(codeChallengeMethod, codeChallenge); err != nil {
- return err
- }
- flowType := getFlowFromChallenge(params.CodeChallenge)
- var flowStateID *uuid.UUID
- flowStateID = nil
- if isPKCEFlow(flowType) {
- flowState, err := generateFlowState(db, models.SSOSAML.String(), models.SSOSAML, codeChallengeMethod, codeChallenge, nil)
- if err != nil {
- return err
- }
- flowStateID = &flowState.ID
- }
var ssoProvider *models.SSOProvider
-
if hasProviderID {
ssoProvider, err = models.FindSSOProviderByID(db, params.ProviderID)
if models.IsNotFoundError(err) {
@@ -87,6 +70,29 @@ func (a *API) SingleSignOn(w http.ResponseWriter, r *http.Request) error {
}
}
+ if !ssoProvider.IsEnabled() {
+ return apierrors.NewNotFoundError(
+ apierrors.ErrorCodeSSOProviderDisabled,
+ "SSO Provider is currently disabled")
+ }
+
+ codeChallengeMethod := params.CodeChallengeMethod
+ codeChallenge := params.CodeChallenge
+
+ if err := validatePKCEParams(codeChallengeMethod, codeChallenge); err != nil {
+ return err
+ }
+ flowType := getFlowFromChallenge(params.CodeChallenge)
+ var flowStateID *uuid.UUID
+ flowStateID = nil
+ if isPKCEFlow(flowType) {
+ flowState, err := generateFlowState(db, models.SSOSAML.String(), models.SSOSAML, codeChallengeMethod, codeChallenge, nil)
+ if err != nil {
+ return err
+ }
+ flowStateID = &flowState.ID
+ }
+
entityDescriptor, err := ssoProvider.SAMLProvider.EntityDescriptor()
if err != nil {
return apierrors.NewInternalServerError("Error parsing SAML Metadata for SAML provider").WithInternalError(err)
diff --git a/internal/api/ssoadmin.go b/internal/api/ssoadmin.go
index 682423a568..1b1d9519c7 100644
--- a/internal/api/ssoadmin.go
+++ b/internal/api/ssoadmin.go
@@ -19,22 +19,32 @@ import (
"github.com/supabase/auth/internal/utilities"
)
-// 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.
+// loadSSOProvider looks for an idp_id and first checks it for a "resource_"
+// prefix, if present the provider is loaded by resource_id. Otherwise the
+// provider is loaded by id.
func (a *API) loadSSOProvider(w http.ResponseWriter, r *http.Request) (context.Context, error) {
ctx := r.Context()
db := a.db.WithContext(ctx)
- idpParam := chi.URLParam(r, "idp_id")
+ var (
+ provider *models.SSOProvider
+ err error
+ )
- idpID, err := uuid.FromString(idpParam)
- if err != nil {
- // idpParam is not UUIDv4
- return nil, apierrors.NewNotFoundError(apierrors.ErrorCodeSSOProviderNotFound, "SSO Identity Provider not found")
+ const resourcePrefix = "resource_"
+ idpParam := chi.URLParam(r, "idp_id")
+ switch {
+ case strings.HasPrefix(idpParam, resourcePrefix):
+ resourceID := strings.TrimPrefix(idpParam, resourcePrefix)
+ provider, err = models.FindSSOProviderByResourceID(db, resourceID)
+ default:
+ idpID, idpErr := uuid.FromString(idpParam)
+ if idpErr != nil {
+ return nil, apierrors.NewNotFoundError(apierrors.ErrorCodeSSOProviderNotFound, "SSO Identity Provider not found")
+ }
+ provider, err = models.FindSSOProviderByID(db, idpID)
}
- // idpParam is a UUIDv4
- provider, err := models.FindSSOProviderByID(db, idpID)
if err != nil {
if models.IsNotFoundError(err) {
return nil, apierrors.NewNotFoundError(apierrors.ErrorCodeSSOProviderNotFound, "SSO Identity Provider not found")
@@ -44,17 +54,16 @@ func (a *API) loadSSOProvider(w http.ResponseWriter, r *http.Request) (context.C
}
observability.LogEntrySetField(r, "sso_provider_id", provider.ID.String())
-
return withSSOProvider(r.Context(), provider), nil
}
-// adminSSOProvidersList lists all SAML SSO Identity Providers in the system. Does
+// adminSSOProvidersList lists all 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 {
ctx := r.Context()
db := a.db.WithContext(ctx)
- providers, err := models.FindAllSAMLProviders(db)
+ providers, err := models.FindAllSSOProvidersByFilter(db, r.URL.Query())
if err != nil {
return err
}
@@ -77,6 +86,9 @@ type CreateSSOProviderParams struct {
Domains []string `json:"domains"`
AttributeMapping models.SAMLAttributeMapping `json:"attribute_mapping"`
NameIDFormat string `json:"name_id_format"`
+
+ ResourceID *string `json:"resource_id,omitempty"`
+ Disabled *bool `json:"disabled,omitempty"`
}
func (p *CreateSSOProviderParams) validate(forUpdate bool) error {
@@ -223,6 +235,7 @@ func (a *API) adminSSOProvidersCreate(w http.ResponseWriter, r *http.Request) er
}
provider := &models.SSOProvider{
+
// TODO handle Name, Description, Attribute Mapping
SAMLProvider: models.SAMLProvider{
EntityID: metadata.EntityID,
@@ -230,10 +243,15 @@ func (a *API) adminSSOProvidersCreate(w http.ResponseWriter, r *http.Request) er
},
}
+ if params.ResourceID != nil {
+ provider.ResourceID = params.ResourceID
+ }
+ if params.Disabled != nil {
+ provider.Disabled = params.Disabled
+ }
if params.MetadataURL != "" {
provider.SAMLProvider.MetadataURL = ¶ms.MetadataURL
}
-
if params.NameIDFormat != "" {
provider.SAMLProvider.NameIDFormat = ¶ms.NameIDFormat
}
@@ -374,6 +392,28 @@ func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) er
}
}
+ if params.ResourceID != nil {
+ resourceID := *params.ResourceID
+ switch {
+ case resourceID == "" && provider.ResourceID != nil:
+ provider.ResourceID = nil
+ modified = true
+ case resourceID != "" &&
+ (provider.ResourceID == nil ||
+ *provider.ResourceID != resourceID):
+ provider.ResourceID = &resourceID
+ modified = true
+ }
+ }
+
+ if params.Disabled != nil {
+ disabled := *params.Disabled
+ if provider.Disabled == nil || *provider.Disabled != disabled {
+ provider.Disabled = &disabled
+ modified = true
+ }
+ }
+
if modified {
if err := db.Transaction(func(tx *storage.Connection) error {
if terr := tx.Eager().Update(provider); terr != nil {
diff --git a/internal/api/ssoadmin_test.go b/internal/api/ssoadmin_test.go
new file mode 100644
index 0000000000..4bedd009d1
--- /dev/null
+++ b/internal/api/ssoadmin_test.go
@@ -0,0 +1,572 @@
+package api_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/crewjam/saml"
+ "github.com/stretchr/testify/require"
+ "github.com/supabase/auth/internal/api"
+ "github.com/supabase/auth/internal/api/apierrors"
+ "github.com/supabase/auth/internal/e2e"
+ "github.com/supabase/auth/internal/e2e/e2eapi"
+ "github.com/supabase/auth/internal/models"
+)
+
+const testMetadataXMLTemplate = `MIIDqjCCApKgAwIBAgIGAZfq8svbMA0GCSqGSIb3DQEBCwUAMIGVMQswCQYDVQQGEwJVUzETMBEG
+A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
+MBIGA1UECwwLU1NPUHJvdmlkZXIxFjAUBgNVBAMMDXRyaWFsLTgxNzg2MTQxHDAaBgkqhkiG9w0B
+CQEWDWluZm9Ab2t0YS5jb20wHhcNMjUwNzA4MTY1MDA5WhcNMzUwNzA4MTY1MTA5WjCBlTELMAkG
+A1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTAL
+BgNVBAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRYwFAYDVQQDDA10cmlhbC04MTc4NjE0
+MRwwGgYJKoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
+CgKCAQEAviGBsGh17McdQQPdTkI5Stw9wh3SdTpqXQ2C7Us8GMXKmV5/07Gh1tgNKeQDACgdox9v
+nhXyNBjAW5ANITAQAsDwD8k9unZhksEGe5A/v4/Reas8gtYXMj2iVO1TaaM3ZIGamCbMtDybsX+a
+6HCcnWv0LcGXuQLqNApWcKdZ9mNiAMAf3ATucwced8Yl950FsXobEf6bVFiOpIoL5tE4AjOfgfrK
+Vm+p9PxuQh4vl4j8Iw9UkCyiLOwcJyo5XikfM7BeYsoU9WVV85/pXuI6vWMk4zsOfVjsLRhsEI7K
+jbjkIxClLB43V+YOa+IN03Zmsxulp4Tm7AEvw3rlDIUpcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB
+AQAPpGUfNkFzDP2Xzzd8Y+MoWZshbZYLANWcNNIyb5ajqX7CU/vJmOniYpZVp0f7n3Yu6DV28KiL
+AvB46YNtsAtuUtEendEiIO5vcef5o0I4pKUz9RVT1WZIycCHbW9/qhPHpuRVVgpJcp2hleq7eisl
+TYE5YIIH7ovMWAc0RuC3YqNttdq8ampcTxDer9VSVsFFXK/TqxcB7rwgCv6Q9jqf/7dsVKKFyIuS
+NvmYIdjXXfEV0OsftSN/+s+UqtDEqEXR0Bmd51k0OJUm+2iNyu8Nh5Sr2M2475Gk2PSdPbzYdEoi
+nfcdcTq+SxjLzHdQyv2U8TiLwZhRNXcrY8kCqgbyurn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`
+
+func TestE2EAdmin(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+ defer cancel()
+
+ t.Run("Admin", func(t *testing.T) {
+ globalCfg := e2e.Must(e2e.Config())
+
+ inst, err := e2eapi.New(globalCfg)
+ require.NoError(t, err)
+ defer inst.Close()
+
+ t.Run("SSO", func(t *testing.T) {
+ getTestMetadata := func(label string) string {
+ return fmt.Sprintf(testMetadataXMLTemplate, label)
+ }
+ getTestAttributes := func() map[string]models.SAMLAttribute {
+ return map[string]models.SAMLAttribute{
+ "TestE2EAdmin": models.SAMLAttribute{
+ Default: true,
+ },
+ "customAttr": models.SAMLAttribute{
+ Default: "somevalue",
+ },
+ "email": models.SAMLAttribute{
+ Name: "user.email",
+ },
+ "first_name": models.SAMLAttribute{
+ Name: "user.firstName",
+ },
+ "last_name": models.SAMLAttribute{
+ Name: "user.lastName",
+ },
+ "user_name": models.SAMLAttribute{
+ Name: "user.login",
+ },
+ }
+ }
+
+ checkAPIError := func(t *testing.T, b []byte) {
+ apiError := new(apierrors.HTTPError)
+ if err := json.Unmarshal(b, &apiError); err != nil {
+ return
+ }
+
+ if apiError.Message != "" {
+ require.NoError(t, apiError)
+ }
+ }
+
+ checkHTTPRes := func(t *testing.T, httpRes *http.Response, exp any) {
+ require.GreaterOrEqual(t, httpRes.StatusCode, 200)
+ require.LessOrEqual(t, httpRes.StatusCode, 201)
+
+ body, err := io.ReadAll(httpRes.Body)
+ require.NoError(t, err)
+
+ checkAPIError(t, body)
+
+ rdr := bytes.NewReader(body)
+ err = json.NewDecoder(rdr).Decode(exp)
+ require.NoError(t, err)
+ }
+
+ // basic check
+ checkProvider := func(t *testing.T, pr *models.SSOProvider) {
+ const zeroID = "00000000-0000-0000-0000-000000000000"
+ require.NotNil(t, pr)
+ require.NotNil(t, pr.ID)
+ require.NotEqual(t, pr.ID.String(), zeroID)
+
+ require.True(t, len(pr.ID.String()) > 0)
+ require.NotNil(t,
+ pr.SAMLProvider.NameIDFormat)
+ require.Equal(t,
+ *pr.SAMLProvider.NameIDFormat,
+ string(saml.EmailAddressNameIDFormat))
+
+ require.True(t, len(pr.SSODomains) > 0)
+ for _, domain := range pr.SSODomains {
+ require.NotEmpty(t, domain.Domain)
+ }
+
+ require.True(t, len(pr.SSODomains) > 0)
+ require.LessOrEqual(t, pr.CreatedAt, pr.UpdatedAt)
+ }
+
+ checkProviderMap := func(t *testing.T, m map[string]*models.SSOProvider) {
+ for k, v := range m {
+ require.Equal(t, k, v.ID.String())
+ checkProvider(t, v)
+ }
+ }
+
+ equalProviderParams := func(t *testing.T,
+ params *api.CreateSSOProviderParams,
+ pr *models.SSOProvider,
+ ) {
+ checkProvider(t, pr)
+
+ require.NotNil(t, params)
+
+ if params.ResourceID != nil {
+ require.NotNil(t, pr.ResourceID)
+ require.Equal(t, *params.ResourceID, *pr.ResourceID)
+ }
+
+ if len(params.Domains) > 0 {
+ require.Equal(t, len(params.Domains), len(pr.SSODomains))
+ }
+
+ if params.NameIDFormat != "" {
+ require.NotNil(t, pr.SAMLProvider.NameIDFormat)
+ require.Equal(t,
+ *pr.SAMLProvider.NameIDFormat,
+ params.NameIDFormat)
+ }
+
+ if len(params.Domains) > 0 {
+ require.Equal(t, len(params.Domains), len(pr.SSODomains))
+ for _, domain := range params.Domains {
+ var ok bool
+ for _, v := range pr.SSODomains {
+ if domain == v.Domain {
+ ok = true
+ }
+ }
+ require.True(t, ok, "Could not find domain %s", domain)
+ }
+ }
+
+ if len(params.AttributeMapping.Keys) > 0 {
+ require.True(t,
+ params.AttributeMapping.Equal(
+ &pr.SAMLProvider.AttributeMapping))
+ }
+ }
+
+ equalProvider := func(t *testing.T, a, b *models.SSOProvider) {
+ checkProvider(t, a)
+ checkProvider(t, b)
+
+ require.Equal(t, a.ID, b.ID)
+ require.Equal(t,
+ a.ResourceID,
+ b.ResourceID)
+
+ require.Equal(t, a.SSODomains, b.SSODomains)
+
+ require.Equal(t,
+ a.SAMLProvider.ID,
+ b.SAMLProvider.ID)
+ require.Equal(t,
+ a.SAMLProvider.EntityID,
+ b.SAMLProvider.EntityID)
+ require.True(t,
+ a.SAMLProvider.AttributeMapping.Equal(
+ &b.SAMLProvider.AttributeMapping))
+ require.Equal(t,
+ a.SAMLProvider.NameIDFormat,
+ b.SAMLProvider.NameIDFormat)
+ require.Equal(t,
+ a.SAMLProvider.CreatedAt,
+ b.SAMLProvider.CreatedAt)
+ require.Equal(t,
+ a.SAMLProvider.UpdatedAt,
+ b.SAMLProvider.UpdatedAt)
+ }
+
+ equalProviderMaps := func(t *testing.T, a, b map[string]*models.SSOProvider) {
+ for expKey, expPr := range a {
+ gotPr, ok := b[expKey]
+ require.True(t, ok)
+ equalProvider(t, expPr, gotPr)
+ }
+ }
+
+ listToMap := func(list []*models.SSOProvider) map[string]*models.SSOProvider {
+ out := make(map[string]*models.SSOProvider)
+ for _, pr := range list {
+ out[pr.ID.String()] = pr
+ }
+ return out
+ }
+
+ listProvidersWithFilter := func(
+ t *testing.T,
+ filter url.Values,
+ ) map[string]*models.SSOProvider {
+ url := "/admin/sso/providers?" + filter.Encode()
+
+ httpReq, err := http.NewRequestWithContext(
+ ctx, "GET", url, nil)
+ require.NoError(t, err)
+
+ httpRes, err := inst.DoAdmin(httpReq)
+ require.NoError(t, err)
+
+ var res struct {
+ Items []*models.SSOProvider `json:"items"`
+ }
+ checkHTTPRes(t, httpRes, &res)
+
+ prMap := listToMap(res.Items)
+ checkProviderMap(t, prMap)
+ return prMap
+ }
+
+ listProviders := func(t *testing.T) map[string]*models.SSOProvider {
+ prMap := listProvidersWithFilter(t, nil)
+ for k, pr := range prMap {
+ keys := pr.SAMLProvider.AttributeMapping.Keys
+ if _, ok := keys["TestE2EAdmin"]; !ok {
+ delete(prMap, k)
+ }
+ }
+ return prMap
+ }
+
+ getProvider := func(
+ t *testing.T,
+ urlSegment string,
+ ) (res *models.SSOProvider) {
+ t.Run("Get/"+urlSegment, func(t *testing.T) {
+ url := "/admin/sso/" + urlSegment
+ httpReq, err := http.NewRequestWithContext(
+ ctx, "GET", url, nil)
+ require.NoError(t, err)
+
+ httpRes, err := inst.DoAdmin(httpReq)
+ require.NoError(t, err)
+
+ res = new(models.SSOProvider)
+ checkHTTPRes(t, httpRes, res)
+ checkProvider(t, res)
+ })
+ return
+ }
+
+ updateProvider := func(
+ t *testing.T,
+ urlSegment string,
+ req *api.CreateSSOProviderParams,
+ ) (res *models.SSOProvider) {
+ t.Run("Update/"+urlSegment, func(t *testing.T) {
+ url := "/admin/sso/" + urlSegment
+ body := new(bytes.Buffer)
+ err := json.NewEncoder(body).Encode(req)
+ require.NoError(t, err)
+
+ httpReq, err := http.NewRequestWithContext(
+ ctx, "PUT", url, body)
+ require.NoError(t, err)
+
+ httpRes, err := inst.DoAdmin(httpReq)
+ require.NoError(t, err)
+
+ res = new(models.SSOProvider)
+ checkHTTPRes(t, httpRes, res)
+ checkProvider(t, res)
+ equalProviderParams(t, req, res)
+ })
+ return
+ }
+
+ deleteProvider := func(
+ t *testing.T,
+ urlSegment string,
+ ) (res *models.SSOProvider) {
+ t.Run("Delete/"+urlSegment, func(t *testing.T) {
+ url := "/admin/sso/" + urlSegment
+ httpReq, err := http.NewRequestWithContext(
+ ctx, "DELETE", url, nil)
+ require.NoError(t, err)
+
+ httpRes, err := inst.DoAdmin(httpReq)
+ require.NoError(t, err)
+
+ res = new(models.SSOProvider)
+ checkHTTPRes(t, httpRes, res)
+ checkProvider(t, res)
+ })
+ return
+ }
+
+ cleanProviders := func(t *testing.T, name string) {
+ t.Run("Clean/"+name, func(t *testing.T) {
+ prMap := listProviders(t)
+ for _, pr := range prMap {
+ keys := pr.SAMLProvider.AttributeMapping.Keys
+ if _, ok := keys["TestE2EAdmin"]; ok {
+ deleteProvider(t, "providers/"+pr.ID.String())
+ }
+ }
+ })
+ }
+
+ // cleanup providers before & after tests
+ cleanProviders(t, "Before")
+ defer cleanProviders(t, "After")
+
+ createProvider := func(
+ t *testing.T,
+ req *api.CreateSSOProviderParams,
+ ) (res *models.SSOProvider) {
+ t.Run("Create", func(t *testing.T) {
+ body := new(bytes.Buffer)
+ err := json.NewEncoder(body).Encode(req)
+ require.NoError(t, err)
+
+ httpReq, err := http.NewRequestWithContext(
+ ctx, "POST", "/admin/sso/providers", body)
+ require.NoError(t, err)
+
+ httpRes, err := inst.DoAdmin(httpReq)
+ require.NoError(t, err)
+
+ res = new(models.SSOProvider)
+ checkHTTPRes(t, httpRes, res)
+
+ checkProvider(t, res)
+ equalProviderParams(t, req, res)
+ })
+ return
+ }
+
+ t.Run("ByProviderID", func(t *testing.T) {
+ const label = "by-provider-id"
+
+ createReq := &api.CreateSSOProviderParams{
+ Type: "saml",
+ MetadataURL: "",
+ MetadataXML: getTestMetadata(label),
+ Domains: []string{
+ label + ".local",
+ },
+ AttributeMapping: models.SAMLAttributeMapping{
+ Keys: getTestAttributes(),
+ },
+ NameIDFormat: string(saml.EmailAddressNameIDFormat),
+ }
+ createRes := createProvider(t, createReq)
+ equalProviderParams(t, createReq, createRes)
+
+ providerSeg := "providers/" + createRes.ID.String()
+ getRes := getProvider(t, providerSeg)
+ equalProvider(t, createRes, getRes)
+
+ updateReq := &api.CreateSSOProviderParams{
+ Domains: []string{
+ label + ".local",
+ label + "-new.local",
+ },
+ }
+ updateRes := updateProvider(t, providerSeg, updateReq)
+ getRes = getProvider(t, providerSeg)
+ equalProvider(t, updateRes, getRes)
+
+ {
+ currentProviderMap := map[string]*models.SSOProvider{
+ getRes.ID.String(): getRes,
+ }
+ prMap := listProviders(t)
+ equalProviderMaps(t, currentProviderMap, prMap)
+ }
+
+ {
+ deleteRes := deleteProvider(t, providerSeg)
+ equalProvider(t, getRes, deleteRes)
+
+ url := "/admin/sso/" + providerSeg
+ httpReq, err := http.NewRequestWithContext(
+ ctx, "GET", url, nil)
+ require.NoError(t, err)
+
+ httpRes, err := inst.DoAdmin(httpReq)
+ require.NoError(t, err)
+ require.Equal(t, 404, httpRes.StatusCode)
+ }
+ })
+
+ t.Run("ByResourceID", func(t *testing.T) {
+ const label = "by-resource-id"
+
+ currentProviderMap := make(map[string]*models.SSOProvider)
+ suffixes := []string{"live", "testing"}
+ for _, suffix := range suffixes {
+ t.Run("WithSuffix/"+suffix, func(t *testing.T) {
+ resourceID := label + ":" + suffix
+ createReq := &api.CreateSSOProviderParams{
+ Type: "saml",
+ ResourceID: &resourceID,
+ MetadataURL: "",
+ MetadataXML: getTestMetadata(label + "-" + suffix),
+ Domains: []string{
+ label + "-" + suffix + ".local",
+ },
+ AttributeMapping: models.SAMLAttributeMapping{
+ Keys: getTestAttributes(),
+ },
+ NameIDFormat: string(saml.EmailAddressNameIDFormat),
+ }
+
+ createRes := createProvider(t, createReq)
+ equalProviderParams(t, createReq, createRes)
+ require.Nil(t, createRes.Disabled)
+ require.True(t, createRes.IsEnabled())
+
+ resourceSeg := "providers/resource_" + resourceID
+ getRes := getProvider(t, resourceSeg)
+ equalProvider(t, createRes, getRes)
+ require.Nil(t, getRes.Disabled)
+ require.True(t, getRes.IsEnabled())
+
+ t.Run("AddDomain", func(t *testing.T) {
+ updateReq := &api.CreateSSOProviderParams{
+ Domains: []string{
+ label + "-" + suffix + ".local",
+ label + "-" + suffix + "-new.local",
+ },
+ }
+ updateRes := updateProvider(t, resourceSeg, updateReq)
+ require.Nil(t, updateRes.Disabled)
+ require.True(t, updateRes.IsEnabled())
+
+ getRes = getProvider(t, resourceSeg)
+ equalProvider(t, updateRes, getRes)
+ require.Nil(t, getRes.Disabled)
+ require.True(t, getRes.IsEnabled())
+
+ currentProviderMap[getRes.ID.String()] = getRes
+ })
+
+ disabled := true
+ t.Run("DisabledFlag/true", func(t *testing.T) {
+ updateReq := &api.CreateSSOProviderParams{
+ Disabled: &disabled,
+ }
+ updateRes := updateProvider(t, resourceSeg, updateReq)
+ require.NotNil(t, updateRes.Disabled)
+ require.True(t, *updateRes.Disabled)
+ require.False(t, updateRes.IsEnabled())
+
+ getRes = getProvider(t, resourceSeg)
+ equalProvider(t, updateRes, getRes)
+ require.NotNil(t, getRes.Disabled)
+ require.True(t, *getRes.Disabled)
+ require.False(t, getRes.IsEnabled())
+
+ currentProviderMap[getRes.ID.String()] = getRes
+ })
+
+ disabled = false
+ t.Run("DisabledFlag/false", func(t *testing.T) {
+ updateReq := &api.CreateSSOProviderParams{
+ Disabled: &disabled,
+ }
+ updateRes := updateProvider(t, resourceSeg, updateReq)
+ require.NotNil(t, updateRes.Disabled)
+ require.False(t, *updateRes.Disabled)
+ require.True(t, updateRes.IsEnabled())
+
+ getRes = getProvider(t, resourceSeg)
+ equalProvider(t, updateRes, getRes)
+ require.NotNil(t, getRes.Disabled)
+ require.False(t, *getRes.Disabled)
+ require.True(t, getRes.IsEnabled())
+
+ currentProviderMap[getRes.ID.String()] = getRes
+ })
+ })
+ }
+
+ t.Run("ListByFilter", func(t *testing.T) {
+
+ t.Run("NoFilter", func(t *testing.T) {
+ prMap := listProviders(t)
+ equalProviderMaps(t, currentProviderMap, prMap)
+ })
+
+ t.Run("WithResourceID", func(t *testing.T) {
+ for _, pr := range currentProviderMap {
+ q := make(url.Values)
+ q.Add("resource_id", *pr.ResourceID)
+
+ prMap := listProvidersWithFilter(t, q)
+ require.Len(t, prMap, 1)
+
+ got, ok := prMap[pr.ID.String()]
+ require.True(t, ok)
+ require.NotNil(t, got)
+
+ equalProvider(t, pr, got)
+ }
+ })
+
+ t.Run("WithResourceIDPrefix", func(t *testing.T) {
+ q := make(url.Values)
+ q.Add("resource_id_prefix", label+":")
+
+ prMap := listProvidersWithFilter(t, q)
+ require.Len(t, prMap, 2)
+
+ for _, pr := range currentProviderMap {
+ got, ok := prMap[pr.ID.String()]
+ require.True(t, ok)
+ require.NotNil(t, got)
+
+ equalProvider(t, pr, got)
+ }
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ for _, pr := range currentProviderMap {
+ resourceSeg := "providers/resource_" + *pr.ResourceID
+ deleteRes := deleteProvider(t, resourceSeg)
+ equalProvider(t, pr, deleteRes)
+
+ url := "/admin/sso/" + resourceSeg
+ httpReq, err := http.NewRequestWithContext(
+ ctx, "GET", url, nil)
+ require.NoError(t, err)
+
+ httpRes, err := inst.DoAdmin(httpReq)
+ require.NoError(t, err)
+ require.Equal(t, 404, httpRes.StatusCode)
+ }
+ })
+ })
+ })
+ })
+}
diff --git a/internal/models/sso.go b/internal/models/sso.go
index 28c2429acb..3a5be7d973 100644
--- a/internal/models/sso.go
+++ b/internal/models/sso.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"database/sql/driver"
"encoding/json"
+ "net/url"
"reflect"
"strings"
"time"
@@ -16,8 +17,9 @@ import (
)
type SSOProvider struct {
- ID uuid.UUID `db:"id" json:"id"`
-
+ ID uuid.UUID `db:"id" json:"id"`
+ ResourceID *string `db:"resource_id" json:"resource_id,omitempty"`
+ Disabled *bool `db:"disabled" json:"disabled"`
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"`
@@ -25,6 +27,10 @@ type SSOProvider struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
+func (p SSOProvider) IsEnabled() bool {
+ return p.Disabled == nil || !*p.Disabled
+}
+
func (p SSOProvider) TableName() string {
return "sso_providers"
}
@@ -199,6 +205,19 @@ func FindSSOProviderByID(tx *storage.Connection, id uuid.UUID) (*SSOProvider, er
return nil, errors.Wrap(err, "error finding SAML SSO provider by ID")
}
+ return &ssoProvider, nil
+}
+
+func FindSSOProviderByResourceID(tx *storage.Connection, id string) (*SSOProvider, error) {
+ var ssoProvider SSOProvider
+
+ if err := tx.Eager().Q().Where("resource_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 Resource ID")
+ }
return &ssoProvider, nil
}
@@ -233,7 +252,7 @@ func FindSSOProviderByDomain(tx *storage.Connection, domain string) (*SSOProvide
return &ssoProvider, nil
}
-func FindAllSAMLProviders(tx *storage.Connection) ([]SSOProvider, error) {
+func FindAllSSOProviders(tx *storage.Connection) ([]SSOProvider, error) {
var providers []SSOProvider
if err := tx.Eager().All(&providers); err != nil {
@@ -247,6 +266,33 @@ func FindAllSAMLProviders(tx *storage.Connection) ([]SSOProvider, error) {
return providers, nil
}
+const (
+ resourceIDFilter = "resource_id"
+ resourceIDPrefixFilter = "resource_id_prefix"
+)
+
+// FindAllSSOProvidersByFilter finds SSO Providers with the matching filter.
+func FindAllSSOProvidersByFilter(
+ tx *storage.Connection,
+ queryValues url.Values,
+) ([]*SSOProvider, error) {
+ ssoProviders := []*SSOProvider{}
+
+ q := tx.Eager().Q()
+ if v := queryValues.Get(resourceIDFilter); v != "" {
+ q = q.Where("resource_id = ?", v)
+ } else if v := queryValues.Get(resourceIDPrefixFilter); v != "" {
+ q = q.Where("resource_id LIKE ?", v+"%")
+ }
+ if err := q.All(&ssoProviders); err != nil {
+ if errors.Cause(err) == sql.ErrNoRows {
+ return nil, nil
+ }
+ return nil, errors.Wrap(err, "error loading all SAML SSO providers")
+ }
+ return ssoProviders, nil
+}
+
func FindSAMLRelayStateByID(tx *storage.Connection, id uuid.UUID) (*SAMLRelayState, error) {
var state SAMLRelayState
diff --git a/internal/models/sso_test.go b/internal/models/sso_test.go
index b6c9656309..dd6f8c8ee5 100644
--- a/internal/models/sso_test.go
+++ b/internal/models/sso_test.go
@@ -1,8 +1,11 @@
package models
import (
- tst "testing"
+ "net/url"
+ "slices"
+ "testing"
+ "github.com/gofrs/uuid"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/supabase/auth/internal/conf"
@@ -20,7 +23,7 @@ func (ts *SSOTestSuite) SetupTest() {
TruncateAll(ts.db)
}
-func TestSSO(t *tst.T) {
+func TestSSO(t *testing.T) {
globalConfig, err := conf.LoadGlobal(modelsTestConfig)
require.NoError(t, err)
@@ -230,3 +233,240 @@ func (ts *SSOTestSuite) TestFindSAMLProviderByEntityID() {
}
}
}
+
+func (ts *SSOTestSuite) TestFindSSOProviderByResourceID() {
+ const (
+ g1Prefix = "group_one:"
+ g1Test = g1Prefix + "test"
+ g1Live = g1Prefix + "live"
+
+ g2Prefix = "group_two:"
+ g2Test = g2Prefix + "test"
+ g2Live = g2Prefix + "live"
+ )
+
+ genStr := func() string {
+ return uuid.Must(uuid.NewV4()).String()
+ }
+
+ genProvider := func(resourceID string) *SSOProvider {
+ str := genStr()
+ pr := &SSOProvider{
+ SAMLProvider: SAMLProvider{
+ EntityID: "https://example.com/saml/metadata/" + str,
+ MetadataXML: "",
+ },
+ SSODomains: []SSODomain{
+ {
+ Domain: str + ".local",
+ },
+ },
+ }
+ if resourceID != "" {
+ pr.ResourceID = &resourceID
+ }
+ return pr
+ }
+
+ var (
+ ssoProviderG1Test = genProvider(g1Test)
+ ssoProviderG1Live = genProvider(g1Live)
+ ssoProviderG2Test = genProvider(g2Test)
+ ssoProviderG2Live = genProvider(g2Live)
+ ssoProviderNoResourceID1 = genProvider("")
+ ssoProviderNoResourceID2 = genProvider("")
+ )
+
+ noProviders := []*SSOProvider{
+ ssoProviderNoResourceID1,
+ ssoProviderNoResourceID2,
+ }
+ g1Providers := []*SSOProvider{
+ ssoProviderG1Test,
+ ssoProviderG1Live,
+ }
+ g2Providers := []*SSOProvider{
+ ssoProviderG2Test,
+ ssoProviderG2Live,
+ }
+ gProviders := slices.Concat(g1Providers, g2Providers)
+ allProviders := slices.Concat(g1Providers, g2Providers, noProviders)
+
+ defer func() {
+ for _, pr := range allProviders {
+ ts.db.Eager().Destroy(pr)
+ }
+ }()
+
+ for i := range allProviders {
+ pr := allProviders[i]
+ require.NoError(ts.T(), ts.db.Eager().Create(pr))
+ ts.db.Eager().Q().Where("id = ?", pr.ID).First(pr)
+ }
+
+ type testsSpec struct {
+ query url.Values
+ exp []*SSOProvider
+ }
+
+ tests := []testsSpec{
+
+ // no filters
+ {
+ query: nil,
+ exp: allProviders,
+ },
+
+ // resource_id
+ {
+ query: url.Values{"resource_id": []string{g1Prefix}},
+ exp: nil,
+ },
+ {
+ query: url.Values{"resource_id": []string{g1Test}},
+ exp: []*SSOProvider{ssoProviderG1Test},
+ },
+ {
+ query: url.Values{"resource_id": []string{g1Live}},
+ exp: []*SSOProvider{ssoProviderG1Live},
+ },
+ {
+ query: url.Values{"resource_id": []string{g2Test}},
+ exp: []*SSOProvider{ssoProviderG2Test},
+ },
+ {
+ query: url.Values{"resource_id": []string{g2Live}},
+ exp: []*SSOProvider{ssoProviderG2Live},
+ },
+
+ // resource_id - negative
+ {
+ query: url.Values{"resource_id": []string{g1Test[:len(g1Test)-1]}},
+ exp: nil,
+ },
+
+ // resource_id_prefix
+ {
+ query: url.Values{"resource_id_prefix": []string{g1Prefix}},
+ exp: g1Providers,
+ },
+ {
+ query: url.Values{"resource_id_prefix": []string{g2Prefix}},
+ exp: g2Providers,
+ },
+
+ // resource_id_prefix - partial
+ {
+ query: url.Values{"resource_id_prefix": []string{"group"}},
+ exp: gProviders,
+ },
+ {
+ query: url.Values{"resource_id_prefix": []string{"group_one:"}},
+ exp: g1Providers,
+ },
+ {
+ query: url.Values{"resource_id_prefix": []string{"group_two:"}},
+ exp: g2Providers,
+ },
+ {
+ query: url.Values{"resource_id_prefix": []string{g1Test[:len(g1Test)-2]}},
+ exp: []*SSOProvider{ssoProviderG1Test},
+ },
+ {
+ query: url.Values{"resource_id_prefix": []string{g1Live[:len(g1Live)-2]}},
+ exp: []*SSOProvider{ssoProviderG1Live},
+ },
+
+ // resource_id_prefix - exact matches
+ {
+ query: url.Values{"resource_id_prefix": []string{g1Test}},
+ exp: []*SSOProvider{ssoProviderG1Test},
+ },
+ {
+ query: url.Values{"resource_id_prefix": []string{g1Live}},
+ exp: []*SSOProvider{ssoProviderG1Live},
+ },
+ {
+ query: url.Values{"resource_id_prefix": []string{g2Test}},
+ exp: []*SSOProvider{ssoProviderG2Test},
+ },
+ {
+ query: url.Values{"resource_id_prefix": []string{g2Live}},
+ exp: []*SSOProvider{ssoProviderG2Live},
+ },
+
+ {
+ query: url.Values{"resource_id_prefix": []string{"invalid:"}},
+ exp: nil,
+ },
+ {
+ query: url.Values{"resource_id_prefix": []string{"invalid:"}},
+ exp: nil,
+ },
+ }
+
+ check := func(t *testing.T, exp, got []*SSOProvider) {
+ t.Helper()
+
+ require.Len(t, got, len(exp))
+
+ isEqual := func(a, b *SSOProvider) bool {
+ return a.ID == b.ID && a.ResourceID == b.ResourceID
+ }
+ equal := slices.EqualFunc(exp, got, isEqual)
+ if !equal {
+ require.Equal(t, exp, got)
+ }
+ }
+
+ for _, test := range tests {
+ ts.Run("FindAllSSOProvidersByFilter/query='"+test.query.Encode()+"'", func() {
+ prs, err := FindAllSSOProvidersByFilter(ts.db, test.query)
+ require.NoError(ts.T(), err)
+ require.NotNil(ts.T(), prs)
+ check(ts.T(), test.exp, prs)
+ })
+ }
+
+ for _, exp := range allProviders {
+
+ {
+ got, err := FindSSOProviderByID(ts.db, exp.ID)
+ require.NoError(ts.T(), err)
+ require.NotNil(ts.T(), got)
+ check(ts.T(), []*SSOProvider{exp}, []*SSOProvider{got})
+ }
+
+ if exp.ResourceID != nil {
+ got, err := FindSSOProviderByResourceID(ts.db, *exp.ResourceID)
+ require.NoError(ts.T(), err)
+ require.NotNil(ts.T(), got)
+ check(ts.T(), []*SSOProvider{exp}, []*SSOProvider{got})
+ }
+
+ for _, domain := range exp.SSODomains {
+ got, err := FindSSOProviderByDomain(ts.db, domain.Domain)
+ require.NoError(ts.T(), err)
+ require.NotNil(ts.T(), got)
+ check(ts.T(), []*SSOProvider{exp}, []*SSOProvider{got})
+ }
+ }
+
+ {
+ got, err := FindSSOProviderByResourceID(ts.db, "")
+ require.Error(ts.T(), err)
+ require.Nil(ts.T(), got)
+ }
+
+ {
+ got, err := FindSSOProviderByID(ts.db, uuid.Nil)
+ require.Error(ts.T(), err)
+ require.Nil(ts.T(), got)
+ }
+
+ {
+ got, err := FindSSOProviderByDomain(ts.db, "_test_invalid_")
+ require.Error(ts.T(), err)
+ require.Nil(ts.T(), got)
+ }
+}
diff --git a/migrations/20250717082212_add_disabled_to_sso_providers.up.sql b/migrations/20250717082212_add_disabled_to_sso_providers.up.sql
new file mode 100644
index 0000000000..1328539474
--- /dev/null
+++ b/migrations/20250717082212_add_disabled_to_sso_providers.up.sql
@@ -0,0 +1,9 @@
+do $$ begin
+
+ alter table only {{ index .Options "Namespace" }}.sso_providers
+ add column if not exists disabled boolean null;
+
+ create index if not exists sso_providers_resource_id_pattern_idx
+ on {{ index .Options "Namespace" }}.sso_providers
+ (resource_id text_pattern_ops);
+end $$;