diff --git a/example.env b/example.env index 190d45b1b..bbbb68c4e 100644 --- a/example.env +++ b/example.env @@ -262,3 +262,11 @@ GOTRUE_SMS_TEST_OTP_VALID_UNTIL="" # (e.g. 2023-09-29T08:14:06Z) GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED="false" GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED="false" + +# SCIM config +# Note: SCIM providers are managed via the admin API at /admin/scim-providers +# Create providers with: POST /admin/scim-providers +GOTRUE_SCIM_ENABLED="false" +GOTRUE_SCIM_BASE_URL="http://localhost:9999" +GOTRUE_SCIM_DEFAULT_AUDIENCE="authenticated" +GOTRUE_SCIM_BAN_ON_DEACTIVATE="true" diff --git a/go.mod b/go.mod index e3db84713..eaee2b4f4 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,10 @@ require ( github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/di-wu/parser v0.2.2 // indirect + github.com/di-wu/xsd-datetime v1.0.0 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.0 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -64,6 +67,7 @@ require ( github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/scim2/filter-parser/v2 v2.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect github.com/supranational/blst v0.3.14 // indirect diff --git a/go.sum b/go.sum index 689d516f1..3859fed0f 100644 --- a/go.sum +++ b/go.sum @@ -80,11 +80,17 @@ github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5il github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/di-wu/parser v0.2.2 h1:I9oHJ8spBXOeL7Wps0ffkFFFiXJf/pk7NX9lcAMqRMU= +github.com/di-wu/parser v0.2.2/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo= +github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI= +github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww= github.com/didip/tollbooth/v5 v5.1.1 h1:QpKFg56jsbNuQ6FFj++Z1gn2fbBsvAc1ZPLUaDOYW5k= github.com/didip/tollbooth/v5 v5.1.1/go.mod h1:d9rzwOULswrD3YIrAQmP3bfjxab32Df4IaO6+D25l9g= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry/P5s8R4dYuLkhB9Nhso8ogFWNr4IQ= +github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo= github.com/ethereum/c-kzg-4844/v2 v2.1.0 h1:gQropX9YFBhl3g4HYhwE70zq3IHFRgbbNPw0Shwzf5w= github.com/ethereum/c-kzg-4844/v2 v2.1.0/go.mod h1:TC48kOKjJKPbN7C++qIgt0TJzZ70QznYR7Ob+WXl57E= github.com/ethereum/go-ethereum v1.16.0 h1:Acf8FlRmcSWEJm3lGjlnKTdNgFvF9/l28oQ8Q6HDj1o= @@ -428,6 +434,8 @@ github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3ci github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM= +github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA= github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 h1:eajwn6K3weW5cd1ZXLu2sJ4pvwlBiCWY4uDejOr73gM= github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= diff --git a/hack/test.env b/hack/test.env index 97a01ba03..fa8d2f007 100644 --- a/hack/test.env +++ b/hack/test.env @@ -132,3 +132,10 @@ GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPT=true GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY_ID=abc GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY=pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4 GOTRUE_SECURITY_DB_ENCRYPTION_DECRYPTION_KEYS=abc:pwFoiPyybQMqNmYVN0gUnpbfpGQV2sDv9vp0ZAxi_Y4 + +# SCIM configuration for tests +# Note: SCIM providers are created via admin API in test setup +GOTRUE_SCIM_ENABLED=true +GOTRUE_SCIM_BASE_URL="http://localhost:9999" +GOTRUE_SCIM_DEFAULT_AUDIENCE="authenticated" +GOTRUE_SCIM_BAN_ON_DEACTIVATE=true diff --git a/internal/api/api.go b/internal/api/api.go index ed77282d7..af5552879 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -225,6 +225,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne return api.Signup(w, r) }) }) + r.With(api.limitHandler(api.limiterOpts.Recover)). With(api.verifyCaptcha).With(api.requireEmailProvider).Post("/recover", api.Recover) @@ -335,6 +336,18 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne }) }) + // SCIM provider management endpoints + r.Route("/scim-providers", func(r *router) { + r.Get("/", api.AdminSCIMProviderList) + r.Post("/", api.AdminSCIMProviderCreate) + + r.Route("/{provider_id}", func(r *router) { + r.Get("/", api.AdminSCIMProviderGet) + r.Post("/rotate-token", api.AdminSCIMProviderRotateToken) + r.Delete("/", api.AdminSCIMProviderDelete) + }) + }) + // Admin only oauth client management endpoints if globalConfig.OAuthServer.Enabled { r.Route("/oauth", func(r *router) { @@ -355,6 +368,25 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne } }) + // SCIM v2 endpoints (minimal Users only) + r.Route("/scim/v2", func(r *router) { + r.Use(api.requireSCIMEnabled) + r.Use(api.requireSCIMAuth) + r.Get("/ServiceProviderConfig", api.SCIMServiceProviderConfig) + r.Get("/ResourceTypes", api.SCIMResourceTypes) + r.Get("/Schemas", api.SCIMSchemas) + r.Route("/Users", func(r *router) { + r.Get("/", api.SCIMUsersList) + r.Post("/", api.SCIMUsersCreate) + r.Route("/{scim_user_id}", func(r *router) { + r.Get("/", api.SCIMUsersGet) + r.Put("/", api.SCIMUsersReplace) + r.Patch("/", api.SCIMUsersPatch) + r.Delete("/", api.SCIMUsersDelete) + }) + }) + }) + // OAuth Dynamic Client Registration endpoint (public, rate limited) if globalConfig.OAuthServer.Enabled { r.Route("/oauth", func(r *router) { diff --git a/internal/api/errors.go b/internal/api/errors.go index 7479f9f03..22ee00362 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -78,6 +78,12 @@ func HandleResponseError(err error, w http.ResponseWriter, r *http.Request) { log := observability.GetLogEntry(r).Entry errorID := utilities.GetRequestID(r.Context()) + // Handle SCIM errors first (before API versioning) + if IsSCIMError(err) { + WriteSCIMError(w, err) + return + } + apiVersion, averr := DetermineClosestAPIVersion(r.Header.Get(APIVersionHeaderName)) if averr != nil { log.WithError(averr).Warn("Invalid version passed to " + APIVersionHeaderName + " header, defaulting to initial version") diff --git a/internal/api/middleware.go b/internal/api/middleware.go index e41ae80c3..e62845be6 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -296,6 +296,51 @@ func (a *API) requireSAMLEnabled(w http.ResponseWriter, req *http.Request) (cont return ctx, nil } +// requireSCIMEnabled ensures SCIM is enabled +func (a *API) requireSCIMEnabled(w http.ResponseWriter, req *http.Request) (context.Context, error) { + ctx := req.Context() + if !a.config.SCIM.Enabled { + return nil, apierrors.NewNotFoundError(apierrors.ErrorCodeValidationFailed, "SCIM is disabled") + } + return ctx, nil +} + +const scimProviderContextKey = contextKey("scim_provider_id") + +func withSCIMProvider(ctx context.Context, providerID string) context.Context { + return context.WithValue(ctx, scimProviderContextKey, providerID) +} + +func getSCIMProvider(ctx context.Context) string { + if val := ctx.Value(scimProviderContextKey); val != nil { + if providerID, ok := val.(string); ok { + return providerID + } + } + return "" +} + +// requireSCIMAuth authenticates SCIM requests via Bearer token from scim_providers table +func (a *API) requireSCIMAuth(w http.ResponseWriter, req *http.Request) (context.Context, error) { + ctx := req.Context() + db := a.db.WithContext(ctx) + + // Extract Bearer token + authz := req.Header.Get("Authorization") + if m := bearerRegexp.FindStringSubmatch(authz); len(m) == 2 { + token := m[1] + + // Look up provider by token in database + provider, err := models.FindSCIMProviderByToken(db, token) + if err == nil && provider != nil { + // Use provider UUID as the stable provider ID + return withSCIMProvider(ctx, provider.ID.String()), nil + } + } + + return nil, apierrors.NewForbiddenError(apierrors.ErrorCodeInvalidCredentials, "Invalid SCIM credentials") +} + func (a *API) requireManualLinkingEnabled(w http.ResponseWriter, req *http.Request) (context.Context, error) { ctx := req.Context() if !a.config.Security.ManualLinkingEnabled { diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 68dbabb7c..183f27007 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -19,6 +19,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/api/apierrors" "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" ) @@ -509,3 +510,45 @@ func (ts *MiddlewareTestSuite) TestDatabaseCleanup() { } mockCleanup.AssertNumberOfCalls(ts.T(), "Clean", 1) } + +func TestRequireSCIMEnabled(t *testing.T) { + api := &API{config: &conf.GlobalConfiguration{}} + // disabled + req := httptest.NewRequest(http.MethodGet, "/scim/v2/Users", nil) + w := httptest.NewRecorder() + _, err := api.requireSCIMEnabled(w, req) + require.Error(t, err) + + // enabled + api.config.SCIM.Enabled = true + _, err = api.requireSCIMEnabled(w, req) + require.NoError(t, err) +} + +func TestRequireSCIMAuth_BearerAndBasic(t *testing.T) { + api, _, err := setupAPIForTest() + require.NoError(t, err) + defer api.db.Close() + require.NoError(t, models.TruncateAll(api.db)) + + api.config.SCIM.Enabled = true + + // Create a test SCIM provider with token "tok" + provider, err := models.NewSCIMProvider("test-provider", "tok", "authenticated") + require.NoError(t, err) + require.NoError(t, api.db.Create(provider)) + + // Bearer token success + req := httptest.NewRequest(http.MethodGet, "/scim/v2/Users", nil) + req.Header.Set("Authorization", "Bearer tok") + w := httptest.NewRecorder() + _, err = api.requireSCIMAuth(w, req) + require.NoError(t, err) + + // Bearer token failure + req = httptest.NewRequest(http.MethodGet, "/scim/v2/Users", nil) + req.Header.Set("Authorization", "Bearer wrong") + w = httptest.NewRecorder() + _, err = api.requireSCIMAuth(w, req) + require.Error(t, err) +} diff --git a/internal/api/router.go b/internal/api/router.go index 1feb66d3f..c8f2506df 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -30,6 +30,9 @@ func (r *router) Post(pattern string, fn apiHandler) { func (r *router) Put(pattern string, fn apiHandler) { r.chi.Put(pattern, handler(fn)) } +func (r *router) Patch(pattern string, fn apiHandler) { + r.chi.Method(http.MethodPatch, pattern, handler(fn)) +} func (r *router) Delete(pattern string, fn apiHandler) { r.chi.Delete(pattern, handler(fn)) } diff --git a/internal/api/scim.go b/internal/api/scim.go new file mode 100644 index 000000000..4a0cb71bc --- /dev/null +++ b/internal/api/scim.go @@ -0,0 +1,572 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" +) + +// SCIM response structures + +type SCIMServiceProviderConfig struct { + Schemas []string `json:"schemas"` + Patch SCIMSupported `json:"patch"` + Bulk SCIMSupported `json:"bulk"` + Filter SCIMFilter `json:"filter"` + ChangePassword SCIMSupported `json:"changePassword"` + Sort SCIMSupported `json:"sort"` + Etag SCIMSupported `json:"etag"` + AuthenticationSchemes []interface{} `json:"authenticationSchemes"` +} + +type SCIMSupported struct { + Supported bool `json:"supported"` +} + +type SCIMFilter struct { + Supported bool `json:"supported"` + MaxResults int `json:"maxResults"` +} + +type SCIMResourceType struct { + ID string `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Schema string `json:"schema"` +} + +type SCIMResourceTypesResponse struct { + Resources []SCIMResourceType `json:"Resources"` + TotalResults int `json:"totalResults"` + ItemsPerPage int `json:"itemsPerPage"` + StartIndex int `json:"startIndex"` + Schemas []string `json:"schemas"` +} + +type SCIMSchemaAttribute struct { + Name string `json:"name"` + Type string `json:"type"` + Required *bool `json:"required,omitempty"` + Uniqueness *string `json:"uniqueness,omitempty"` + SubAttributes []SCIMSchemaAttribute `json:"subAttributes,omitempty"` +} + +type SCIMSchema struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Attributes []SCIMSchemaAttribute `json:"attributes"` +} + +type SCIMSchemasResponse struct { + Resources []SCIMSchema `json:"Resources"` + TotalResults int `json:"totalResults"` + ItemsPerPage int `json:"itemsPerPage"` + StartIndex int `json:"startIndex"` + Schemas []string `json:"schemas"` +} + +// ServiceProviderConfig +func (a *API) SCIMServiceProviderConfig(w http.ResponseWriter, r *http.Request) error { + resp := SCIMServiceProviderConfig{ + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"}, + Patch: SCIMSupported{Supported: true}, + Bulk: SCIMSupported{Supported: false}, + Filter: SCIMFilter{Supported: true, MaxResults: 200}, + ChangePassword: SCIMSupported{Supported: false}, + Sort: SCIMSupported{Supported: false}, + Etag: SCIMSupported{Supported: false}, + AuthenticationSchemes: []interface{}{}, + } + return scimSendJSON(w, http.StatusOK, resp) +} + +// ResourceTypes +func (a *API) SCIMResourceTypes(w http.ResponseWriter, r *http.Request) error { + resp := SCIMResourceTypesResponse{ + Resources: []SCIMResourceType{ + { + ID: "User", + Name: "User", + Endpoint: "/scim/v2/Users", + Schema: "urn:ietf:params:scim:schemas:core:2.0:User", + }, + }, + TotalResults: 1, + ItemsPerPage: 1, + StartIndex: 1, + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + } + return scimSendJSON(w, http.StatusOK, resp) +} + +// Schemas (return only core User schema minimal) +func (a *API) SCIMSchemas(w http.ResponseWriter, r *http.Request) error { + required := true + uniqueness := "server" + + resp := SCIMSchemasResponse{ + Resources: []SCIMSchema{ + { + ID: "urn:ietf:params:scim:schemas:core:2.0:User", + Name: "User", + Description: "User Account", + Attributes: []SCIMSchemaAttribute{ + {Name: "userName", Type: "string", Required: &required, Uniqueness: &uniqueness}, + {Name: "externalId", Type: "string"}, + {Name: "active", Type: "boolean"}, + {Name: "displayName", Type: "string"}, + {Name: "name", Type: "complex", SubAttributes: []SCIMSchemaAttribute{ + {Name: "givenName", Type: "string"}, + {Name: "familyName", Type: "string"}, + }}, + {Name: "emails", Type: "complex"}, + }, + }, + }, + TotalResults: 1, + ItemsPerPage: 1, + StartIndex: 1, + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + } + return scimSendJSON(w, http.StatusOK, resp) +} + +// Users list +func (a *API) SCIMUsersList(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + aud := a.scimAudience() + + // SCIM pagination uses 1-based startIndex + startIndex, _ := strconv.Atoi(r.URL.Query().Get("startIndex")) + if startIndex <= 0 { + startIndex = 1 + } + count, _ := strconv.Atoi(r.URL.Query().Get("count")) + if count <= 0 || count > 200 { + count = 50 + } + page := (startIndex-1)/count + 1 + + filter := r.URL.Query().Get("filter") + + var resources []any + var total uint64 + + if filter != "" { + // minimal parser: "attr eq \"value\"" + parts := strings.Split(filter, "eq") + if len(parts) == 2 { + attr := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + + // Parse JSON string to handle proper escaping + var parsedVal string + if err := json.Unmarshal([]byte(val), &parsedVal); err != nil { + // Fallback to simple trim if JSON parsing fails + parsedVal = strings.Trim(val, "\"") + } + val = parsedVal + + switch attr { + case "userName": + providerID := getSCIMProvider(ctx) + var user *models.User + q := db.Q().Where("instance_id = ? and aud = ? and email = ? and scim_provider_id = ?", uuid.Nil, aud, val, providerID) + err := q.First(&user) + if err == nil && user != nil { + resources = append(resources, a.toSCIMUser(user)) + total = 1 + } else { + total = 0 + } + case "externalId": + var users []*models.User + providerID := getSCIMProvider(ctx) + q := db.Q().Where("instance_id = ? and aud = ? and scim_external_id = ? and scim_provider_id = ?", uuid.Nil, aud, val, providerID) + if err := q.All(&users); err == nil { + for _, u := range users { + resources = append(resources, a.toSCIMUser(u)) + } + total = uint64(len(users)) + } + } + } + if resources == nil { + resources = []any{} + } + resp := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + "totalResults": total, + "itemsPerPage": len(resources), + "startIndex": 1, + "Resources": resources, + } + return scimSendJSON(w, http.StatusOK, resp) + } + + // Ensure page and count are non-negative before converting to uint64 + if page < 0 { + page = 1 + } + if count < 0 { + count = 50 + } + pageParams := &models.Pagination{Page: uint64(page), PerPage: uint64(count)} // #nosec G115 + + // Filter by provider ID for isolation + providerID := getSCIMProvider(ctx) + var users []*models.User + q := db.Q().Where("instance_id = ? and aud = ? and scim_provider_id = ?", uuid.Nil, aud, providerID) + q = q.Paginate(int(pageParams.Page), int(pageParams.PerPage)) // #nosec G115 + err := q.All(&users) + if err != nil { + return err + } + + resources = make([]any, 0, len(users)) + for _, u := range users { + resources = append(resources, a.toSCIMUser(u)) + } + + // Get total count for the provider + var totalCount int + countQ := db.Q().Where("instance_id = ? and aud = ? and scim_provider_id = ?", uuid.Nil, aud, providerID) + totalCount, _ = countQ.Count(&models.User{}) + + resp := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + "totalResults": totalCount, + "itemsPerPage": len(resources), + "startIndex": startIndex, + "Resources": resources, + } + return scimSendJSON(w, http.StatusOK, resp) +} + +// Users get +func (a *API) SCIMUsersGet(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + idStr := chi.URLParam(r, "scim_user_id") + userID, err := uuid.FromString(idStr) + if err != nil { + return SCIMNotFound("User not found") + } + u, err := models.FindUserByID(db, userID) + if err != nil { + return SCIMNotFound("User not found") + } + if u.Aud != a.scimAudience() { + return SCIMNotFound("User not found") + } + + // Check provider isolation + providerID := getSCIMProvider(ctx) + if len(u.SCIMProviderID) > 0 && u.SCIMProviderID.String() != providerID { + return SCIMNotFound("User not found") + } + return scimSendJSON(w, http.StatusOK, a.toSCIMUser(u)) +} + +// Users create +func (a *API) SCIMUsersCreate(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + return err + } + + aud := a.scimAudience() + email := getString(body, "userName") + if email == "" { + // fallback from emails[0].value + if emails, ok := body["emails"].([]any); ok && len(emails) > 0 { + if m, ok := emails[0].(map[string]any); ok { + email = getString(m, "value") + } + } + } + + user, err := models.NewUser("", email, "", aud, map[string]any{}) + if err != nil { + return err + } + + // metadata + if name, ok := body["name"].(map[string]any); ok { + if user.UserMetaData == nil { + user.UserMetaData = map[string]any{} + } + if v := getString(name, "givenName"); v != "" { + user.UserMetaData["given_name"] = v + } + if v := getString(name, "familyName"); v != "" { + user.UserMetaData["family_name"] = v + } + } + if v := getString(body, "displayName"); v != "" { + if user.UserMetaData == nil { + user.UserMetaData = map[string]any{} + } + user.UserMetaData["display_name"] = v + } + if v := getString(body, "externalId"); v != "" { + user.SCIMExternalID = storage.NullString(v) + } + + // Set provider ID for isolation + providerID := getSCIMProvider(ctx) + user.SCIMProviderID = storage.NullString(providerID) + + err = db.Transaction(func(tx *storage.Connection) error { + if terr := tx.Create(user); terr != nil { + return terr + } + if user.GetEmail() != "" { + if _, terr := a.createNewIdentity(tx, user, "email", map[string]any{"email": user.GetEmail(), "email_verified": true, "sub": user.ID.String()}); terr != nil { + return terr + } + } + return nil + }) + if err != nil { + return err + } + + w.Header().Set("Location", a.scimUserLocation(user.ID)) + return scimSendJSON(w, http.StatusCreated, a.toSCIMUser(user)) +} + +// Users replace +func (a *API) SCIMUsersReplace(w http.ResponseWriter, r *http.Request) error { + // For minimal impl, treat as PATCH replace of active/displayName/name + return a.SCIMUsersPatch(w, r) +} + +// Users patch +func (a *API) SCIMUsersPatch(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + idStr := chi.URLParam(r, "scim_user_id") + userID, err := uuid.FromString(idStr) + if err != nil { + return SCIMNotFound("User not found") + } + user, err := models.FindUserByID(db, userID) + if err != nil { + return SCIMNotFound("User not found") + } + if user.Aud != a.scimAudience() { + return SCIMNotFound("User not found") + } + + // Check provider isolation + providerID := getSCIMProvider(ctx) + if len(user.SCIMProviderID) > 0 && user.SCIMProviderID.String() != providerID { + return SCIMNotFound("User not found") + } + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + return err + } + + // Support RFC7644 patch operations minimally + if ops, ok := body["Operations"].([]any); ok { + err = db.Transaction(func(tx *storage.Connection) error { + for _, op := range ops { + m, _ := op.(map[string]any) + path := getString(m, "path") + // normalize path + switch path { + case "active", "path eq \"active\"": + val, _ := m["value"].(bool) + if val { + // restore by un-banning + user.BannedUntil = nil + if terr := user.UpdateBannedUntil(tx); terr != nil { + return terr + } + } else { + // ban for 100 years + t := time.Now().Add(100 * 365 * 24 * time.Hour) + user.BannedUntil = &t + if terr := user.UpdateBannedUntil(tx); terr != nil { + return terr + } + } + case "name.givenName": + if user.UserMetaData == nil { + user.UserMetaData = map[string]any{} + } + user.UserMetaData["given_name"] = getString(m, "value") + if terr := user.UpdateUserMetaData(tx, user.UserMetaData); terr != nil { + return terr + } + case "name.familyName": + if user.UserMetaData == nil { + user.UserMetaData = map[string]any{} + } + user.UserMetaData["family_name"] = getString(m, "value") + if terr := user.UpdateUserMetaData(tx, user.UserMetaData); terr != nil { + return terr + } + case "displayName": + if user.UserMetaData == nil { + user.UserMetaData = map[string]any{} + } + user.UserMetaData["display_name"] = getString(m, "value") + if terr := user.UpdateUserMetaData(tx, user.UserMetaData); terr != nil { + return terr + } + } + } + return nil + }) + if err != nil { + return err + } + } + + return scimSendJSON(w, http.StatusOK, a.toSCIMUser(user)) +} + +// Users delete (deprovision) +func (a *API) SCIMUsersDelete(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + idStr := chi.URLParam(r, "scim_user_id") + userID, err := uuid.FromString(idStr) + if err != nil { + return SCIMNotFound("User not found") + } + user, err := models.FindUserByID(db, userID) + if err != nil { + return SCIMNotFound("User not found") + } + if user.Aud != a.scimAudience() { + return SCIMNotFound("User not found") + } + + // Check provider isolation + providerID := getSCIMProvider(ctx) + if len(user.SCIMProviderID) > 0 && user.SCIMProviderID.String() != providerID { + return SCIMNotFound("User not found") + } + + if a.config.SCIM.BanOnDeactivate { + // ban long-term + t := time.Now().Add(100 * 365 * 24 * time.Hour) + user.BannedUntil = &t + if terr := user.UpdateBannedUntil(db); terr != nil { + return terr + } + } else { + // soft delete user and identities + if err := db.Transaction(func(tx *storage.Connection) error { + if terr := user.SoftDeleteUser(tx); terr != nil { + return terr + } + if terr := user.SoftDeleteUserIdentities(tx); terr != nil { + return terr + } + return nil + }); err != nil { + return err + } + } + + return scimSendJSON(w, http.StatusNoContent, nil) +} + +func (a *API) toSCIMUser(u *models.User) map[string]any { + baseURL := a.config.SCIM.BaseURL + if baseURL == "" { + baseURL = a.config.API.ExternalURL + } + emails := []any{} + if u.GetEmail() != "" { + emails = append(emails, map[string]any{"value": u.GetEmail(), "primary": true}) + } + active := !u.IsBanned() && u.DeletedAt == nil + return map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "id": u.ID.String(), + "externalId": func() any { + if len(u.SCIMExternalID) > 0 { + return u.SCIMExternalID.String() + } + return nil + }(), + "userName": u.GetEmail(), + "displayName": func() any { + if v, ok := u.UserMetaData["display_name"]; ok { + return v + } + return nil + }(), + "name": map[string]any{ + "givenName": u.UserMetaData["given_name"], + "familyName": u.UserMetaData["family_name"], + }, + "active": active, + "emails": emails, + "meta": map[string]any{ + "resourceType": "User", + "location": baseURL + "/scim/v2/Users/" + u.ID.String(), + "created": u.CreatedAt.Format(time.RFC3339), + "lastModified": u.UpdatedAt.Format(time.RFC3339), + }, + } +} + +func scimSendJSON(w http.ResponseWriter, status int, obj any) error { + w.Header().Set("Content-Type", "application/scim+json") + b, err := json.Marshal(obj) + if err != nil { + return err + } + w.WriteHeader(status) + _, err = w.Write(b) + return err +} + +func (a *API) scimUserLocation(id uuid.UUID) string { + baseURL := a.config.SCIM.BaseURL + if baseURL == "" { + baseURL = a.config.API.ExternalURL + } + return baseURL + "/scim/v2/Users/" + id.String() +} + +func getString(m map[string]any, k string) string { + if m == nil { + return "" + } + if v, ok := m[k]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// scimAudience returns a single audience context for SCIM operations. +// SCIM tokens are operator-level and should not be able to enumerate across audiences. +func (a *API) scimAudience() string { + if a.config.SCIM.DefaultAudience != "" { + return a.config.SCIM.DefaultAudience + } + return a.config.JWT.Aud +} diff --git a/internal/api/scim_admin.go b/internal/api/scim_admin.go new file mode 100644 index 000000000..5b1f66729 --- /dev/null +++ b/internal/api/scim_admin.go @@ -0,0 +1,256 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" +) + +// SCIMProviderCreateRequest is the request body for creating a SCIM provider +type SCIMProviderCreateRequest struct { + Name string `json:"name"` + Audience string `json:"audience,omitempty"` +} + +// SCIMProviderCreateResponse includes the generated token (only shown once) +type SCIMProviderCreateResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Audience string `json:"audience,omitempty"` + Token string `json:"token"` + CreatedAt string `json:"created_at"` +} + +// SCIMProviderResponse is the standard response for provider details (without token) +type SCIMProviderResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Audience string `json:"audience,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// SCIMProviderListResponse is the response for listing providers +type SCIMProviderListResponse struct { + Providers []SCIMProviderResponse `json:"providers"` + Total int `json:"total"` +} + +// SCIMProviderRotateTokenResponse includes the new token (only shown once) +type SCIMProviderRotateTokenResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Token string `json:"token"` + UpdatedAt string `json:"updated_at"` +} + +// AdminSCIMProviderCreate creates a new SCIM provider +func (a *API) AdminSCIMProviderCreate(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + var req SCIMProviderCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid request body") + } + + if req.Name == "" { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Provider name is required") + } + + // Check if provider with this name already exists + existing, _ := models.FindSCIMProviderByName(db, req.Name) + if existing != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Provider with this name already exists") + } + + // Generate a secure random token + token, err := generateSCIMToken() + if err != nil { + return apierrors.NewInternalServerError("Failed to generate token") + } + + // Create the provider + provider, err := models.NewSCIMProvider(req.Name, token, req.Audience) + if err != nil { + return apierrors.NewInternalServerError("Failed to create provider") + } + + // Save to database + if err := db.Create(provider); err != nil { + return apierrors.NewInternalServerError("Failed to save provider") + } + + // Return response with token (only time it's shown) + resp := SCIMProviderCreateResponse{ + ID: provider.ID, + Name: provider.Name, + Audience: provider.Audience, + Token: token, + CreatedAt: provider.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + + w.WriteHeader(http.StatusCreated) + return sendJSON(w, http.StatusCreated, resp) +} + +// AdminSCIMProviderList lists all SCIM providers +func (a *API) AdminSCIMProviderList(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + providers, err := models.FindAllSCIMProviders(db, 0, 0) + if err != nil { + return apierrors.NewInternalServerError("Failed to list providers") + } + + total, err := models.CountSCIMProviders(db) + if err != nil { + return apierrors.NewInternalServerError("Failed to count providers") + } + + responses := make([]SCIMProviderResponse, len(providers)) + for i, p := range providers { + responses[i] = SCIMProviderResponse{ + ID: p.ID, + Name: p.Name, + Audience: p.Audience, + CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: p.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + } + + resp := SCIMProviderListResponse{ + Providers: responses, + Total: total, + } + + return sendJSON(w, http.StatusOK, resp) +} + +// AdminSCIMProviderGet gets a specific SCIM provider +func (a *API) AdminSCIMProviderGet(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + providerID, err := uuid.FromString(chi.URLParam(r, "provider_id")) + if err != nil { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSSOProviderNotFound, "SCIM provider not found") + } + + provider, err := models.FindSCIMProviderByID(db, providerID) + if err != nil { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSSOProviderNotFound, "SCIM provider not found") + } + + resp := SCIMProviderResponse{ + ID: provider.ID, + Name: provider.Name, + Audience: provider.Audience, + CreatedAt: provider.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: provider.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + + return sendJSON(w, http.StatusOK, resp) +} + +// AdminSCIMProviderRotateToken rotates the token for a SCIM provider +func (a *API) AdminSCIMProviderRotateToken(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + providerID, err := uuid.FromString(chi.URLParam(r, "provider_id")) + if err != nil { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSSOProviderNotFound, "SCIM provider not found") + } + + var provider *models.SCIMProvider + err = db.Transaction(func(tx *storage.Connection) error { + p, terr := models.FindSCIMProviderByID(tx, providerID) + if terr != nil { + return terr + } + provider = p + + // Generate new token + newToken, terr := generateSCIMToken() + if terr != nil { + return terr + } + + // Update the token + if terr := provider.UpdateToken(tx, newToken); terr != nil { + return terr + } + + // Store token in response (we'll use it after transaction) + provider.PasswordHash = newToken // Temporarily store plaintext for response + return nil + }) + + if err != nil { + if _, ok := err.(models.SCIMProviderNotFoundError); ok { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSSOProviderNotFound, "SCIM provider not found") + } + return apierrors.NewInternalServerError("Failed to rotate token") + } + + resp := SCIMProviderRotateTokenResponse{ + ID: provider.ID, + Name: provider.Name, + Token: provider.PasswordHash, // This is the plaintext token we stored temporarily + UpdatedAt: provider.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + + return sendJSON(w, http.StatusOK, resp) +} + +// AdminSCIMProviderDelete soft-deletes a SCIM provider +func (a *API) AdminSCIMProviderDelete(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + providerID, err := uuid.FromString(chi.URLParam(r, "provider_id")) + if err != nil { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSSOProviderNotFound, "SCIM provider not found") + } + + err = db.Transaction(func(tx *storage.Connection) error { + provider, terr := models.FindSCIMProviderByID(tx, providerID) + if terr != nil { + return terr + } + + return provider.SoftDelete(tx) + }) + + if err != nil { + if _, ok := err.(models.SCIMProviderNotFoundError); ok { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSSOProviderNotFound, "SCIM provider not found") + } + return apierrors.NewInternalServerError("Failed to delete provider") + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// generateSCIMToken generates a cryptographically secure random token +func generateSCIMToken() (string, error) { + // Generate 32 bytes of random data + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + + // Encode as base64 URL-safe string (no padding) + token := base64.RawURLEncoding.EncodeToString(b) + return token, nil +} diff --git a/internal/api/scim_errors.go b/internal/api/scim_errors.go new file mode 100644 index 000000000..1e92caaca --- /dev/null +++ b/internal/api/scim_errors.go @@ -0,0 +1,131 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +// SCIMError represents a SCIM error response according to RFC 7644 Section 3.12 +type SCIMError struct { + Schemas []string `json:"schemas"` + Status string `json:"status"` + ScimType string `json:"scimType,omitempty"` + Detail string `json:"detail,omitempty"` + Meta *SCIMErrorMeta `json:"meta,omitempty"` +} + +type SCIMErrorMeta struct { + ResourceType string `json:"resourceType,omitempty"` +} + +const scimErrorSchema = "urn:ietf:params:scim:api:messages:2.0:Error" + +// Common SCIM error types +const ( + SCIMErrorTypeInvalidFilter = "invalidFilter" + SCIMErrorTypeInvalidPath = "invalidPath" + SCIMErrorTypeInvalidValue = "invalidValue" + SCIMErrorTypeTooMany = "tooMany" + SCIMErrorTypeUniqueness = "uniqueness" + SCIMErrorTypeMutability = "mutability" + SCIMErrorTypeInvalidSyntax = "invalidSyntax" + SCIMErrorTypeNoTarget = "noTarget" + SCIMErrorTypeSensitive = "sensitive" +) + +// NewSCIMError creates a new SCIM error +func NewSCIMError(status int, scimType, detail string) *SCIMError { + return &SCIMError{ + Schemas: []string{scimErrorSchema}, + Status: http.StatusText(status), + ScimType: scimType, + Detail: detail, + } +} + +// SCIMBadRequest returns a 400 Bad Request SCIM error +func SCIMBadRequest(scimType, detail string) error { + return &scimError{ + statusCode: http.StatusBadRequest, + err: NewSCIMError(http.StatusBadRequest, scimType, detail), + } +} + +// SCIMUnauthorized returns a 401 Unauthorized SCIM error +func SCIMUnauthorized(detail string) error { + return &scimError{ + statusCode: http.StatusUnauthorized, + err: NewSCIMError(http.StatusUnauthorized, "", detail), + } +} + +// SCIMForbidden returns a 403 Forbidden SCIM error +func SCIMForbidden(detail string) error { + return &scimError{ + statusCode: http.StatusForbidden, + err: NewSCIMError(http.StatusForbidden, "", detail), + } +} + +// SCIMNotFound returns a 404 Not Found SCIM error +func SCIMNotFound(detail string) error { + return &scimError{ + statusCode: http.StatusNotFound, + err: NewSCIMError(http.StatusNotFound, "", detail), + } +} + +// SCIMConflict returns a 409 Conflict SCIM error +func SCIMConflict(scimType, detail string) error { + return &scimError{ + statusCode: http.StatusConflict, + err: NewSCIMError(http.StatusConflict, scimType, detail), + } +} + +// SCIMInternalError returns a 500 Internal Server Error SCIM error +func SCIMInternalError(detail string) error { + return &scimError{ + statusCode: http.StatusInternalServerError, + err: NewSCIMError(http.StatusInternalServerError, "", detail), + } +} + +// scimError implements the error interface and holds both status code and SCIM error details +type scimError struct { + statusCode int + err *SCIMError +} + +func (e *scimError) Error() string { + return e.err.Detail +} + +func (e *scimError) StatusCode() int { + return e.statusCode +} + +func (e *scimError) SCIMError() *SCIMError { + return e.err +} + +// WriteSCIMError writes a SCIM error response +func WriteSCIMError(w http.ResponseWriter, err error) { + if scimErr, ok := err.(*scimError); ok { + w.Header().Set("Content-Type", "application/scim+json") + w.WriteHeader(scimErr.StatusCode()) + _ = json.NewEncoder(w).Encode(scimErr.SCIMError()) + return + } + + // Fallback for non-SCIM errors + w.Header().Set("Content-Type", "application/scim+json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(NewSCIMError(http.StatusInternalServerError, "", "Internal server error")) +} + +// IsSCIMError checks if an error is a SCIM error +func IsSCIMError(err error) bool { + _, ok := err.(*scimError) + return ok +} diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go new file mode 100644 index 000000000..db70fa62a --- /dev/null +++ b/internal/api/scim_test.go @@ -0,0 +1,390 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" +) + +func setupSCIMAPIForTest(t *testing.T) (*API, string) { + t.Helper() + api, cfg, err := setupAPIForTestWithCallback(func(c *conf.GlobalConfiguration, _ *storage.Connection) { + if c != nil { + c.SCIM.Enabled = true + if c.API.ExternalURL == "" { + c.API.ExternalURL = "http://localhost" + } + // point DB to test env credentials + c.DB.URL = "postgres://supabase_auth_admin:root@localhost:5432/postgres" + } + }) + require.NoError(t, err) + t.Cleanup(func() { _ = api.db.Close() }) + // Ensure DB clean + require.NoError(t, models.TruncateAll(api.db)) + _ = cfg + + // Create a test SCIM provider with unique name per test + providerName := "test-provider-" + t.Name() + provider, err := models.NewSCIMProvider(providerName, "testtoken", "authenticated") + require.NoError(t, err) + require.NoError(t, api.db.Create(provider)) + + return api, "testtoken" +} + +func TestSCIM_ServiceProviderConfig(t *testing.T) { + api, token := setupSCIMAPIForTest(t) + + req := httptest.NewRequest(http.MethodGet, "/scim/v2/ServiceProviderConfig", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var body map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + require.Contains(t, body["schemas"], "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig") +} + +func TestSCIM_UsersLifecycle(t *testing.T) { + api, token := setupSCIMAPIForTest(t) + + // Create user + create := map[string]any{ + "userName": "scim.user@example.com", + "displayName": "SCIM User", + "name": map[string]any{ + "givenName": "SCIM", + "familyName": "User", + }, + "externalId": "ext-123", + } + var buf bytes.Buffer + require.NoError(t, json.NewEncoder(&buf).Encode(create)) + req := httptest.NewRequest(http.MethodPost, "/scim/v2/Users", &buf) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + var created map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&created)) + id := created["id"].(string) + require.NotEmpty(t, id) + + // Get user and assert active=true + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/scim/v2/Users/%s", id), nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var got map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&got)) + require.Equal(t, true, got["active"]) + + // Patch deactivate (active=false) + patch := map[string]any{ + "Operations": []any{ + map[string]any{ + "op": "replace", + "path": "active", + "value": false, + }, + }, + } + buf.Reset() + require.NoError(t, json.NewEncoder(&buf).Encode(patch)) + req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/scim/v2/Users/%s", id), &buf) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var patched map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&patched)) + require.Equal(t, false, patched["active"]) // now disabled + + // Delete (ban / soft deprovision) + req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/scim/v2/Users/%s", id), nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusNoContent, w.Code) + + // Verify in DB: user still exists, not hard-deleted, banned + uid := uuid.FromStringOrNil(id) + u, err := models.FindUserByID(api.db, uid) + require.NoError(t, err) + require.Nil(t, u.DeletedAt) + require.True(t, u.IsBanned()) + + // GET should still return the user with active=false (soft state) + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/scim/v2/Users/%s", id), nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var afterDel map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&afterDel)) + require.Equal(t, false, afterDel["active"]) // stays disabled +} + +func TestSCIM_AuthRequired(t *testing.T) { + api, _ := setupSCIMAPIForTest(t) + req := httptest.NewRequest(http.MethodGet, "/scim/v2/Users", nil) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusForbidden, w.Code) +} + +func TestSCIM_SchemasAndResourceTypes(t *testing.T) { + api, token := setupSCIMAPIForTest(t) + + // Schemas + req := httptest.NewRequest(http.MethodGet, "/scim/v2/Schemas", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + // ResourceTypes + req = httptest.NewRequest(http.MethodGet, "/scim/v2/ResourceTypes", nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) +} + +func TestSCIM_UsersPagination(t *testing.T) { + api, token := setupSCIMAPIForTest(t) + + createUser := func(email string) { + body := map[string]any{ + "userName": email, + } + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(body) + req := httptest.NewRequest(http.MethodPost, "/scim/v2/Users", &buf) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + } + createUser("a@example.com") + createUser("b@example.com") + + req := httptest.NewRequest(http.MethodGet, "/scim/v2/Users?startIndex=1&count=1", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var list map[string]any + _ = json.NewDecoder(w.Body).Decode(&list) + require.Equal(t, float64(1), list["itemsPerPage"]) // JSON numbers decode to float64 +} + +// Sets up API with SCIM enabled and a fixed DefaultAudience. +func setupSCIMSecurityAPI(t *testing.T) (*API, string) { + t.Helper() + api, _, err := setupAPIForTestWithCallback(func(c *conf.GlobalConfiguration, _ *storage.Connection) { + if c != nil { + c.SCIM.Enabled = true + c.SCIM.DefaultAudience = "tenantA" + if c.API.ExternalURL == "" { + c.API.ExternalURL = "http://localhost" + } + c.DB.URL = "postgres://supabase_auth_admin:root@localhost:5432/postgres" + } + }) + require.NoError(t, err) + t.Cleanup(func() { _ = api.db.Close() }) + require.NoError(t, models.TruncateAll(api.db)) + + // Create a test SCIM provider with unique name per test + providerName := "test-provider-security-" + t.Name() + provider, err := models.NewSCIMProvider(providerName, "secr", "tenantA") + require.NoError(t, err) + require.NoError(t, api.db.Create(provider)) + + return api, "secr" +} + +// Ensure listing via SCIM does not return users belonging to another audience. +func TestSCIM_ListDoesNotLeakOtherAudience(t *testing.T) { + api, token := setupSCIMSecurityAPI(t) + + // Create a user in tenantA via SCIM + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(map[string]any{"userName": "a@example.com"}) + req := httptest.NewRequest(http.MethodPost, "/scim/v2/Users", &buf) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + // Create a user in another audience (tenantB) directly in DB + other, err := models.NewUser("", "b@example.com", "", "tenantB", nil) + require.NoError(t, err) + require.NoError(t, api.db.Create(other)) + + // List via SCIM should only include tenantA user + req = httptest.NewRequest(http.MethodGet, "/scim/v2/Users?startIndex=1&count=50", nil) + req.Header.Set("Authorization", "Bearer "+token) + w = httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var list map[string]any + _ = json.NewDecoder(w.Body).Decode(&list) + resources := list["Resources"].([]any) + require.Len(t, resources, 1) +} + +// Ensure filters cannot fetch a user from another audience. +func TestSCIM_FilterOtherAudienceNoResults(t *testing.T) { + api, token := setupSCIMSecurityAPI(t) + + // Create user in other audience directly + other, err := models.NewUser("", "cross@example.com", "", "tenantB", nil) + require.NoError(t, err) + require.NoError(t, api.db.Create(other)) + + // Filter by userName eq other email should return 0 for tenantA-scoped SCIM + req := httptest.NewRequest(http.MethodGet, "/scim/v2/Users?filter="+url.QueryEscape("userName eq \"cross@example.com\""), nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var list map[string]any + _ = json.NewDecoder(w.Body).Decode(&list) + require.Equal(t, float64(0), list["totalResults"]) // JSON numbers decode to float64 +} + +// Ensure request headers cannot force audience switching during SCIM operations. +func TestSCIM_HeaderAudIgnored(t *testing.T) { + api, token := setupSCIMSecurityAPI(t) + + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(map[string]any{"userName": "hdr@example.com"}) + req := httptest.NewRequest(http.MethodPost, "/scim/v2/Users", &buf) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set(audHeaderName, "tenantB") + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + // Confirm the created user belongs to tenantA (DefaultAudience), not tenantB + var created map[string]any + _ = json.NewDecoder(w.Body).Decode(&created) + id := created["id"].(string) + uid := uuid.FromStringOrNil(id) + u, err := models.FindUserByID(api.db, uid) + require.NoError(t, err) + require.Equal(t, "tenantA", u.Aud) +} + +// This test verifies that a SCIM-provisioned user (non-SSO) remains separate from an SSO user +// created during a SAML flow for the same email, and that deprovisioning via SCIM does not ban the SSO user. +func TestSCIMSAML_UserSeparationAndDeprovision(t *testing.T) { + api, _, err := setupAPIForTestWithCallback(func(c *conf.GlobalConfiguration, _ *storage.Connection) { + if c != nil { + c.SCIM.Enabled = true + if c.API.ExternalURL == "" { + c.API.ExternalURL = "http://localhost" + } + c.DB.URL = "postgres://supabase_auth_admin:root@localhost:5432/postgres" + } + }) + require.NoError(t, err) + t.Cleanup(func() { _ = api.db.Close() }) + require.NoError(t, models.TruncateAll(api.db)) + + // Create a test SCIM provider with token "tok" and unique name + providerName := "test-provider-saml-" + t.Name() + scimProvider, err := models.NewSCIMProvider(providerName, "tok", "authenticated") + require.NoError(t, err) + require.NoError(t, api.db.Create(scimProvider)) + + // 1) Provision user via SCIM + email := "samlscim@example.com" + body := map[string]any{"userName": email, "displayName": "SCIM+SAML"} + var buf bytes.Buffer + require.NoError(t, json.NewEncoder(&buf).Encode(body)) + req := httptest.NewRequest(http.MethodPost, "/scim/v2/Users", &buf) + req.Header.Set("Authorization", "Bearer tok") + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + var created map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&created)) + scimID := created["id"].(string) + require.NotEmpty(t, scimID) + + // 2) Simulate SAML login with same email -> should create separate SSO user + ssoProviderID := uuid.Must(uuid.NewV4()).String() + upd := provider.UserProvidedData{} + upd.Emails = append(upd.Emails, provider.Email{Email: email, Verified: true, Primary: true}) + claims := &provider.Claims{Subject: uuid.Must(uuid.NewV4()).String(), Issuer: "entity-id", Email: email, EmailVerified: true} + upd.Metadata = claims + + // Use a dummy request with correct audience context + sreq := httptest.NewRequest(http.MethodGet, "http://localhost/", nil) + + // Run in a transaction to mimic SAML ACS behavior + err = api.db.Transaction(func(tx *storage.Connection) error { + // providerType must be in sso: form to scope linking domain + _, _, terr := api.createAccountFromExternalIdentity(tx, sreq, &upd, "sso:"+ssoProviderID, false) + return terr + }) + require.NoError(t, err) + + // 3) Verify there are two users with same email: one non-SSO (SCIM), one SSO + users, err := models.FindUsersInAudience(api.db, api.config.JWT.Aud, nil, nil, "") + require.NoError(t, err) + var nonSSO, sso *models.User + for _, u := range users { + if u.GetEmail() == email { + if u.IsSSOUser { + sso = u + } else { + nonSSO = u + } + } + } + require.NotNil(t, nonSSO) + require.NotNil(t, sso) + require.Equal(t, nonSSO.ID.String(), scimID) + require.False(t, nonSSO.IsSSOUser) + require.True(t, sso.IsSSOUser) + + // 4) Deprovision SCIM user (DELETE via SCIM) -> only SCIM user should be banned, SSO user stays active + req = httptest.NewRequest(http.MethodDelete, "/scim/v2/Users/"+nonSSO.ID.String(), nil) + req.Header.Set("Authorization", "Bearer tok") + w = httptest.NewRecorder() + api.handler.ServeHTTP(w, req) + require.Equal(t, http.StatusNoContent, w.Code) + + // Reload both users + nonSSO, err = models.FindUserByID(api.db, nonSSO.ID) + require.NoError(t, err) + sso, err = models.FindUserByID(api.db, sso.ID) + require.NoError(t, err) + + require.True(t, nonSSO.IsBanned()) + require.False(t, sso.IsBanned()) +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index b7024e7c5..d27607eb0 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -352,6 +352,7 @@ type GlobalConfiguration struct { MFA MFAConfiguration `json:"MFA"` SAML SAMLConfiguration `json:"saml"` CORS CORSConfiguration `json:"cors"` + SCIM SCIMConfiguration `json:"scim"` Experimental ExperimentalConfiguration `json:"experimental"` Reloading ReloadingConfiguration `json:"reloading"` @@ -381,6 +382,16 @@ func (c *CORSConfiguration) AllAllowedHeaders(defaults []string) []string { return result } +// SCIMConfiguration holds configuration for the SCIM server. +type SCIMConfiguration struct { + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url" split_words:"true"` + DefaultAudience string `json:"default_audience" split_words:"true"` + BanOnDeactivate bool `json:"ban_on_deactivate" split_words:"true" default:"true"` +} + +func (c *SCIMConfiguration) Validate() error { return nil } + // EmailContentConfiguration holds the configuration for emails, both subjects and template URLs. type EmailContentConfiguration struct { Invite string `json:"invite"` @@ -1248,6 +1259,7 @@ func (c *GlobalConfiguration) Validate() error { &c.Sessions, &c.Hook, &c.JWT.Keys, + &c.SCIM, } for _, validatable := range validatables { diff --git a/internal/models/connection.go b/internal/models/connection.go index 82a5e8775..cd89bce60 100644 --- a/internal/models/connection.go +++ b/internal/models/connection.go @@ -50,6 +50,7 @@ func TruncateAll(conn *storage.Connection) error { (&pop.Model{Value: FlowState{}}).TableName(), (&pop.Model{Value: OneTimeToken{}}).TableName(), (&pop.Model{Value: OAuthServerClient{}}).TableName(), + (&pop.Model{Value: SCIMProvider{}}).TableName(), } for _, tableName := range tables { diff --git a/internal/models/scim_provider.go b/internal/models/scim_provider.go new file mode 100644 index 000000000..ba272d807 --- /dev/null +++ b/internal/models/scim_provider.go @@ -0,0 +1,167 @@ +package models + +import ( + "context" + "time" + + "github.com/gofrs/uuid" + "github.com/pkg/errors" + "github.com/supabase/auth/internal/crypto" + "github.com/supabase/auth/internal/storage" + "golang.org/x/crypto/bcrypt" +) + +// SCIMProvider represents a SCIM provider configuration for enterprise customer isolation +type SCIMProvider struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + PasswordHash string `json:"-" db:"password_hash"` + Audience string `json:"audience,omitempty" db:"audience"` + + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"` +} + +// TableName returns the database table name for SCIMProvider +func (SCIMProvider) TableName() string { + return "scim_providers" +} + +// NewSCIMProvider creates a new SCIM provider with a hashed token +func NewSCIMProvider(name, token, audience string) (*SCIMProvider, error) { + if name == "" { + return nil, errors.New("provider name is required") + } + if token == "" { + return nil, errors.New("provider token is required") + } + + id, err := uuid.NewV4() + if err != nil { + return nil, errors.Wrap(err, "failed to generate provider ID") + } + + // Hash the token using crypto package + hash, err := crypto.GenerateFromPassword(context.Background(), token) + if err != nil { + return nil, errors.Wrap(err, "failed to hash provider token") + } + + now := time.Now() + return &SCIMProvider{ + ID: id, + Name: name, + PasswordHash: hash, + Audience: audience, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// Authenticate verifies a token against the provider's stored hash +func (p *SCIMProvider) Authenticate(token string) error { + if p.DeletedAt != nil { + return errors.New("provider has been deleted") + } + + err := bcrypt.CompareHashAndPassword([]byte(p.PasswordHash), []byte(token)) + if err != nil { + return errors.New("invalid token") + } + return nil +} + +// UpdateToken updates the provider's token hash +func (p *SCIMProvider) UpdateToken(tx *storage.Connection, newToken string) error { + hash, err := crypto.GenerateFromPassword(context.Background(), newToken) + if err != nil { + return errors.Wrap(err, "failed to hash new token") + } + + p.PasswordHash = hash + p.UpdatedAt = time.Now() + + return tx.UpdateOnly(p, "password_hash", "updated_at") +} + +// SoftDelete marks the provider as deleted +func (p *SCIMProvider) SoftDelete(tx *storage.Connection) error { + now := time.Now() + p.DeletedAt = &now + p.UpdatedAt = now + + return tx.UpdateOnly(p, "deleted_at", "updated_at") +} + +// SCIMProviderNotFoundError is returned when a SCIM provider is not found +type SCIMProviderNotFoundError struct{} + +func (e SCIMProviderNotFoundError) Error() string { + return "SCIM provider not found" +} + +// FindSCIMProviderByID finds a provider by ID +func FindSCIMProviderByID(conn *storage.Connection, id uuid.UUID) (*SCIMProvider, error) { + var provider SCIMProvider + err := conn.Q().Where("id = ? AND deleted_at IS NULL", id).First(&provider) + if err != nil { + return nil, SCIMProviderNotFoundError{} + } + return &provider, nil +} + +// FindSCIMProviderByName finds a provider by name +func FindSCIMProviderByName(conn *storage.Connection, name string) (*SCIMProvider, error) { + var provider SCIMProvider + err := conn.Q().Where("name = ? AND deleted_at IS NULL", name).First(&provider) + if err != nil { + return nil, SCIMProviderNotFoundError{} + } + return &provider, nil +} + +// FindSCIMProviderByToken finds a provider by verifying the token against all active providers +// This is less efficient but necessary for token-based authentication +func FindSCIMProviderByToken(conn *storage.Connection, token string) (*SCIMProvider, error) { + var providers []*SCIMProvider + err := conn.Q().Where("deleted_at IS NULL").All(&providers) + if err != nil { + return nil, errors.Wrap(err, "failed to query providers") + } + + for _, provider := range providers { + if provider.Authenticate(token) == nil { + return provider, nil + } + } + + return nil, errors.New("no provider found with matching token") +} + +// FindAllSCIMProviders returns all non-deleted providers +func FindAllSCIMProviders(conn *storage.Connection, page, perPage uint64) ([]*SCIMProvider, error) { + var providers []*SCIMProvider + + q := conn.Q().Where("deleted_at IS NULL").Order("created_at DESC") + + if page > 0 && perPage > 0 { + // Validate bounds before converting to int to prevent overflow + if page > uint64(^uint(0)>>1) || perPage > uint64(^uint(0)>>1) { + return nil, errors.New("page or perPage value exceeds maximum int value") + } + q = q.Paginate(int(page), int(perPage)) + } + + err := q.All(&providers) + if err != nil { + return nil, errors.Wrap(err, "failed to query providers") + } + + return providers, nil +} + +// CountSCIMProviders returns the total count of non-deleted providers +func CountSCIMProviders(conn *storage.Connection) (int, error) { + return conn.Q().Where("deleted_at IS NULL").Count(&SCIMProvider{}) +} diff --git a/internal/models/user.go b/internal/models/user.go index 068b0c970..d751c65d9 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -64,11 +64,13 @@ type User struct { Factors []Factor `json:"factors,omitempty" has_many:"factors"` Identities []Identity `json:"identities" has_many:"identities"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - BannedUntil *time.Time `json:"banned_until,omitempty" db:"banned_until"` - DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"` - IsAnonymous bool `json:"is_anonymous" db:"is_anonymous"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + BannedUntil *time.Time `json:"banned_until,omitempty" db:"banned_until"` + DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"` + IsAnonymous bool `json:"is_anonymous" db:"is_anonymous"` + SCIMExternalID storage.NullString `json:"scim_external_id,omitempty" db:"scim_external_id"` + SCIMProviderID storage.NullString `json:"scim_provider_id,omitempty" db:"scim_provider_id"` DONTUSEINSTANCEID uuid.UUID `json:"-" db:"instance_id"` } diff --git a/migrations/20250902211151_add_scim_providers_table.up.sql b/migrations/20250902211151_add_scim_providers_table.up.sql new file mode 100644 index 000000000..a0814f928 --- /dev/null +++ b/migrations/20250902211151_add_scim_providers_table.up.sql @@ -0,0 +1,23 @@ +-- Create scim_providers table for SCIM provider authentication +create table if not exists {{ index .Options "Namespace" }}.scim_providers ( + id uuid not null, + name text not null, + password_hash text not null, + audience text null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz null, + constraint scim_providers_pkey primary key (id), + constraint scim_providers_name_key unique (name), + constraint scim_providers_name_length check (char_length(name) <= 255) +); + +-- Create indexes +create index if not exists scim_providers_name_idx + on {{ index .Options "Namespace" }}.scim_providers (name); + +create index if not exists scim_providers_deleted_at_idx + on {{ index .Options "Namespace" }}.scim_providers (deleted_at); + +create index if not exists scim_providers_audience_idx + on {{ index .Options "Namespace" }}.scim_providers (audience); \ No newline at end of file diff --git a/migrations/20250902211322_add_scim_external_id_column.up.sql b/migrations/20250902211322_add_scim_external_id_column.up.sql new file mode 100644 index 000000000..753c2afbf --- /dev/null +++ b/migrations/20250902211322_add_scim_external_id_column.up.sql @@ -0,0 +1,8 @@ +-- Add scim_external_id column to users table +alter table {{ index .Options "Namespace" }}.users +add column if not exists scim_external_id text null; + +-- Create index for fast lookups by SCIM external ID +create index if not exists users_scim_external_id_idx + on {{ index .Options "Namespace" }}.users (scim_external_id) + where scim_external_id is not null; \ No newline at end of file diff --git a/migrations/20250902211429_add_scim_provider_id_column.up.sql b/migrations/20250902211429_add_scim_provider_id_column.up.sql new file mode 100644 index 000000000..5993fefce --- /dev/null +++ b/migrations/20250902211429_add_scim_provider_id_column.up.sql @@ -0,0 +1,8 @@ +-- Add scim_provider_id column to users table for provider isolation +alter table {{ index .Options "Namespace" }}.users +add column if not exists scim_provider_id text null; + +-- Create index for fast lookups by SCIM provider ID +create index if not exists users_scim_provider_id_idx + on {{ index .Options "Namespace" }}.users (scim_provider_id) + where scim_provider_id is not null; \ No newline at end of file