diff --git a/internal/api/api.go b/internal/api/api.go index e598501cb..37cc80d2b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -13,6 +13,7 @@ import ( "github.com/supabase/auth/internal/api/apitask" "github.com/supabase/auth/internal/api/oauthserver" "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/api/scim" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/hooks/hookshttp" "github.com/supabase/auth/internal/hooks/hookspgfunc" @@ -46,6 +47,7 @@ type API struct { hooksMgr *v0hooks.Manager hibpClient *hibp.PwnedClient oauthServer *oauthserver.Server + scim *scim.Server tokenService *tokens.Service mailer mailer.Mailer oidcCache *provider.OIDCProviderCache @@ -211,6 +213,16 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Post("/", api.ExternalProviderCallback) }) + if globalConfig.Experimental.ScimEnabled { + api.scim = scim.NewServer() + + r.Route("/scim/v2", func(r *router) { + r.Get("/ServiceProviderConfig", api.scim.ServiceProviderConfig) + r.Get("/ResourceTypes", api.scim.ResourceTypes) + r.Get("/Schemas", api.scim.Schemas) + }) + } + r.Route("/", func(r *router) { r.Use(api.isValidExternalHost) diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go new file mode 100644 index 000000000..c12b5bce4 --- /dev/null +++ b/internal/api/scim/server.go @@ -0,0 +1,32 @@ +package scim + +import ( + "net/http" +) + +const mediaType = "application/scim+json" + +type Server struct { +} + +func NewServer() *Server { + return &Server{} +} + +func (srv *Server) ServiceProviderConfig(w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", mediaType) + w.WriteHeader(http.StatusNotImplemented) + return nil +} + +func (srv *Server) ResourceTypes(w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", mediaType) + w.WriteHeader(http.StatusNotImplemented) + return nil +} + +func (srv *Server) Schemas(w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Content-Type", mediaType) + w.WriteHeader(http.StatusNotImplemented) + return nil +} diff --git a/internal/api/scim/server_test.go b/internal/api/scim/server_test.go new file mode 100644 index 000000000..c11b0a493 --- /dev/null +++ b/internal/api/scim/server_test.go @@ -0,0 +1,34 @@ +package scim + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestServer(t *testing.T) { + t.Run("NewServer", func(t *testing.T) { + require.NotNil(t, NewServer()) + }) + + srv := NewServer() + for _, tc := range []struct { + path string + handler func(http.ResponseWriter, *http.Request) error + }{ + {"ServiceProviderConfig", srv.ServiceProviderConfig}, + {"ResourceTypes", srv.ResourceTypes}, + {"Schemas", srv.Schemas}, + } { + t.Run(tc.path, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/"+tc.path, nil) + w := httptest.NewRecorder() + + require.NoError(t, tc.handler(w, r)) + require.Equal(t, w.Code, http.StatusNotImplemented) + require.Equal(t, "application/scim+json", w.Header().Get("Content-Type")) + }) + } +} diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go new file mode 100644 index 000000000..1d50c53ab --- /dev/null +++ b/internal/api/scim_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/storage" +) + +var scimPaths = []string{ + "/scim/v2/ServiceProviderConfig", + "/scim/v2/ResourceTypes", + "/scim/v2/Schemas", +} + +func TestSCIM(t *testing.T) { + t.Run("Disabled by default", func(t *testing.T) { + api, _, err := setupAPIForTest() + require.NoError(t, err) + + require.False(t, api.config.Experimental.ScimEnabled) + require.Nil(t, api.scim) + + for _, path := range scimPaths { + r := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, r) + + require.Equal(t, w.Code, http.StatusNotFound) + } + }) + + t.Run("Can be enabled", func(t *testing.T) { + api, _, err := setupAPIForTestWithCallback(func(config *conf.GlobalConfiguration, conn *storage.Connection) { + if config != nil { + config.Experimental.ScimEnabled = true + } + }) + require.NoError(t, err) + + require.True(t, api.config.Experimental.ScimEnabled) + require.NotNil(t, api.scim) + }) + + for _, path := range scimPaths { + t.Run(path, func(t *testing.T) { + api, _, err := setupAPIForTestWithCallback(func(config *conf.GlobalConfiguration, conn *storage.Connection) { + if config != nil { + config.Experimental.ScimEnabled = true + } + }) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, r) + + require.Equal(t, w.Code, http.StatusNotImplemented) + }) + } +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 87077bc4d..96a147db5 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -359,6 +359,11 @@ type ExperimentalConfiguration struct { // provided, GET /admin/users serves fast, count-free cursor pages. // Env: GOTRUE_EXPERIMENTAL_CURSOR_PAGINATION_ENABLED=true CursorPaginationEnabled bool `split_words:"true" default:"false"` + + // ScimEnabled gates the /scim/v2 router. Ships dark: no per-provider + // enablement yet, just a kill switch for internal verification. + // Env: GOTRUE_EXPERIMENTAL_SCIM_ENABLED=true + ScimEnabled bool `split_words:"true" default:"false"` } // ReloadingConfiguration holds the configuration values for runtime diff --git a/internal/conf/confload/confload_test.go b/internal/conf/confload/confload_test.go index 05dd53869..31b27f5cb 100644 --- a/internal/conf/confload/confload_test.go +++ b/internal/conf/confload/confload_test.go @@ -266,6 +266,34 @@ func TestExperimentalCursorPaginationEnabled(t *testing.T) { } } +func TestExperimentalScimEndpoints(t *testing.T) { + baseEnv := func() { + os.Clearenv() + os.Setenv("GOTRUE_SITE_URL", "http://localhost:8080") + os.Setenv("GOTRUE_DB_DRIVER", "postgres") + os.Setenv("GOTRUE_DB_DATABASE_URL", "fake") + os.Setenv("GOTRUE_JWT_SECRET", "secret") + os.Setenv("API_EXTERNAL_URL", "http://localhost:9999") + } + + { + baseEnv() + cfg, err := LoadGlobalFromEnv() + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, false, cfg.Experimental.ScimEnabled) + } + + { + baseEnv() + os.Setenv("GOTRUE_EXPERIMENTAL_SCIM_ENABLED", "true") + cfg, err := LoadGlobalFromEnv() + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, true, cfg.Experimental.ScimEnabled) + } +} + func TestLoading(t *testing.T) { os.Clearenv()