Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,11 @@ GOTRUE_SMS_TEST_OTP_VALID_UNTIL="<ISO date time>" # (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"
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
7 changes: 7 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions internal/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
45 changes: 45 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions internal/api/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
3 changes: 3 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Loading